Get started
Get started

Requests and responses

Requests and responses

A handful of rules hold on every operation rather than on one, so no operation's reference repeats them. They cover the headers a request sends and the headers every answer carries, how a write is made safe to retry, how a listing pages, and what a limit tells you before it refuses you.

The Map Studio API takes every one of them. The Tile API and the Data API only read, so the write rules stop at the Map Studio API, and each section says where the surfaces differ.

Quote the request id

Every answer on every surface carries X-Request-Id: a success, a refusal, a redirect, and the opening of a stream alike. It is 32 lowercase hexadecimal characters, chosen by Mountaya. A value you send, in that header or in a trace context, is never echoed back.

HTTP/1.1 404 Not Found
X-Request-Id: 4bf92f3577b34da6a3ce929d0e0e4736

Log it beside every failure, and quote it when you report a problem: it is what finds one request among the rest. The TypeScript client carries it as requestId on every error the API answered, and ends the error's message with it. A MountayaConnectionError carries none, since no answer came.

Send writes with an idempotency key

Every POST, PATCH and DELETE on the Map Studio API takes an Idempotency-Key header, except POST /v1/sessions, POST /v1/organizations/lookup, and the two collaboration signals, POST /v1/itineraries/{itinerary_id}/collab and POST /v1/zones/{zone_id}/collab, which restate where their sender is and are safe to send again without one. Choose one value per logical write and send it again, unchanged, on every retry of that write. A UUID is the usual choice; any 1 to 255 printable ASCII characters without a space are accepted.

curl -X POST https://api.mountaya.com/v1/itineraries \
  -H "X-API-Key: pk_your_publishable_key" \
  -H "X-Session-Token: sess_your_session_token" \
  -H "Idempotency-Key: 6f1c0a9e-2d47-4b83-9e5a-1c7b3f08d264" \
  -H "Content-Type: application/json" \
  -d '{"name": "Tour du Mont-Blanc", "activity_id": "a1c4e7b2-3d59-4f08-8b6a-2e5c9d0f7134"}'

The first request runs. What a repeat gets depends on the request it carries the key with:

You send the key againThe answer
With a different request400 Bad Request, at ["request", "headers", "Idempotency-Key"], whether or not the first one still runs. A key names one write, and this is checked first.
With the same request, after the first one finishedThe first answer, replayed: its status, its body, its Location and ETag, with Idempotent-Replayed: true. Nothing runs twice, and no limit counts it again. A first answer that cannot be given again is the one exception, in the last row.
With the same request, while the first one still runs409 Conflict, coded IDEMPOTENCY_KEY_IN_USE, with Retry-After: 1. Wait the second and send it again.
With the same request, after a first one whose write was made and whose answer cannot be given again409 Conflict, coded IDEMPOTENCY_ANSWER_UNAVAILABLE, on every repeat. The first answer was too large to keep whole, or was a 5xx answered after the write was made. extensions.metadata carries the status it was answered with, from 200 to 599, and its location and etag where it had them. Read the resource again rather than sending the write once more.

Two requests are the same when their method, path, query, and body are, and the media type of their Content-Type with its charset. If-Match is not compared, so a write sent again with a fresh tag after a 412 takes a new key: under the old one, it is answered the 412 again.

A key is held for 12 hours, for the credential that sent it and for that operation. Every answer below 500 other than a 429 is stored, a refusal included, so a retried request that was refused is refused again in the same words. A 429 stores nothing. What a 5xx leaves depends on whether the write was made before it:

  • A 5xx answered before anything was written releases the key, so a retry runs the write again.
  • A 5xx answered after the write was made keeps the key, without the answer. Every repeat is the 409 coded IDEMPOTENCY_ANSWER_UNAVAILABLE, whose status names the 5xx: the write landed, so read the resource rather than make it twice. Creating an organization, an invitation, a publishable key, an itinerary or a copy of one names what it made at location all the same, so carry on with that one.

An answer too large to keep whole is kept the same way, without its body. The TypeScript client raises that 409 as MountayaIdempotentAnswerUnavailableError and never asks again. Keys are best-effort: while Mountaya cannot record one, the write runs as if it carried none.

The key is what makes a retried delete safe to read. A DELETE that removed a resource, repeated after the first one landed, finds nothing left and answers 404 (a revoked key and a removed share link answer 200 again). Sent with the first one's key, it replays the first 200 instead.

Each operation also states x-idempotent in the reference: whether repeating it without a key can have a second effect. A key makes a write that takes one safe to repeat, which is why the TypeScript client mints one for every such write and reuses it on every retry.

Make a write conditional

Eight resources carry an etag: Itinerary, ItineraryWithDetails, Zone, Collection, Organization, OrganizationSettings, PublishableKey, and AiProviderConfig. Every answer that returns one carries the same value in an ETag header. It is an opaque tag in double quotes, such as "d42", that moves whenever what that read returns moves. Compare it whole, and never parse it.

Send it back as If-Match and the write lands only if nobody changed the resource since you read it:

curl -X PATCH https://api.mountaya.com/v1/itineraries/$ITINERARY_ID \
  -H "X-API-Key: pk_your_publishable_key" \
  -H "X-Session-Token: sess_your_session_token" \
  -H "Idempotency-Key: 0d93b5e2-7a14-4c6f-8e21-5b9a7c3d40f8" \
  -H 'If-Match: "d42"' \
  -H "Content-Type: application/json" \
  -d '{"name": "Tour du Mont-Blanc, reversed"}'

If-Match is taken by the PATCH and DELETE of each of those resources, by every write to an itinerary's segments, segment groups, waypoints, and publication, by the publication of a zone (POST and DELETE /v1/zones/{zone_id}/publication), and by every write to what a collection holds. It names one tag, or * for any version. A list of tags or a weak W/ tag is a 400.

When the resource has moved on, the write is refused with 412 Precondition Failed, coded PRECONDITION_FAILED. Nothing is changed, and the answer's ETag names the current version. Read the resource again, decide whether your change still applies, and send it with the new tag: the same tag is refused the same way. The condition is asked last: a stale tag is answered only for a write that would otherwise go through. A resource you cannot see answers 404, a role that may not make the write 403, a refused body 400, a conflict 409, and a ceiling its LIMIT_REACHED, each before the tag is compared.

  • An itinerary's tag covers what lies under it. Its read returns its segments, waypoints, and publication, so a write to any of them moves the itinerary's tag.
  • An organization's settings carry their own tag. Renaming the organization never refuses a change to its settings, and the reverse.
  • Omit If-Match and the last write wins. The condition is yours to ask for, never imposed.

Page through a listing

Every listing of what an organization or its members create pages by cursor: itineraries, zones, collections and what a collection holds, members, invitations, publishable keys, invoices, payment methods, and your own memberships and invitations under /v1/me. A catalog, such as activities or layers, and a usage series return the whole of what they cover.

A listing that pages takes limit, between 1 and 100 and 50 when omitted, and cursor. It reports its page at extensions.metadata.pagination:

{
  "data": [{ "id": "0b6d1a4e-6d3f-4f7a-9c2e-1f0a5b8c3d21", "name": "Tour du Mont Blanc" }],
  "extensions": {
    "metadata": {
      "pagination": { "limit": 50, "next_cursor": "eyJ2IjoxLCJzIjoiLXVwZGF0ZWRfYXQifQ" }
    }
  }
}

Send next_cursor back as cursor to read the next page. The last page reports "next_cursor": null, and that is the only way the end is told: a page shorter than limit, or one holding no item at all, can still have one after it, since a page is cut before what your credential may not see is left out of it. Follow every cursor a page names.

curl "https://api.mountaya.com/v1/itineraries?status=published&sort=-updated_at&cursor=eyJ2IjoxLCJzIjoiLXVwZGF0ZWRfYXQifQ" \
  -H "X-API-Key: pk_your_publishable_key"

A cursor is opaque and at most 1024 characters. It is bound to the operation, the filters, and the sort of the request that issued it, and to the scope it listed. On getItineraries, getZones, and getCollections it is also bound to the read tier of the credential it was issued to: a key alone, or a key with a session token naming a user. A session token naming nobody reads on the key's tier. Ask for the next page the same way: a cursor sent otherwise, or altered, is a 400 at ["request", "query", "cursor"]. Paging follows the position in the sort order rather than a count, so an item whose sort key does not change is never skipped or served twice, whatever is created or deleted between two pages.

Filters are query parameters named after the field they narrow, such as organization_id, status, or is_private, and they combine: an item is listed only when it matches every one you send. Two are shared across listings. updated_since lists what changed at or after an instant, and q matches part of a name without regard to case. A filter narrows what your credential reaches and never widens it.

sort takes one key from the list the operation declares, descending when written with a leading -, and ties break on id. Each operation's reference names its keys and its default, such as -updated_at on itineraries, zones, and collections.

A query parameter the operation does not declare is a 400. Sending offset or page is refused, not ignored.

Respect the rate limits

Every 429 Too Many Requests, on every surface, carries Retry-After: the whole seconds until the limit lifts, at least 1. For a per-minute limit it is the seconds left in the minute, at most 60; for a daily bound, such as the organizations one user creates or the invitations one organization sends, the seconds until the next day begins in UTC, up to 86,400; for a spent monthly allowance, the seconds until the next calendar month begins in UTC. Wait that long, with a little jitter of your own, then send the request again. With an Idempotency-Key, sending a write again is safe.

A limit counts a request when it arrives, so a refused request counts in its window too. Every limit is best-effort: a request that cannot be counted at that moment is allowed rather than refused, so treat a 429 as the signal rather than a count to plan against.

"Per calling address" counts the requests from one client address, an IPv6 address counted by its /64 prefix, so the addresses of one network share a count.

On the Data API and the Tile API, only your own clients and the hosted embed count toward the per-minute limits, the monthly allowances, and the invoice: your SDK or any other client calling with your keys, the MCP server, and the tiles the hosted embed draws on your key, which your usage shows apart. An Embedded Studio on its studio-embedded session, Map Studio in the Mountaya web application, and share links are recorded apart, so your usage shows them, and held to abuse bounds of their own instead. The Tile API's base map counts toward none of them, on any surface: see what the Tile API counts. Rate limiting lists every figure.

See a limit coming

The Data API and the Tile API also send the IETF rate-limit fields on every answer the limits weighed, under a key, and on the Tile API under a share token as well: a success, a 429, and a refusal made after the limits. A refusal made before them, for the credential, the calling page, the product (402), or the key's scope (403), carries neither, since no limit was weighed for it.

RateLimit-Policy: "minute";q=600;w=60, "month";q=2000000;w=2678400
RateLimit: "minute";r=412;t=23, "month";r=1873540;t=1036800

RateLimit-Policy names each limit the request was counted against, with its quota q and its window w in seconds. RateLimit gives, in the same order, the requests left r and the seconds until the window resets t. On a 429, r reads 0.

PolicyWhat it counts
minuteThe organization's per-minute limit for its own clients and the hosted embed, across all of its keys.
monthThe monthly allowance for its own clients and the hosted embed, listed only where the organization's agreement sets one.
addressThe bound on one calling address: on the Tile API, for a key sent without a session token or with one naming nobody, the hosted embed's included, and for a share token; on both APIs, for a token minted for studio-embedded that names nobody.
memberThe bound on the member a token minted for studio-embedded names.
studio-embeddedThe bound every token minted for studio-embedded under the organization's keys shares.
shareOn the Tile API, the bound every share link of the organization that owns the link shares.

Neither field is sent on a tile a shared cache may store, which is the same for every caller, so the fields would show one organization's figures to everyone; a copy a shared cache serves never reaches Mountaya and counts toward no limit. A figure that cannot be read at that moment leaves its entry out. The Map Studio API sends neither: it answers a 429 with Retry-After all the same.

The Map Studio API's limits

"Per user" counts the requests of the user the session token names. "Per organization" counts every request of the organization's keys together, or of its share links for a request made with a share token.

What is limitedOperationsCeiling
Reads under a key alone, or with a session token naming nobodyevery GET600 a minute per calling address, then 6,000 a minute per organization; a token naming nobody is held as the key alone is
Reads under a key with a session token naming a userevery GET1,200 a minute per user, then 6,000 a minute per organization, counted apart from the reads made under a key alone
Reads under a share tokenevery GET that takes a share token600 a minute per calling address, then 6,000 a minute per organization that owns the link
Organization creationPOST /v1/organizations10 a minute and 50 a day per user
Importpreview and import10 a minute per user, 60 per organization
Routed segment writescreate, update, split, merge, and convert a segment120 a minute per user, 1,200 per organization
Zone writescreate and update a zone30 a minute per user, 300 per organization
Agent run startsPOST /v1/agent/runs10 a minute per user, 100 per organization
Agent run inputsPOST /v1/agent/runs/{run_id}/input120 a minute per user, 1,200 per organization
Collaboration signalsthe collab signal of an itinerary or a zone1,200 a minute per user
Stream opensthe four event streams60 a minute per user, 1,200 per organization
Open streamsthe four event streams20 open at once per user, 50 per calling address, 1,000 per organization; an open past any of them is a 429 with Retry-After, and a stream gives its place back when it ends
Invitationsinviting by email30 a minute per user, and 200 a day per organization, those sent from the Mountaya web application included
Billing reads the payment provider answerspayment methods, invoices, promotion code, and opening billing (provisioning), which checks its promotion code as the preview does60 a minute per user, 300 per organization, one allowance for the four
Billing writes the payment provider makessetting up and saving a card, choosing the default card, detaching a card, running and ending the Data API subscription, and updating the billing details30 a minute per user, 300 per organization, one allowance for the seven, apart from the billing reads'
Unknown share tokensevery read that takes a share tokena calling address presenting too many unknown share tokens within a minute; a token resolved moments earlier is never refused for it
Unknown publishable keysevery operation that takes a publishable key60 unknown keys refused a minute per calling address, a key refused again within seconds counted once; a key resolved moments earlier is never refused for it

Of the two ceilings a row of reads names, the first is checked first, and a read it refuses spends nothing of the second, so one address or one user never spends the whole of the organization's 6,000 a minute. The organization's 200 invitations a day are counted only once the caller is known to be one of its owners or admins, so a caller who may not invite never spends them. A replayed idempotent request is never counted again. The Data API's and the Tile API's per-minute limits, the Tile API's bound per calling address, and the counters that guard POST /v1/sessions, are on the authentication page.

A person signed in to the Mountaya web application, rather than calling with a key or a link, counts toward none of the organization's per-minute limits or monthly allowances. They are held to bounds of their own, counted per person, their reads at 3,000 a minute per person, and the limits above hold them all the same, per person rather than per organization, while the invitations they send count toward the organization's 200 a day.

The routing a Map Studio write does (a routed segment, an import, a zone computed from a source) is recorded as studio-embedded data usage under a key, and as studio-online from the Mountaya web application. It is held to the limits above, never to the Data API's: it counts toward no Data API limit, allowance, or invoice.

Changes take effect on the next request

Mountaya answers every request by the state it reads as the request arrives. Revoking a key, narrowing its scopes, taking an itinerary's or a zone's publication down, and removing or rotating a share link govern the very next request Mountaya answers.

A shared cache between your visitors and Mountaya, such as a CDN or a corporate proxy, is another matter: an answer it already holds is served until the lifetime its Cache-Control names runs out. Only public answers are ever stored that way, and each says so:

AnswerCache-ControlServed from a shared cache for at most
A published itinerary read, and its pacepublic, max-age=300, stale-while-revalidate=606 minutes
A published zone readpublic, max-age=300, stale-while-revalidate=606 minutes
The Map Studio API's layer catalog, GET /v1/layerspublic, max-age=86400, stale-while-revalidate=360025 hours
A public tile or specification on the Tile API, and its layer catalog under a key or a share tokenpublic, max-age=3005 minutes
A base-map answer on the Tile API: a terrain style, its sources, sprites and glyphs, the elevation rasteras Mountaya's base-map provider labels itthe provider's lifetime

Every other answer is private, so no shared cache holds it.

Know how long a request may run

Each operation states its time budget as x-timeout-ms in the reference: 30 seconds, and 55 on the import preview and the import, which route a whole file. Past it the request is answered 504 Gateway Timeout, coded GATEWAY_TIMEOUT, whatever was in flight. Wait a little longer than the budget before you give up on an attempt, so you never abandon an answer that was coming.

The four event streams run under no budget: a stream ends with the session token it was opened with instead, as authentication explains. The Tile API runs every request under a budget too. The Data API answers every request within 30 seconds: one still running then is a 504 coded GATEWAY_TIMEOUT, with no data, whatever had resolved, and its root fields count toward no allowance and no invoice.

Send only the fields an operation declares

Request bodies are closed. A field an object does not declare is a 400 naming it, rather than being ignored, so a misspelled field is reported instead of silently dropped:

{
  "errors": [
    {
      "message": "property \"is_privat\" is unsupported",
      "path": ["request", "body", "is_privat"],
      "extensions": { "code": "VALIDATION_FAILED" }
    }
  ]
}

A message raised by this validation is in English whatever Accept-Language asks for, so branch on extensions.code and path rather than on the text. Every string, number, and array a request carries is bounded, and the bound is declared where the field is. The few open maps by design, such as an agent run's context, declare how many entries and how many bytes they hold.

Paths, methods, and HEAD

  • Canonical paths. A path written other than in its canonical form, with a trailing slash or an empty or dot segment, is answered 308 Permanent Redirect to the canonical path, query string kept, for GET and HEAD. On the Map Studio API every other method is answered 404, so a write is never redirected into a read. The Tile API takes no other method on any path, so it answers one there as on the canonical path: a write 405 before the credential is read, and an OPTIONS that is not a preflight 405 after it. The 308 carries Location and no error envelope.
  • Methods. The Map Studio API answers GET, POST, PATCH, and DELETE, and there is no PUT: a toggled state is a resource of its own, and an action is a POST to a verb under the resource it acts on. The Tile API answers GET and HEAD, and the Data API POST. A method a path does not take is 405 Method Not Allowed, and its Allow header names the methods it does.
  • HEAD. Every path that takes GET takes HEAD, answered with the headers the GET would carry and no body. On the Tile API a HEAD is limited as its GET is and never counted as usage, since it serves no tile, and neither is a specification. A HEAD on an event stream opens no subscription.

Headers on every answer

HeaderSent on
X-Request-Idevery answer, on every surface
WWW-Authenticateevery 401: ApiKey realm="mountaya", or ShareToken realm="mountaya" for a request made with a share token. It says what to send, never why the credential was refused.
Allowevery 405
Retry-Afterevery 429, and the 409 coded IDEMPOTENCY_KEY_IN_USE
Locationevery 201, naming what was created, and every 308
ETagevery answer that returns a resource carrying an etag, and the 412 that refuses a stale If-Match
Idempotent-Replayedtrue on a write's stored answer, replayed
RateLimit, RateLimit-Policyevery Data API and Tile API answer the limits weighed, under a key or a share token

A JSON answer that declares no Cache-Control of its own is private, no-store, and a stream is no-cache. A browser script on another origin can read Retry-After, RateLimit, RateLimit-Policy, X-Request-Id, ETag, Location, Content-Disposition, Idempotent-Replayed, and Allow: the APIs expose them to CORS.

In the TypeScript client

The client applies these rules for you: it mints an Idempotency-Key for every write that takes one and resends it on every retry, sends the ifMatch you pass, waits out Retry-After, walks a listing's cursors with paginate until one reads null, and reports each answer's request id, rate-limit state, and whether it was replayed to its onResponse hook, and the first two on every error.

Next steps