Use the API client
@mountaya/client is the only place a Mountaya HTTP request is constructed. It holds your credential and attaches the right headers per surface. It retries what is worth retrying, and raises a typed error for everything else. @mountaya/client-react puts it behind a provider and TanStack Query hooks.
Construct a client
One instance holds one credential and one set of policies. Give it a publishable key and it reaches public data from anywhere.
Every timestamp the contract declares arrives as a real Date, and every other string stays a string, so there is nothing to parse.
Map Studio, Data and Tile APIs
The Map Studio API arrives under three namespaces. studio is the map itself: itineraries, zones, collections and their catalogs. admin is the organization around it. agent starts an AI agent run, answers it and rates it, and a run's events are read through createAgentTransport from @mountaya/client/events. tiles serves tiles and style specifications.
admin covers the organization's settings, its members and invitations, its publishable keys, its usage and its billing, and, under admin.users, the signed-in person's own memberships, preferences and invitations. Secret keys are absent from this contract: they mint session tokens, so no client method lists or issues one.
The Data API is GraphQL, and you never write a query. Each operation sends a generated document. Pass select to narrow that document and the return type to the fields you name.
Omit select and you receive the full result. signal lives in the same options bag and aborts the request.
Map Studio types are exported from the package root. Tile and Data API types stay behind the TileSchema and DataSchema namespaces, where their colliding names cannot shadow one another.
Choose a credential
The session token also decides who a call acts as. Your server mints it with user_id set to the person signed in, and every request made with it acts as that person: no header names a user. Every call that acts as a member, each write, the collaboration and agent streams, and every read under /v1/me, is answered 401 under a token minted for no user, so such a token reads and never writes. Every studio-embedded operation is answered 401 under a token minted for any surface but studio-embedded too.
The client reads what each operation declares and refuses a credential that cannot meet it before anything is sent, with a MountayaConfigurationError naming the operation: a secret key anywhere but postSession, no session token where one is required, and a token a SessionTokenEngine reports as minted for no user where the operation acts as a member. mountaya.transport.actsAsMember is true under a token minted for a user, false under one minted for nobody, and undefined where the client is not told, as with a token held as a string. mountaya.transport.reachesSessionOperations answers whether a client can make the calls that need a session token at all.
Authentication states what each credential may do.
Mint a session token on your server
Minting takes the secret key, so it happens on your server and nowhere else.
Send data.token to the browser. It is short-lived, includes expires_at, and names its member at data.user_id. Read user_id from your own session, never from the request: the token lets a page act as that member. Leave it out for a token that names nobody: it reads your content as a publishable key alone does, never a draft, reaches the administration's reads when minted for the default surface, and no call that acts as a member. A backend that reads your drafts mints its token for a member. A user_id that is neither a member of the key's organization nor invited to it answers 403, and is limited on its own, so it never holds back the tokens you mint for everyone else.
surface names what the token is for, and the token keeps it for its whole life. Left out, it is api, the surface of your own clients, whose Data API and Tile API requests count toward your organization's limits, allowances, and invoice. A token for an Embedded Studio, the editor and its read-only viewer alike, is minted with surface: "studio-embedded": it is the only token a studio-embedded operation accepts, and a studio refuses any other, and a publishable key given no session token, before it sends a request. What the studio asks of the Data API and the Tile API under it is recorded apart and never counted while your organization holds Embedded Studio. A read-only viewer's token, minted for studio-embedded without user_id, reads what a publishable key alone reaches, never a draft, as every token naming nobody does: see serving anonymous viewers. Only a key carrying the studio-embedded scope gets one, any other key answering 403, and only while your organization holds Embedded Studio, which is otherwise a 402.
In client code, reach for the browser entry point. It does not export the secret-key credential.
A client constructed with an sk_ where both window and document exist throws MountayaConfigurationError rather than shipping the key, and so does one constructed in a React Native application, whose bundle every device that installs it holds. A worker holds neither global, so import @mountaya/client/browser there: the browser entry is a worker's defence.
Handle errors
Every failure is a class of the MountayaError family. A failure the platform answered is a MountayaApiError, with status, code and the platform's own errors array, and a validation failure adds the field path. It also carries requestId, the answer's X-Request-Id, which its message ends on, metadata, the envelope's extensions.metadata as sent, rateLimit where the answer reported one, and retryAfter, the whole seconds its Retry-After named, where it sent one: every 429 does, and a 503 or a 409 coded IDEMPOTENCY_KEY_IN_USE may. A timeout or a dropped connection is a MountayaConnectionError, which carries none of them. isMountayaError and isMountayaApiError, from @mountaya/client, classify a caught value by the name its class declares, so they also answer for an error thrown by a second copy of the package.
A 402 is answered by changing the agreement or the billing details, not in code: its message names the fact and points to Talk to us. It is always a MountayaBillingError, whatever its entry says. The entry's code is PAYMENT_REQUIRED, or LIMIT_REACHED for a call past a ceiling of the agreement, its seats, its published itineraries or its published zones, and error.hasCode tells them apart. The Data API also answers it for a self-serve organization with no payment method on file.
Every 429 names its wait in Retry-After. A per-minute limit, a share token refused on the Tile API because its address presented too many unknown share tokens, and a publishable key refused because its address had too many unknown publishable keys refused, name the seconds left in the minute, which the client waits out within the call's budget before it asks again. A spent monthly allowance names the seconds until the next UTC month, which no budget waits for: the error reaches your code at once, as a MountayaRateLimitError carrying retryAfter. Stop calling the surface until it has passed.
A 412 is a MountayaPreconditionFailedError: a write whose ifMatch names a version the resource has moved on from, and its etag is the tag the resource carries now. Two 409s answer a write's Idempotency-Key rather than the write, and each raises a MountayaConflictError of its own. One coded IDEMPOTENCY_KEY_IN_USE says the first request with the key is still running: the client asks again itself for as long as the call's maxWaitMs lasts, then raises MountayaIdempotencyKeyInUseError, and the same call sent later with its idempotencyKey is answered whatever the first request is. One coded IDEMPOTENCY_ANSWER_UNAVAILABLE is a MountayaIdempotentAnswerUnavailableError, which says the opposite of every other 409: the write landed, and its first answer cannot be given again, because it was too large for the API to keep or was a 5xx answered after the write. The client never asks again on it. Its answer carries the status, location and etag that first answer had, so re-read the resource, at answer.location where the write created one, rather than send the write again under a new key, which would make it twice.
A Data API failure answered with an error status raises the class of that status, as a REST failure does. A 200 whose errors say the operation did not happen raises MountayaDataError, on the same base. The errors page lists the code vocabulary, and the order in which a request's checks run.
Retry, write safely, and time out
Retries are on by default: two retries with backoff and full jitter, on 429, 502, 503, 504 and network failures, an attempt that timed out among them, and on a 409 whose Idempotency-Key is still in use. A 429 is repeated whatever the operation; the others where the operation declares itself idempotent or the request carried a key, which every write does. A 409 whose key is still in use is asked again, at least a second apart, for as long as the call's maxWaitMs lasts, and maxRetries does not count those repeats. A 4xx that is a statement about your request is never repeated, a 412 least of all.
Retry-After replaces the schedule: the wait is the seconds it names plus up to a second of jitter. Every wait of a call comes out of one budget, maxWaitMs, a minute by default, and a wait that does not fit is not taken: the error reaches your code instead.
Every write but postSession, postOrganizationLookup and the two collaboration signals, which a repeat restates, sends an Idempotency-Key, the same on every attempt of one call. The client draws it from the runtime's cryptographic random source, and a runtime with none sends no key. The API answers a key it has seen with the answer it stored rather than run the write again, so a write whose answer was lost to a dropped connection is asked again safely. A 5xx answered before the write was made keeps nothing, so a write asked again after one runs again; one answered after the write was made is answered on every repeat with the 409 that raises MountayaIdempotentAnswerUnavailableError. Every error a keyed write raises carries the key as error.idempotencyKey, and onResponse is told replayed: true of an answer the API kept for the key and gave again. A caller that asks again itself, after a page reload or from a queue, passes the key it used the first time:
A resource a write can be made conditional on carries its etag. Send it back as ifMatch, and the write lands only if nobody changed the resource since you read it:
The client never sends a tag you did not name, so a write without one replaces what is there. An itinerary's tag moves with every write to anything under it, its segments, waypoints and publication included. Requests and responses sets out what the API does with both headers.
Each surface has its own base URL: rest, data and tiles. Override one to reach a local stack, or pass "" for same-origin and /api behind your own gateway.
timeoutMs bounds each attempt's wait for a response, not the retry sequence. An attempt waits the longer of the client's timeoutMs and the operation's own budget plus five seconds, so it never abandons an answer that was coming. Every operation but the event streams states a budget, 30 seconds on most and 55 on the import preview and the import, so under the default of 30 seconds an attempt waits 35 seconds, a Data API call included, and 60 on an import. A call's own timeoutMs, beside retry and signal, stands over both.
Watch every answer
onResponse is told of every answer, a success's included: the operation, the attempt, the status, the request id, whether the answer was replayed from an Idempotency-Key, and the rate-limit state the Data API and the Tile API report on every answer the limits weighed.
The state is for observing, never for steering: the client delays no request on it, and a fault the hook throws changes nothing the call answers. parseRateLimit reads the same two headers off an answer the client did not make, a tile a map library fetched for instance.
Read a whole listing
Every listing of what an organization or its members create is served a page at a time, by an opaque cursor. A call names its page size as limit, from 1 to 100 and 50 by default, and the answer names the cursor of the next page at extensions.metadata.pagination.next_cursor, null on the last. paginate reads such a listing to its end and answers its rows one at a time.
Pass a method of the client itself and that method's own arguments, filters and sort included. Each row is typed as the method's own, and a member the operation does not declare is a type error, as it is in a direct call. Every request carries those arguments unchanged, since a cursor is bound to the filters and the order it was issued under. A page is requested only once the rows before it are read, so a loop that stops early leaves the rest unrequested, and a cursor under query starts the walk where an earlier one stopped.
The walk ends on a page whose next_cursor is null, however long the listing is, or after maxPages requests where you set it. A page that holds no row and names a cursor is followed all the same: a page is cut before what your credential may not see is left out of it. A listing that names a cursor the walk has already followed rejects with MountayaApiError rather than read without end. The API reads by keyset, so a row whose sort key does not change is neither skipped nor read twice, though a row a write moves across the cursor may be.
Decode JSON you already hold
A method decodes its response's timestamps as it answers. JSON that reaches your program some other way passes through no method: a fixture served as a static file, a record read back from a cache. A cast leaves every timestamp in it a string behind a type that says Date. decodeRestSchema gives it the decoding a response gets, by the name of the schema it holds.
The name is a key of RestSchema.components["schemas"], and the result is typed as that schema. A string becomes a Date only in a field the schema declares one, at any depth. It decodes and does not validate, so JSON from a source you do not trust is yours to check first. The value you pass is never written to. It takes one value, so decode a list member by member.
Put a client in React context
MountayaProvider holds one client. Mount it inside your QueryClientProvider.
useMountaya returns that client, and throws outside a provider.
The provider names no user: the client's session token does. It keys every read under the user that token names, and when the client's scope changes, a sign-in, a switch of account or a sign-out, it drops the reads made under the scope it left, so a read made for one person is never served to the next.
Read and write with hooks
A resource hook reads one resource by its id and answers its record. The error is typed as MountayaError, the family every failed call belongs to, so narrow with isMountayaApiError before reading errors or status. The typed classes narrow without a cast.
useItinerary(itineraryId, { surface: "published" }) reads the last publication rather than the draft, and useZone(zoneId, { surface: "published" }) reads a zone's. Each version lands on a cache entry of its own, so a read of the publication never overwrites the draft an editor holds. useCollectionSpecification(collectionId, { query: { with_zones: true } }) names the layers a collection's specification declares, with_zones and with_itineraries, each left out being the organization's own default, and each query has a cache entry of its own. Each resource hook's select projects the record. The resource hooks are useItinerary, useItinerarySpecification, useCollection, useCollectionSpecification, useZone, useActivities and useLayers.
Each resource hook is a query options factory bound to the provider's client, and the factories are exported beside the hooks for prefetchQuery, ensureQueryData or a useQuery of your own. Each takes the client first. itineraryQueryOptions(client, itineraryId, { surface }) and zoneQueryOptions(client, zoneId, { surface }) take the version to read, and collectionSpecificationQueryOptions(client, collectionId, query) the layers to declare.
A listing hook reads a listing a page at a time, filtered and sorted by the query it is given, and answers the rows of every page read so far:
The listing hooks are useItineraries, useCollections and useZones. hasNextPage says whether the listing goes on and fetchNextPage reads the next page. useCollections({ itinerary_id }) lists the collections holding one itinerary, and useCollections({ zone_id }) those holding one zone. A listing keeps its rows on screen while a filter changes, never across a change of credential.
mountayaInfiniteQueryOptions reads any other listing that pages as TanStack's infinite query, the members of an organization for instance:
useMountayaQuery reads any operation of the REST, Tile or Data API surface by its name and arguments, and answers what the client returns for it: the whole body of a REST or Tile operation.
A read that no single operation answers, several calls combined for instance, runs through useMountayaCustomQuery(key, fn, options?) under a key you build with useMountayaKeys. useMountayaMutation is the write half. It takes the mutation function first and options second.
Both default retry to false. The client already backs off and reads Retry-After. Retrying three times on top turns one failed call into twelve. Override per call: useMountayaQuery("rest", "getActivities", undefined, { retry: 1 }).
useMountayaKeys builds cache keys bound to the provider's credential. keys.all(), keys.scope(), keys.surface("rest") and keys.operation("rest", "getItinerary") narrow by prefix. A sign-out invalidates keys.scope(), which drops only the reads made under the departing credential.
Refresh a session token in the browser
useSessionToken calls your minting endpoint and refreshes the token before it expires. Its session goes into the credential in place of the token: the client reads the token it holds at each request, so one client serves the component's life.
Each refresh reaches the next request, and each tile a map built on the client asks for, with nothing rebuilt, so no query key or map keyed on the client changes. A request made before the first token waits for it, bounded by its signal and its timeoutMs, so the first reads of a page carry the session. Your endpoint calls POST /v1/sessions with { publishable_key, user_id }, the signed-in member as user_id, and surface: "studio-embedded" beside them when the client serves a studio, and returns the { token, expires_at, user_id } it receives: the user_id is how the client knows whom its calls act as. Mint again for a new person: a token names one member for its whole life. Pass enabled: false while signed out, which stops minting and clears the token, its expiry and the last error. The result also exposes token, getToken, expiresAt, error, isLoading and a refresh. Outside React, pass a SessionTokenEngine itself as sessionToken.
An event stream ends with the token it was opened with. The streams of @mountaya/client/events close such a stream with the reason reconnect: open it again at once, from the last event id, and it goes out with the token the client holds then. A 401 on that open is the credential gone.