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.
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.
The first request runs. What a repeat gets depends on the request it carries the key with:
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
5xxanswered before anything was written releases the key, so a retry runs the write again. - A
5xxanswered after the write was made keeps the key, without the answer. Every repeat is the409codedIDEMPOTENCY_ANSWER_UNAVAILABLE, whosestatusnames the5xx: 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 atlocationall 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:
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-Matchand 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:
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.
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 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.
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.
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:
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:
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 Redirectto the canonical path, query string kept, forGETandHEAD. On the Map Studio API every other method is answered404, 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 write405before the credential is read, and anOPTIONSthat is not a preflight405after it. The308carriesLocationand no error envelope. - Methods. The Map Studio API answers
GET,POST,PATCH, andDELETE, and there is noPUT: a toggled state is a resource of its own, and an action is aPOSTto a verb under the resource it acts on. The Tile API answersGETandHEAD, and the Data APIPOST. A method a path does not take is405 Method Not Allowed, and itsAllowheader names the methods it does. - HEAD. Every path that takes
GETtakesHEAD, answered with the headers theGETwould carry and no body. On the Tile API aHEADis limited as itsGETis and never counted as usage, since it serves no tile, and neither is a specification. AHEADon an event stream opens no subscription.
Headers on every answer
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.