REST API reference
Links, stats, unlocks and payouts as JSON, plus signed webhooks for every event. One base address, one key, one error shape.
On this page
The basics
Baseevery path below hangs off ithttps://montvo.com/api/v1AuthorizationrequiredBearer sk_live_…— your secret key, from the Developers screen. Shown once when it is made and never again; making a new one revokes the old.Content-Typeon requests with a bodyapplication/jsonEncoding- JSON in, JSON out. Timestamps are ISO 8601 in UTC; money is a number in US dollars.
Two keys, and only one of them belongs here
pk_live_…), which is printed into a public page and opens exactly one endpoint — see the script endpoint reference. Present it to anything on this page and the answer is the same 401 a made-up key gets.Endpoints
| Method | Path | What it does |
|---|---|---|
POST | /links | Make a paid link. |
GET | /links | List yours, newest first. |
GET | /links/:slug | One link. |
DELETE | /links/:slug | Remove one, and its history. |
GET | /links/:slug/stats | Unlocks and earnings, by window. |
GET | /unlocks/:token | Whether one visitor finished. |
POST | /unlocks/:token/claim | The same, and only once. |
GET | /unlocks | Finished trips under one reference. |
GET | /payouts | Balance and what has been sent. |
GET | /me | Who the key belongs to. |
Links
The pair the API exists for: make a link, and find the ones you have. A link made here is filed under source API, which is what tells it apart from one you typed into the dashboard or one your site script created.
POST /links
urlrequiredstring- Where the visitor ends up.
httporhttps, and never a Montvo address — a link cannot point at Montvo. aliasstring- The name you want:
montvo.link/your-alias. Three to forty characters, lower-case letters, digits and dashes, dashes between characters rather than at either end. Left out, a six-character name is derived from the address. levelstring- How hard the link monetizes: light (1 ad), balanced (2 ads), max (3 ads). Left out, your account default from Settings is used.
titlestring- What the gate calls it. The only description a visitor sees. Trimmed to 80 characters.
curl -X POST https://montvo.com/api/v1/links \ -H "Authorization: Bearer $MONTVO_KEY" \ -H "Content-Type: application/json" \ -d '{ "url": "https://example.com/download", "alias": "spring-pack", "level": "max" }'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
201, with the link:
{ "id": "0f5b1c2e-6a44-4c0e-9d21-8b0f7a5e9c31", "slug": "spring-pack", "short_url": "https://montvo.link/spring-pack", "url": "https://example.com/download", "title": null, "level": "max", "status": "active", "kind": "link", "source": "API", "created_at": "2026-09-12T10:12:04.218Z"}source is one of YouTube, Discord, Telegram, Reddit, Website, Newsletter, GitHub, Twitch, API, Other; kind is link for one you made and script for one your site tag made; status is active, paused or disabled.
const res = await fetch("https://montvo.com/api/v1/links", { method: "POST", headers: { Authorization: `Bearer ${process.env.MONTVO_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ url: "https://example.com/download", alias: "spring-pack", level: "max", }),}); const link = await res.json();console.log(link.short_url); // https://montvo.link/spring-packUse 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
An alias somebody else has is a 409
422.GET /links
limit1–100, default 25- How many to return.
cursorstring- The
next_cursorfrom the page before. Newest first, paged by the created time of the last row rather than by an offset — an offset shifts under you while you page and silently skips rows.
# The first page, and then the next one.curl "https://montvo.com/api/v1/links?limit=50" \ -H "Authorization: Bearer $MONTVO_KEY" curl "https://montvo.com/api/v1/links?limit=50&cursor=2026-09-12T10:12:04.218Z" \ -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 is { "data": [ … ], "next_cursor": … }, each entry shaped exactly like the one POST /links returns. next_cursor is null on the last page.
GET and DELETE /links/:slug
GET answers with the one link; DELETE answers 204 with no body.
Deleting keeps the earnings
A slug that belongs to somebody else answers 404 rather than 403. A refusal would confirm it exists.
GET /links/:slug/stats
rangeone of 24h, 7d, 30d, 3m — default 7d- The window to report over. The same windows the analytics screen offers.
{ "slug": "spring-pack", "range": "7d", "empty": false, "totals": { "unlocks": 1840, "earnings": 5.2314, "cpm": 2.8432, "unlock_rate": 0.7213 }, "series": { "labels": ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"], "unlocks": [210, 264, 301, 288, 254, 262, 261], "earnings": [0.5972, 0.7508, 0.8559, 0.8191, 0.7223, 0.7451, 0.741] }, "by_country": [ { "name": "United States", "unlocks": 812, "earnings": 3.1044, "share": 0.4413 } ], "by_device": [ { "name": "Mobile", "unlocks": 1203, "earnings": 3.2841, "share": 0.6538 } ]}This reads the same function the dashboard's analytics screen does, so the API and the screen cannot quote different numbers for the same link. empty says out loud that nothing was measured in the window, rather than leaving you to infer it from a row of zeroes. share is a fraction of the window's unlocks, not a percentage.
Unlocks
Three endpoints for one question: did a particular visitor actually finish the gate? Put your own reference on the link you share, and you can either wait for the visitor to arrive at your destination carrying a token, or hold the reference yourself and ask us about it while they are still away.
| Call | Answers |
|---|---|
GET /unlocks/:token | One completion, in full. Idempotent — ask as often as you like. |
POST /unlocks/:token/claim | The same, and only the first time. 409 afterwards, so a reward cannot be collected twice. |
GET /unlocks?sub=… | { completed, billable, data } for one reference. What a creator polls while a visitor is away — and the way round a destination that can carry nothing back. |
The whole flow, with the parts that decide a reward, is on its own page: verifying an unlock — along with a complete example app to start from.
Payouts and account
GET /payouts — what is owed and what has been sent. Read-only, and it always will be: moving money is not something an API key should be able to start.
{ "balance": 41.8302, "minimum": 5, "wallet": "bc1q…7x2f", "next_payout_date": "2026-09-18", "lifetime": { "total": 1204.5, "count": 14, "fees": 3.8112 }, "data": [ { "id": "b41f…", "run_date": "2026-09-11", "period_start": "2026-09-05", "period_end": "2026-09-11", "amount": 86.4, "btc": 0.00081, "fee": 0.27, "status": "sent", "txid": "9c2f…" } ]}GET /me — who the key belongs to, and what it is allowed. The first call worth making when wiring one up, because it either works or tells you why not.
{ "id": "0f5b1c2e-6a44-4c0e-9d21-8b0f7a5e9c31", "name": "Ada", "username": "ada", "joined_at": "2026-04-02T09:14:51.002Z", "key": { "kind": "secret", "rate_limit_per_minute": 600 }}Webhooks
Point one HTTPS endpoint at your server and Montvo posts to it as things happen, rather than you polling for them. Set it up on the Developers screen, where the endpoint, its secret and the event picker live.
| Event | Fires when |
|---|---|
link.created | From the dashboard, the API or your site script. |
unlock.recorded | Thousands a day on a busy link. |
payout.sent | Every Friday, once the BTC is on its way. |
referral.joined | A new creator under your referral link. |
unlock.recorded carries the completion token and your reference when the link was opened with one, so a creator listening here does not have to wait to be asked:
{ "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"}Checking a delivery came from us
Every delivery carries Montvo-Signature and Montvo-Timestamp. What is signed is the timestamp and the body joined, never the body alone — without the timestamp in the signed string, one captured delivery can be replayed at you for ever and still verify.
import crypto from "node:crypto"; app.post("/montvo", express.raw({ type: "*/*" }), (req, res) => { const stamp = req.header("Montvo-Timestamp"); const signed = `${stamp}.${req.body.toString("utf8")}`; const mine = "sha256=" + crypto.createHmac("sha256", process.env.MONTVO_WEBHOOK_SECRET) .update(signed) .digest("hex"); // Reject anything older than five minutes, so a captured // delivery cannot be replayed later. const fresh = Math.abs(Date.now() / 1000 - Number(stamp)) < 300; if (!fresh || mine !== req.header("Montvo-Signature")) { return res.sendStatus(401); } res.sendStatus(200);});A delivery that fails gets 3 attempts before it is called failed. Answer 2xx quickly and do your work afterwards: the delivery log keeps 7 days, and it is on the Developers screen beside the request log.
Errors
One shape for the whole API, so a caller writes one branch for failure rather than one per endpoint:
{ "error": { "code": "invalid_level", "message": "level has to be light, balanced or max." }}| Status | Code | What happened |
|---|---|---|
400 | invalid_body | The body is not JSON, or is not a JSON object. |
401 | unauthorized | No Authorization header, or a key that is not a live secret key. |
404 | not_found | Nothing of yours has that name. Also the answer for something that exists and is not yours. |
409 | slug_taken | That alias belongs to somebody. Retry with another. |
409 | already_claimed | That completion has already been claimed. The message says when. |
422 | invalid_url, invalid_alias, invalid_level, invalid_title, invalid_link, invalid_limit, invalid_cursor, invalid_range, invalid_sub | Something in the request was not what the endpoint takes. The message names it. |
429 | rate_limited | Over the limit for this minute. Retry-After says how many seconds to wait. |
500 | server_error | Ours. Retrying is safe. |
Limits, and what is logged
600 calls a minute per secret key. Every answer to a key we recognized carries X-RateLimit-Limit and X-RateLimit-Remaining; a 429 adds Retry-After.
Every call is logged, and it is worth knowing exactly what that means:
- What is kept: the method, the path with its query string, the status, how long it took, and a short note when something failed.
- What is not: the body you sent. We do not store it.
- For how long: 7 days, after which it is swept. You can read it on the Developers screen.