Script API

Endpoint reference

The single call the site script makes: a batch of destinations in, a short link each out. Documented for anyone building pages the script cannot reach — server-rendered HTML, a native app, a static build step.

On this page

The call

Addresses in, a short link each out. It is the only endpoint a site key opens, and the only request the site script ever makes.

POSTmethod
https://montvo.com/api/v1/site/links
Authorizationrequired
Bearer pk_live_… — your site key, from the Developers screen. A secret key is refused here.
Content-Typeexpected
application/json
Request
curl -X POST https://montvo.com/api/v1/site/links \  -H "Authorization: Bearer pk_live_9f2c4b8a" \  -H "Content-Type: application/json" \  -d '{    "urls": [      "https://example.com/download",      "https://store.example.com/hoodie"    ]  }'

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

The request body

urlsrequiredstring[]
Between one and 50 web addresses. Anything in the list that is not a string, or is not an address we can shorten, is dropped quietly rather than failing the whole call — the answer tells you which ones survived.

Addresses are normalized and deduplicated before anything is created, so sending the same destination twice in one call, or written two different ways, produces one link.

What will not be shortened

  • Anything that is not http or https.
  • Anything on montvo.com or montvo.link, or a subdomain of either. A Montvo link cannot point at Montvo.
  • Anything that does not parse as a web address at all.
  • An address whose derived slug is already taken. That one is skipped; the rest of the call is unaffected.

The response

200, with one object: links, keyed by the address exactly as you sent it.

200 OK
{  "links": {    "https://example.com/download": "https://montvo.link/105010",    "https://store.example.com/hoodie": "https://montvo.link/1gjybr"  }}

Keyed by what you sent

Not by what we stored. We normalize an address before we look it up — a missing trailing slash, an uppercase host — and keying the answer by the normalized form would make every caller repeat that work to find their own entry.

An address that could not be shortened is simply absent from links. Read it with a fallback to the original rather than assuming every key you sent comes back.

Errors

One shape for every failure, so a caller writes one branch rather than one per case:

422 Unprocessable Content
{  "error": {    "code": "too_many_urls",    "message": "Send at most 50 addresses at a time."  }}
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 site key. The same answer either way, on purpose: a key found in a public page should not be able to map out which endpoints exist.
422invalid_urlsurls is missing, empty, or not an array — or nothing in it was an address we can shorten.
422too_many_urlsMore than 50 addresses. Split the list and call twice.
429rate_limitedOver the limit for this minute. Retry-After says how many seconds to wait.
500server_errorOurs. Nothing was created; retrying is safe.

Every call, including the ones that failed, shows up in the request log on your Developers screen with its status and how long it took. The log keeps 7 days.

Rate limit

3,000 calls a minute, per site key. That ceiling is set by your traffic rather than by your code — a site key is called once by every visitor who lands on a page — so it is deliberately far above the secret key's, and it is counted separately. A busy afternoon is not supposed to look like an attack.

X-RateLimit-Limit
On every answer to a key we recognized — a 401 carries none of these. 3,000 for a site key.
X-RateLimit-Remaining
Calls left in the current minute.
Retry-After429 only
Seconds until the window resets.

Calling it from a browser

Any origin may call this endpoint. That is the point of a tag on somebody else's page, and the reason the site key is a different kind of key from the secret one: it opens nothing worth stealing.

  • The answer carries Access-Control-Allow-Origin for the calling origin, and Vary: Origin.
  • OPTIONS is answered with 204 and a 24-hour Access-Control-Max-Age, so the preflight costs a visitor one request rather than one per page.
  • Authorization and Content-Type are the allowed headers; POST and OPTIONS the allowed methods.

Or just use the tag

If your pages are HTML a browser can walk, the site script already does all of this — the batching, the caching, the second pass for links added later — from a single tag.

Calling it from a server

A page rendered on a server has no anchors for a browser script to find, so the swap has to happen while the HTML is being built. Ask for the short links, then write them into the markup:

Server-side
const urls = [  "https://example.com/download",  "https://store.example.com/hoodie",]; const res = await fetch("https://montvo.com/api/v1/site/links", {  method: "POST",  headers: {    Authorization: `Bearer ${process.env.MONTVO_SITE_KEY}`,    "Content-Type": "application/json",  },  body: JSON.stringify({ urls }),}); // An address we could not shorten is simply absent from the// answer, so fall back to the original rather than to undefined.const { links } = await res.json();const paid = (url) => links[url] ?? url;

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

Cache what comes back. A short link for a given destination does not change, and asking again on every render spends your rate limit on an answer you already have.

Server-side is a different exposure

A site key in a server environment variable is not published to anyone, which is a better position to be in — but it is still a site key, and a build that ships it into client-side JavaScript has published it after all. Either is survivable; neither is a secret key.
Something here wrong, or missing?Tell us →