REST API

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 it
https://montvo.com/api/v1
Authorizationrequired
Bearer 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 body
application/json
Encoding
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

The other is the site key (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

MethodPathWhat it does
POST/linksMake a paid link.
GET/linksList yours, newest first.
GET/links/:slugOne link.
DELETE/links/:slugRemove one, and its history.
GET/links/:slug/statsUnlocks and earnings, by window.
GET/unlocks/:tokenWhether one visitor finished.
POST/unlocks/:token/claimThe same, and only once.
GET/unlocksFinished trips under one reference.
GET/payoutsBalance and what has been sent.
GET/meWho the key belongs to.

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.

urlrequiredstring
Where the visitor ends up. http or https, 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.
Request
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:

201 Created
{  "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.

The same, from a server
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-pack

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

An alias somebody else has is a 409

Slugs are one namespace across the whole platform, so a name being taken is not a bug in your request — retry with another rather than treating it as a failure. Anything that was never allowed in the first place, such as a reserved word or a name with a space in it, comes back 422.
limit1–100, default 25
How many to return.
cursorstring
The next_cursor from 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.
Paging
# 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 answers with the one link; DELETE answers 204 with no body.

Deleting keeps the earnings

A deleted link stops resolving and leaves your list, and what it earned stays on your balance. It is not undone from here, its slug stays reserved so the address cannot be recreated pointing elsewhere, and its unlocks go on counting towards your account totals. Pause instead if you may want it back.

A slug that belongs to somebody else answers 404 rather than 403. A refusal would confirm it exists.

rangeone of 24h, 7d, 30d, 3m — default 7d
The window to report over. The same windows the analytics screen offers.
200 OK
{  "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.

CallAnswers
GET /unlocks/:tokenOne completion, in full. Idempotent — ask as often as you like.
POST /unlocks/:token/claimThe 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.

GET /payouts
{  "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.

GET /me
{  "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.

EventFires when
link.createdFrom the dashboard, the API or your site script.
unlock.recordedThousands a day on a busy link.
payout.sentEvery Friday, once the BTC is on its way.
referral.joinedA 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:

unlock.recorded
{  "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.

Express
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:

422 Unprocessable Content
{  "error": {    "code": "invalid_level",    "message": "level has to be light, balanced or max."  }}
StatusCodeWhat happened
400invalid_bodyThe body is not JSON, or is not a JSON object.
401unauthorizedNo Authorization header, or a key that is not a live secret key.
404not_foundNothing of yours has that name. Also the answer for something that exists and is not yours.
409slug_takenThat alias belongs to somebody. Retry with another.
409already_claimedThat completion has already been claimed. The message says when.
422invalid_url, invalid_alias, invalid_level, invalid_title, invalid_link, invalid_limit, invalid_cursor, invalid_range, invalid_subSomething in the request was not what the endpoint takes. The message names it.
429rate_limitedOver the limit for this minute. Retry-After says how many seconds to wait.
500server_errorOurs. 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.
Something here wrong, or missing?Tell us →