Verifying an unlock
Your own system needs to know that a particular visitor really finished the gate — not that somebody did, and not that they guessed the destination. Put a reference on the link, and check the token they arrive with.
On this page
What this is for
You run your own flow — a step list, a reward, a download that should only be released once — and a Montvo link is one of the steps. Your server cannot see our gate, so on its own it has no way to tell somebody who sat through the ads from somebody who guessed the destination and typed it in.
So the link carries your reference out, and the gate carries a token back. The token is minted by us, at the one moment that proves the trip happened, and can only be read with your secret key. Nothing a visitor can type produces one.
# what you share, with your own reference on ithttps://montvo.link/spring-pack?sub=user_42 # where the visitor lands, once they have finishedhttps://example.com/download?montvo_token=Z6mprxAzwMBl9m5FlH9xWHyt3EYqXhgdBmLtv8EjL_cWould rather start from code that already works? There is a complete example app at the end of this page.
Three ways to be told
The mechanism above is one of three shapes, and they differ only in who is holding what, and when. Pick by what your destination is and whether your server can be reached from the internet.
| Pattern | What you hold | Best when |
|---|---|---|
| The token on the destination | Nothing, until they arrive. | The destination is a page you control, and one extra request at the end is cheaper than waiting. |
| A ticket you issue then poll | The ticket, before they ever leave. | Your own flow is sitting on a step waiting for an answer — and for a destination that can carry nothing back: a file download, an app, somebody else's page. |
| The webhook we call you | Nothing. You are told. | You can receive an HTTPS request, and would rather not poll at all. |
For a step-based flow, use the second and the third together
unlock.recorded webhook tell you the moment it happens — it carries the ticket back as sub. Polling is then only the fallback for when you cannot receive a delivery, such as on a laptop.Setting it up
- 1
Put your own reference on the link you share, as
?sub=. A fresh one-time value per visit is the one to reach for — see why — though anything your system calls the person will work. One per visitor is what makes the answer about them rather than about your audience.https://montvo.link/spring-pack?sub=user_42 - 2
The visitor goes through the gate as normal. When they reach your destination, we add
montvo_tokento the address:https://example.com/download?montvo_token=Z6mprxAzwMBl9m5FlH9xWHyt3EYqXhgdBmLtv8EjL_c - 3
Your server reads the token and asks us about it, with your secret key. Never from the browser: a key in a page is a key anybody can read, and it would let a visitor answer their own question.
curl https://montvo.com/api/v1/unlocks/$MONTVO_TOKEN \ -H "Authorization: Bearer $MONTVO_KEY"Use your secret key (
sk_live_…). Keep it on your server — never in a page, a repository or a browser bundle.Both are on Developers in the dashboard · Which key when
The answer
{ "token": "Z6mprxAzwMBl9m5FlH9xWHyt3EYqXhgdBmLtv8EjL_c", "completed": true, "billable": true, "claimed": false, "claimed_at": null, "sub": "user_42", "slug": "spring-pack", "short_url": "https://montvo.link/spring-pack", "url": "https://example.com/download", "country": "US", "device": "mobile", "completed_at": "2026-09-15T10:12:04.218Z"}completedalways true- The token exists, so the trip happened. There is no token for a visit that did not.
billableboolean- Whether the traffic checks found the visit worth paying for. This is the field a reward should turn on — see below.
claimedboolean- Whether the claim endpoint has already been used on this token.
claimed_attimestamp or null- When that happened.
substring- Your own reference, exactly as it arrived on the link.
slug, short_url, url- Which link it was, and where it sent them.
country, device- What we billed it as. Same values the analytics screen groups by.
completed_attimestamp- When the visitor left the gate for your destination.
A token we do not recognize answers 404, and so does one belonging to another account or one that has aged out. A caller can do nothing differently with any of the three, and telling them apart would let somebody walk tokens to find which are real.
The token is the proof. The reference is not.
sub is a value a visitor could edit in their own address bar before they set off, so read it as “who I sent this link to” rather than as proof of who arrived. If a reward depends on the identity, pair the two: reward the sub the answer names, and check it against the session you expected — never grant against a sub a visitor chose for themselves.Completed is not the same as billable
A bot that sits through every ad and clicks Continue has completed the gate. It has not earned you anything — the traffic checks decided no advertiser would pay for it, and it is counted as filtered rather than paid.
So the two fields answer two different questions, and the one you want is almost always the second:
| Field | Means | Use it for |
|---|---|---|
completed | They reached your destination. | Unlocking a page, resuming a flow. |
billable | An advertiser paid for the visit. | Anything that costs you money: credits, rewards, a paid download. |
Rewarding on completed alone
billable.Claiming once
Reading a token is idempotent: ask as often as you like, including after a request of yours timed out. That also means reading it twice tells you nothing about whether you have already paid out on it — and the visitor still has the address it arrived on, so refreshing the page is free.
When a reward is at stake, claim instead of reading. The first call succeeds; every one after it answers 409, with the time of the one that counted.
curl -X POST https://montvo.com/api/v1/unlocks/$MONTVO_TOKEN/claim \ -H "Authorization: Bearer $MONTVO_KEY"Use your secret key (sk_live_…). Keep it on your server — never in a page, a repository or a browser bundle.
Both are on Developers in the dashboard · Which key when
It is a POST rather than a flag on the GET for a reason: a request that changes something is a request a browser, a proxy or somebody's retry loop will make on your behalf.
app.get("/unlocked", async (req, res) => { const token = req.query.montvo_token; if (!token) return res.sendStatus(400); // POST, not GET: this is the call that may only succeed once. const check = await fetch( `https://montvo.com/api/v1/unlocks/${token}/claim`, { method: "POST", headers: { Authorization: `Bearer ${process.env.MONTVO_KEY}` } } ); // 404 no such token · 409 already claimed · 200 yours to give. if (!check.ok) return res.sendStatus(check.status === 409 ? 409 : 403); const unlock = await check.json(); // completed says they reached the end. billable says an advertiser // paid for it — which is the one a reward should turn on. if (!unlock.billable) return res.sendStatus(403); await grantReward(unlock.sub); res.sendStatus(200);});Use your secret key (sk_live_…). Keep it on your server — never in a page, a repository or a browser bundle.
Both are on Developers in the dashboard · Which key when
With the site script
The site script rewrites links for you, so there is no address for you to append a reference to. Put it on the tag instead, and every link the script writes carries it:
<script src="https://montvo.com/m.js" data-key="pk_live_9f2c4b8a" data-exclude="shop.mysite.com" data-sub="{{ current_user.id }}" defer></script>Use your site key (pk_live_…). Made to be public: it goes into your page and can only create links.
Both are on Developers in the dashboard · Which key when
Fill data-sub from whatever renders the page — your template's session, a line of script above the tag. Leave it out entirely and nothing changes: no reference, no token, and the destination is handed over exactly as written.
Holding a ticket, and waiting
Nothing has to survive the trip to the destination. Generate a fresh one-time value, use it as the reference, and you are holding the thing you will ask about before the visitor has even clicked — which is what a flow that sits on a step actually wants.
// 1. Before you hand the link over: a ticket only you could have issued.const ticket = crypto.randomUUID();await tickets.put(ticket, { user: session.id, at: Date.now() }); const link = `https://montvo.link/spring-pack?sub=${ticket}`; // 2. While they are away, ask whether it is done. From YOUR server —// a secret key in a browser is a secret key anybody can read.const res = await fetch( `https://montvo.com/api/v1/unlocks?sub=${ticket}`, { headers: { Authorization: `Bearer ${process.env.MONTVO_KEY}` } }); const status = await res.json(); // completed: they reached the destination.// billable: an advertiser paid for it — the one a reward turns on.if (status.billable) { await tickets.delete(ticket); await advanceStep(session.id);}Use your secret key (sk_live_…). Keep it on your server — never in a page, a repository or a browser bundle.
Both are on Developers in the dashboard · Which key when
The answer is the same two fields whether or not anything has happened yet:
// still waiting{ "sub": "6b1f…", "completed": false, "billable": false, "data": [] } // done{ "sub": "6b1f…", "completed": true, "billable": true, "data": [ { "token": "Z6mprx…", "billable": true, "…": "…" } ]}curl "https://montvo.com/api/v1/unlocks?sub=6b1f4d02-9c3a-4f71-bb08-2e5d7a1c4e90" \ -H "Authorization: Bearer $MONTVO_KEY"Use your secret key (sk_live_…). Keep it on your server — never in a page, a repository or a browser bundle.
Both are on Developers in the dashboard · Which key when
Nothing yet is a plain false rather than a 404: “they have not finished” and “there is no such reference” are the same sentence to somebody who is still waiting.
Why a fresh value, and not your user id
A reference you generate per visit does two things a user id cannot, and both of them matter more than they look:
- It makes the answer about one trip. The lookup reports what has happened under a reference. Reuse
user_42across a month and it will happily tell you they finished — last Tuesday. A ticket you issued a minute ago cannot say that. - It cannot be guessed or borrowed. The reference sits on a URL the visitor can edit, so a predictable one lets somebody claim a step under an id that is not theirs. A random ticket you have to have issued closes that: one you never handed out is one you do not recognize.
Check the ticket back against your own record
How often to poll
Polls come out of the same 600 calls a minute as everything else your secret key does, and one waiting visitor is not one request — it is one request every few seconds for as long as they are watching ads.
| Poll every | Per waiting visitor | Visitors you can wait on at once |
|---|---|---|
2s | 30 calls a minute | about 20 |
3s | 20 calls a minute | about 30 |
5s | 12 calls a minute | about 50 |
Which is fine for a flow a handful of people are in at a time, and not fine for a busy one. Poll no faster than every three seconds, give up after a few minutes rather than for ever — the gate takes seconds, not hours — and if you are waiting on more than a few dozen people at once, take the webhook instead.
Poll from your server, never from the page
Being told instead
The unlock.recorded webhook carries both the reference and the token, so your server hears about it the moment it happens, with nothing to poll and nothing for the visitor to carry:
{ "link_id": "0f5b1c2e-6a44-4c0e-9d21-8b0f7a5e9c31", "slug": "spring-pack", "short_url": "https://montvo.link/spring-pack", "country": "US", "device": "mobile", "referrer": "youtube.com", "earnings": 0.00294, "sub": "user_42", "token": "Z6mprxAzwMBl9m5FlH9xWHyt3EYqXhgdBmLtv8EjL_c", "at": "2026-09-15T10:12:04.218Z"}Set the endpoint up on the Developers screen; the signature check is on the reference. One caveat worth knowing before you build only on this: the event fires for unlocks that billed. A completion that the traffic checks filtered is still recorded and still readable by token or by reference — it just does not raise this.
If you need a record for longer than a day
A complete example
montvo/Montvo.com-api-example is a small Next.js app that puts this page together in one place, and deploys to Vercel as it is. The visitor opens your link in a new tab, and the page they left waits and finds out whether they finished:
- A fresh ticket per attempt, kept in an httpOnly cookie, and the link opened with
?sub=in a new tab. GET /unlocks?sub=…from its own server, polled only while the page is visible, straight away when the visitor comes back, and the moment your destination page says they have arrived.billablebefore anything is given, a claim before the reward, and the secret key nowhere near the browser.- A
/diagnosticspage that callsGET /mefrom wherever the app is deployed — which is the test that counts, because a call that works from your laptop can still be stopped on its way from a cloud host.
git clone https://github.com/montvo/Montvo.com-api-examplecd Montvo.com-api-examplecp .env.example .env.local # your secret key and your linknpm install && npm run devThe home page tells you which address to give your link as its destination, for wherever you are running it — localhost included, since the redirect happens in your own browser.
Your second test run will not bill
billable: false — which is the example reading the rule correctly, not failing. Test again from another network or with another link, or after a day. A popup blocker stopping the advert window, or a VPN, does the same.The rules, stated plainly
| What a reference may be | Up to 64 characters of letters, digits and _ - . : @. Anything else is treated as absent — no reference, no token, and the link still works. |
| Who can read a completion | The secret key of the account that owns the link, and nothing else. A site key gets the same 401 a made-up key does. |
| How long it lasts | 24 hours from the moment the visitor reached your destination. After that it is gone — deleted, not archived — and answers 404 to a read and to a claim alike. Verify while the visit is still happening, which is what every one of these patterns does anyway. |
| One trip, one token | A pass redeemed twice — a double click, a back button, a browser prefetching the redirect — produces the same token both times rather than two completions. |
| What we store | Your reference, which link it was, whether it billed, the country and device, and the time. No address, and nothing about the visitor we would not already have counted. |
| Whether it costs anything | No. A link with a reference on it earns exactly what the same link earns without one; this changes nothing a visitor sees or a rate card says. |