schemas
Activity
Contributors
README
Mountaya schemas
The public contract for the Mountaya API: three customer-facing surfaces, versioned together, and the behaviour every Mountaya SDK reproduces. Every published SDK is generated from exactly these files, so the contract an integrator reads and the contract a client was built from are the same bytes rather than two copies that agree until they do not.
If you are integrating, read on. If you are changing the contract, CONTRIBUTING.md is where the sources, the build and the gates are described.
The three surfaces
| Surface | Contract | Base URL |
|---|---|---|
| Map Studio API | openapi/api.yaml | https://api.mountaya.com |
| Tile API | openapi/tiles.yaml | https://tiles.mountaya.com |
| Data API | graphql/data/*.graphqls | https://data.mountaya.com/graphql |
The two OpenAPI files are self-contained bundles, built from the sources beside them and
committed so that nothing has to be checked out or installed to use them. The Data API is a
plain GraphQL schema, split into files by the domain each part answers. sdk/ holds
the half of the contract that is not a shape, below, and one derived file that restates the
shapes for the SDK generators.
There is no separate sandbox host. A test organization on these hosts is how you test.
Each bundle's info.description is the reference an integrator reads first: the read tiers,
the closed set of error codes and the statuses each one accompanies, pagination, and the
rules that apply to every operation rather than to one.
What is published
What belongs on this contract is decided by the credential, not by the subject matter:
A resource an API key reaches is published here. A resource reachable only by signing in to Mountaya's web application is not, and is never generated into a client.
The test cuts across topics rather than along them, which is why the account operations are
here: billing, members, keys and usage are reachable with a management key and a session
token, so an integrator holding a secret key issued in Mountaya's web application can run an
organization end to end without a browser. The rule also explains the absences: nothing that
requires that sign-in appears in these documents, whatever it is about.
One address a client meets is not an operation here: the signed download URL the Data API's
exportItinerary returns. It carries its own short-lived signature rather than a credential
these documents declare, and exportItinerary documents it.
The Tile API and the Data API have no such split. Every tile route and every query field they answer is public, so what is here is the whole of both.
Credentials
An API key rides X-API-Key, and every request carries one except those a share link
authenticates and postOrganizationLookup, which takes no credential. Keys are issued per
organization from the authentication settings, and
a key is its prefix followed by 32 lowercase hexadecimal characters.
- A publishable key (
pk_) is safe to ship in a browser or a mobile app, and is scoped to the products it may reach. On its own it reads: every write, and every read that acts as a member, also needs a session token your backend mints. - A secret key (
sk_) authenticates from your own backend. On the three surfaces it does exactly one thing: mint session tokens withpostSession(POST /v1/sessions). Sent to any other operation it is not recognized as a credential at all. Mountaya's MCP server reads it too, in its key mode: the publishable key inX-API-Keyand the secret key inX-Secret-Key, sent together, from which the server mints its own sessions. The MCP server also accepts, in place of the two keys, an OAuth bearer token whose audience is the MCP server and whose scope ismcp. - A session token (
sess_) ridesX-Session-Tokenbeside a publishable key, never on its own. It lives at most five minutes, and a token minted moments earlier for the same key may come back with less of that left, so readexpires_atrather than assuming the five minutes. There is no refresh endpoint: mint a fresh one. The Data API requires one on every request; the Map Studio API requires one on itsmanagementandstudio-embeddedoperations; and both REST surfaces require one on the publishable-key requests that remain, the Map Studio API'spublishingreads and every Tile API request, when the organization enables therequire_session_tokensafety rule. A token naming nobody satisfies that rule and reads what the key alone does. A share-link read is unaffected by that rule. A token can name a user:postSessiontakes auser_id, a member of the key's organization or a user invited to it, as your backend asserts, and a page holding that token acts as that user and nobody else. A token is minted for a surface,postSession'ssurface:api, the default, for your own clients, orstudio-embedded, which every Embedded Studio needs, the editor and its read-only viewer alike, and which is the only token astudio-embeddedoperation accepts, any other being a401there. It is minted only for a key reaching thestudio-embeddedscope (a403otherwise) of an organization holding Embedded Studio (a402otherwise), and may name a member, for the editor, or nobody, for a viewer. - The Tile API and the Data API also read both from the query, as
publishable_keyandsession_token, for a request that cannot set a header, such as a map library's tile request. The header wins when both are sent, and a query parameter that is present but does not carry its credential's prefix (pk_,sess_) is a401, never read as absent; an empty one counts as absent, as an empty header does. No Map Studio operation reads either from the query. - A share token (
share_) ridesX-Share-Tokenor theshare_tokenquery parameter, and replaces the key rather than accompanying it. It reaches one itinerary, or one collection together with the itineraries and zones inside it, read-only, and an itinerary or a zone is served its published snapshot. A page that sends one must be on Mountaya's own domain; a request with noOriginorRefereris not checked at all. Reading, minting, rotating and removing a link are published operations, each taking a publishable key with thestudio-embeddedscope and a session token minted forstudio-embeddednaming a member with a write role.
A key, a session token or a share token whose organization has been canceled is a 401, like
an unknown or revoked one, and so is a session token that is unknown, expired or minted for
another key: never a quiet fall back to the key alone.
The session token names the user a request acts as, and nothing else does. A key names an
organization and not a person, so a request acts as a user only through a token postSession
minted for that user, and no header names one. Every Map Studio operation declares
x-acting-user, set out under the operation extensions below: required on every
studio-embedded operation, every management write and every operation under /v1/me, where
a token naming no user is a 401; optional on the management reads, which a token minted
without a user may make, asked for no member role, unless it was minted for studio-embedded,
which they answer 401 as they answer a key alone; so a token naming nobody minted for a key
that carries management stays on your own backend, and the token an anonymous browser holds is
minted for a key that carries no management; and none everywhere else. A token naming a user is answered only while that user holds the
role the operation asks, and is otherwise a 403, one answer whether the user is outside the
organization or a member without the role. Any role reads the members, the invitations, the
settings and the AI configuration; the owner or admin role reads the publishable keys, the usage
and the billing, and makes the account's changes; the two catalogs, getAiProviders and
getBillingProducts, ask no role at all. A session governs the members with at most an admin's
say, whoever it names, because any admin can create the secret key that mints it: granting,
taking away or removing admin, inviting an admin and an admin leaving are the owner's acts,
made only signed in to Mountaya's web application, and are a 403 under a key, a session naming
the owner included; handing the organization on is not an operation of this contract, and the
owner makes it in the web application too.
A key carries a list of scopes, and an operation declares the one it needs. A key carries
any of six (publishing, studio-embedded, tiles, data, mcp, management), the members of
PublishableKeyScope, and each names a product except management, which names the
organization's own account. A key carrying management carries no other scope, so a key either
ships to pages, with product scopes, or administers, with management alone. A key carrying
studio-embedded carries tiles and data beside it, since the editor it opens draws its map on
the Tile API and asks its previews of the Data API, so an Embedded Studio's key carries the three.
The scope an
operation needs is declared on it as x-pkey-scope in
the OpenAPI documents, and by the @pkeyScope directive on each root field of the SDL, so a
generated client can answer "what does my key need for this call" without leaving the type. A
key carries exactly the scopes it was given, and a scope opens its product only while the
organization holds it. Both are set out below under x-pkey-scope.
Read tiers
A session token is not a formality. On the two REST surfaces it decides what a read
returns: a publishable key alone lists only public resources and is served the last published
snapshot, while the same key with a session naming a member lists the organization's private
resources too and is served the draft. No credential reaches another organization's draft. info.description in
each bundle sets out the tiers in full, and it is the first thing to read before deciding a
result set is wrong.
A token naming nobody reads as the public. A session token naming nobody, on every surface
but mcp, is served exactly what the key alone is: the published snapshot and never a
draft, public-only listings, tiles cut from published content, and the Data API's search,
itinerary and exportItinerary answered as the key alone. It is what an Embedded Studio's
read-only viewer runs on for a visitor nobody signed in, and what a backend mints to satisfy
require_session_token for one, so it is what an anonymous browser holds. Only a token naming a
member reads the organization's drafts, so a backend or a viewer that must read them mints for a
member; the token Mountaya's MCP server holds for its own requests is the one exception, on the
Data API. The surface decides one more thing: the management reads answer a studio-embedded
token naming nobody 401, and an api one asked for no member role.
A first request
# 1. Mint a session token from your backend, with a secret key. "user_id" names the member a
# page acts as, which every operation whose `x-acting-user` is `required` needs, and
# "surface" makes it an Embedded Studio's token, the only one the write in step 4 takes.
# Without "surface" the token is your own clients' (`api`), whose requests are counted.
curl -s https://api.mountaya.com/v1/sessions \
-H "X-API-Key: sk_..." \
-H "Content-Type: application/json" \
-d '{"publishable_key": "pk_...", "user_id": "7a4e1c98-3b52-4f60-9d81-c05e2f7a83b1", "surface": "studio-embedded"}'
# -> {"data": {"token": "sess_...", "expires_at": "...", "user_id": "7a4e1c98-..."}}
# 2. Read a catalog. The payload is at `data`. The key needs the `publishing` scope, and the
# organization Map Publishing, which its agreement with Mountaya grants: without it this
# is a 402.
curl -s https://api.mountaya.com/v1/activities \
-H "X-API-Key: pk_..." | jq '.data'
# 3. Fetch a tile. The body is the tile's bytes, not JSON. The key needs the `tiles` scope,
# and the organization the Tile API, which its agreement grants. With a session token
# naming a member the zones are drawn as drafts; the key alone draws the published
# public ones.
curl -s https://tiles.mountaya.com/v1/zones/12/2126/1458 \
-H "X-API-Key: pk_..." \
-H "X-Session-Token: sess_..." -o zones.mvt
# 4. Write, as the user the token names. One Idempotency-Key per logical write, sent again
# unchanged on every retry, so a write whose answer was lost runs once. The key needs the
# `studio-embedded` scope, and the organization Embedded Studio and Map Studio. A key
# carrying `studio-embedded` carries `tiles` and `data` beside it, which its studio's map
# and previews are requested under: a key created with it alone is a 400.
curl -s https://api.mountaya.com/v1/itineraries \
-H "X-API-Key: pk_..." \
-H "X-Session-Token: sess_..." \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{"name": "Tour du Mont-Blanc", "activity_id": "a1c4e7b2-3d59-4f08-8b6a-2e5c9d0f7134"}'
# -> 201, Location: /v1/itineraries/..., ETag: "d1"
A self-serve organization, covered by no agreement, holds neither of those two products. What
it holds is the Data API, once its first card is saved, so its first request is a Data API
query under a key carrying data, with a session token.
Responses and errors
Both REST surfaces answer in an envelope. The payload is at data, and an operation that
documents metadata carries it at extensions.metadata. The exceptions are the answers that are
not JSON: an export download is the file's bytes, a tile is the tile's bytes, and the event
streams are text/event-stream.
A failure is a top-level errors array whose every entry carries a message for a person and
a machine-readable extensions.code for a program. A message raised by an operation is
localized by Accept-Language; one raised by request validation, before any operation runs,
is in English whatever the header says, which is one more reason to branch on the code. The
Data API uses the GraphQL-spec error envelope, and the three surfaces draw extensions.code
from one vocabulary, each declaring its own closed part of it. info.description in each
bundle spells out that closed set and which statuses each code accompanies.
Field names are snake_case on both REST surfaces and camelCase on the Data API, so
organization_id in a REST payload is organizationId in a GraphQL one.
Every answer on every surface carries X-Request-Id, the identifier of that request: quote it
when reporting a problem. Every 401 carries WWW-Authenticate, every 405 carries Allow,
and every 429 carries Retry-After.
On the REST surfaces a path written other than in its canonical form, with a trailing slash or
an empty or dot segment, is a 308 to the canonical path for GET and HEAD, and a 404 for
every other method, so a write is never redirected into a read; HEAD is answered on every path
that takes GET. A method a path does not take is a 405.
Writes on the Map Studio API. Every POST, PATCH and DELETE takes an Idempotency-Key
but postSession, which a repeat answers from the token it already minted,
postOrganizationLookup, which writes nothing, and the collaboration signals,
postItineraryCollab and postZoneCollab, which a repeat restates. A request sent again with
the same key within 12 hours is answered with the first one's stored answer and runs once, and a
key still running is a 409 coded IDEMPOTENCY_KEY_IN_USE with Retry-After. A request is the
same when its method, path, query, body and media type are; If-Match is not compared, so a write
retried with a fresh tag after a 412 takes a new key. A 5xx answered before anything was
written releases the key, so a retry runs again. A 5xx answered after the write was made, a
first answer too large to keep whole, or one carrying a credential shown only once, is a 409
coded IDEMPOTENCY_ANSWER_UNAVAILABLE on every repeat instead: the write was made, and
extensions.metadata names how it was answered, so read the resource again, or revoke the
credential it names and create another. Keys are best-effort: while Mountaya cannot record one, the write runs as if
it carried none. A creation answers 201 with Location, and
a write answers the resource whose read it changed, data: null for one that only removes
(revoking a publishable key answers the revoked key). The
resources a write can be made conditional on carry an etag beside the ETag header; send it
back as If-Match, and a write to a resource that has changed since is a 412
PRECONDITION_FAILED. A request body is closed: a field an object does not declare is a 400
naming it. info.description in the Map Studio bundle sets these out under "Requests and
answers".
A request wrong in several ways is answered for the first check it fails. On the Map Studio
API a body over its operation's ceiling is a 413 first, then the path and the method, then
the credential: the key, the organization's standing (401), the calling page (403) and the
session token (401), so the body of a request whose credential is refused is never read. Then
the request is validated against these documents (400), then the organization in the path
(403), the product (402), the key's scope (403) and the role of the user the request acts
as (403), then its Idempotency-Key (a replay counts toward no limit), then the operation's
limits (429), and only then what the operation asks itself: a resource it cannot see (404),
an act it may not make there (403), what only the operation can judge of the request (400),
a conflict (409), a ceiling (402 or 403, coded LIMIT_REACHED) and last a stale
If-Match (412), so a stale tag is answered only for a write that would otherwise go
through. The Tile API and the
Data API refuse a method they do not serve with a 405 first and then check the credential
before anything else. Each surface lists its whole order, with the code each step answers and
what fixes it, under "Which refusal you get first": in info.description for the two REST
surfaces, and in the SDL's introduction for the Data API.
Rate limits
Every 429 carries Retry-After, the whole seconds until the limit lifts: the seconds left
in the minute for a per-minute limit, and the seconds until the next month begins in UTC for a
spent monthly allowance. Wait that long, with a little jitter of your own, and send the request
again. 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 is allowed rather than refused.
The Tile API and the Data API also send the IETF RateLimit-Policy and RateLimit headers on
every answer the limits weighed, under a key, and on the Tile API under a share token as well,
so a client sees a limit coming rather than meeting it: a success, a 429, and a refusal made
after the limits. A refusal made before them, for the credential, the calling page, the product
or a missing card (402) or the key's scope (403), carries neither. RateLimit-Policy names
each limit the request was counted against, with its quota q and window w: "minute" and
"month", where the organization's agreement sets an allowance, for its own clients;
"address", the bound on one calling address; for a session token minted for
studio-embedded, "member", the bound on the member it names, and "studio-embedded", the
bound the organization's Embedded Studios share; and on the Tile API, for a share token,
"share", the bound every share link of the organization that owns it shares. RateLimit gives what is left, r, and the
seconds until the window resets, t. Neither is sent on a tile a shared cache may store. The
Map Studio API sends neither.
Your own clients and your hosted embed count. Data API and Tile API requests are recorded
under the surface they come from, so the organization sees each apart in its usage. api, your
SDK or any other client under one of your keys, and mcp, a request through Mountaya's MCP
server, count toward the per-minute limits, the monthly allowances and the invoice, and on the
Tile API so does embedding, Mountaya's hosted embed drawing under one of your keys, exactly as
api does. The rest of Mountaya's own surfaces are recorded and never counted:
studio-embedded, an Embedded Studio on its studio-embedded session, for as long as the
organization holds Embedded Studio (while it does not, such a session's requests are
recorded and counted as api); studio-online, Map Studio in Mountaya's web application; and
on the Tile API share, a share link. Each is held to abuse bounds of its own instead, in the
table below. The Tile API's base map, every terrain route's answer, is recorded under the
resource type terrain and counted toward nothing, on any surface, your own clients' included.
"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.
| What | Limit |
|---|---|
| Tile API, your own clients and the hosted embed | 600 a minute per organization, the two together, unless its agreement says otherwise. Before it, a key sent without a session token, or with one naming nobody, is held to the larger of 120 a minute and a quarter of the organization's limit per calling address, and to no per-address bound while the organization's limit is unlimited |
| Tile API, an Embedded Studio | 6,000 a minute per member its session names, or per calling address for a session naming nobody, then 60,000 per organization |
| Tile API, share links | 600 a minute per calling address, then 60,000 per organization that owns the link, across all of its links |
| Tile API, the base map: every terrain route, on every surface | 6,000 a minute per calling address, in place of every other bound of its surface |
| Data API, your own clients | 60 requests a minute per organization unless its agreement says otherwise, each request carrying up to four root fields |
| Data API, an Embedded Studio | 300 requests a minute per member its session names, or per calling address for a session naming nobody, then 3,000 per organization |
| Map Studio API, every read | under a key without a session token, or with one naming nobody, 600 a minute per calling address, then 6,000 per organization; with a session token naming a user, 1,200 a minute per user, then 6,000 per organization, counted apart; under a share token, 600 a minute per calling address, then 6,000 per organization that owns the link |
Map Studio API, the billing reads the payment provider answers as they are asked (getOrganizationBillingPaymentMethods, getOrganizationBillingInvoices, getOrganizationBillingInfo, getOrganizationBillingPromotionCode), and postOrganizationBillingProvision, which checks its promotion code the same way | beside the read limit for the four reads, 60 a minute per user and 300 per organization, the five together |
Map Studio API, the billing writes the payment provider makes as they are asked (postOrganizationBillingPaymentMethodSetup, postOrganizationBillingPaymentMethod, postOrganizationBillingPaymentMethodSetDefault, deleteOrganizationBillingPaymentMethod, postOrganizationBillingSubscription, deleteOrganizationBillingSubscription, patchOrganizationBillingInfo) | 30 a minute per user and 300 per organization, the seven together, apart from the billing reads |
Map Studio API, postOrganization | 10 a minute and 50 a day per user |
| Map Studio API, the operations that cost the most | import and preview 10 a minute per user and 60 per organization; routed segment writes 120 and 1,200; zone writes 30 and 300; agent run starts 10 and 100; agent run inputs 120 and 1,200; agent run ratings 60 and 600; collaboration signals 1,200 per user; stream opens 60 and 1,200, and at most 20 streams open at once per user, 50 per calling address and 1,000 per organization; invitations 30 a minute per user and 200 a day per organization, an invitation counting toward the day only once it is a well-formed one from an owner or admin |
POST /v1/sessions | 60 refused requests a minute per calling address; 10 refused requests a minute per secret key, 10 per secret key and user_id, and 10 per secret key and surface; 6,000 tokens minted a minute per secret key, a token returned again not counted |
POST /v1/organizations/lookup | 60 a minute per calling address |
| Every operation of the three APIs that takes a publishable key | 60 unknown publishable keys refused a minute per calling address, a key refused again within seconds counted once; past it, a key not resolved moments earlier is answered 429 until the minute ends, and a key resolved moments earlier is never refused for it |
the signed download URL exportItinerary returns | 60 a minute per calling address, then, once the link is known to be genuine, 6,000 a minute per organization across the URLs its keys minted |
A request from Mountaya's web application, made by a person signed in to it rather than with a key or a link, counts toward none of the organization's per-minute limits or monthly allowances. It is held to bounds of its own, counted per person: 300 Data API requests, 6,000 Tile API requests and 3,000 Map Studio API reads a minute. The Map Studio API's limits on its costliest operations hold it all the same, counted per person rather than per organization, and the invitations an organization sends count toward its daily bound whichever way they are sent. The window is a fixed minute, so a burst spanning a boundary can reach twice the ceiling before anything is refused, and no read on the contract returns the figure an agreement sets.
An organization's agreement may also set a monthly allowance on the Tile API and on the Data
API, and none applies unless it names one. It counts your own clients and your hosted embed: on
the Tile API, the tiles served under the organization's keys on the api and embedding
surfaces, the base map's never; on the Data API, the root fields answered without an error, the
MCP server's requests included. A spent allowance refuses those and nothing else. The routing a Map Studio write does, a routed segment, an
import or a sourced zone, is recorded as studio-embedded data usage under a key and
studio-online from Mountaya's web application, and counts toward neither, nor toward the Data
API's per-minute limit or invoice. On the Tile API, a HEAD and a specification are never
counted, and a tile only once it is served. Rate
limiting is a separate question from what an organization holds: a refusal about what its
agreement covers is a 402, never a 429.
Pagination
Every listing of what an organization or its members create pages by cursor; a catalog 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, the next_cursor the previous page reported at
extensions.metadata.pagination; the last page reports next_cursor: null, and only that ends a
listing: a page may hold fewer items than asked for, or none, and still name the next. A cursor is
at most 1024 characters, and 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 to
the read tier of the credential it was issued to as well, and an
item whose sort key does not change is never skipped or served twice between pages. A listing filters by query parameters
named after the field, updated_since and q among them. A listing whose order is the caller's
to choose takes one sort key from the list it declares, descending with a leading -, and
declares its default; one whose order is not, such as getMyOrganizations or
getOrganizationBillingInvoices, takes no sort and states the order it answers in.
Enums across surfaces
Every enum member on the REST surfaces is kebab-case: lowercase words joined by hyphens, such
as past-due, turn-left or hiking-and-trail. The few enums whose spelling belongs to
someone else say so where they are declared: the dotted collaboration event names
(lock.acquire, itinerary.changed), media types, unit symbols, and the GeoJSON object types.
A usage report names a Data API field in kebab case too, so reverseGeocode is reported as
reverse-geocode. The keys of a listing's sort are field
names, snake_case as every REST field is, with a leading - for a descending order.
A set of enums is declared on both the REST surfaces and the SDL, with the same members in the
same order. The SDL spells a member in SCREAMING_SNAKE, because GraphQL requires an enum value
to be an identifier, and one transform maps every one of them with no exception: lowercase the
SDL member and turn each underscore into a hyphen, so COMPACTED_GRAVEL is compacted-gravel
and HIKING_AND_TRAIL is hiking-and-trail. npm run check:surfaces holds the two lists to
each other member for member, through that transform and description for description, so
neither surface can gain a value or a meaning the other does not have.
A generated client performs none of this. Each surface's generator reads its own document and emits its own constants; the transform is for a reader holding both references at once, or writing something that speaks to both.
Using the bundles
The OpenAPI documents are self-contained, with every $ref resolved and every schema
inlined, so they load in Redoc, Swagger UI, Postman or any generator without needing this
repository checked out.
They declare OpenAPI 3.1.1, so use a generator that reads 3.1. That is what lets a
nullable field say so: 3.0 has no way to write "null or this reference", so a field
documented as nullable rejects null the moment anything actually validates it. On 3.1
those fields are oneOf: [$ref, {type: "null"}] and they validate. If you are pinned to a
3.0-only generator, convert the bundle first rather than reading it as 3.0: the two
disagree about exactly the fields most likely to be null.
Reach a bundle by its versioned URL, which names a contract tag:
https://docs.mountaya.com/schemas/<tag>/openapi/api.yaml, and tiles.yaml beside it. The
documentation site serves the bundles of the current tag, the one in VERSION, at that
path. A bundle of an older tag is read from this repository at that tag, for example git show v0.7.0:openapi/api.yaml in a clone. Never point a generator at a branch: main is where
the next tag is assembled, so a generator pointed there can produce a different client on two
consecutive days.
The bundles are self-contained; the build that produces them is not. The authored
sources reach the shared response envelope through $refs to
raw.githubusercontent.com, each pinned to a tag of a public repository, and npm run build resolves every one over the network. Consuming this repository needs none of that:
the bundles are the published artifact and they carry no external reference. Rebuilding
them does, so a build host that cannot reach GitHub cannot reproduce them, and a run of
npm run build is a run against whatever those tags resolve to at that moment. Pinning to
tags rather than branches is what makes that reproducible; verifying it is npm run check,
which rebuilds and compares bytes.
# Either fetch the bundles of the current tag, with nothing checked out …
curl -sO https://docs.mountaya.com/schemas/v0.7.0/openapi/api.yaml
curl -sO https://docs.mountaya.com/schemas/v0.7.0/openapi/tiles.yaml
# … or hand the versioned URL straight to the generator
npx @openapitools/openapi-generator-cli generate \
-i https://docs.mountaya.com/schemas/v0.7.0/openapi/api.yaml \
-g typescript-fetch -o ./client
The commands below take the same input as a local path, for when you do have the repository checked out:
# Render either bundle as a single self-contained HTML page
npx @redocly/cli build-docs openapi/api.yaml -o api.html
# Generate a client with any generator, in any language
npx @openapitools/openapi-generator-cli generate \
-i openapi/api.yaml -g typescript-fetch -o ./client
# The Data API is a plain GraphQL schema; point any codegen at the SDL
npx -p @graphql-codegen/cli graphql-codegen --schema 'graphql/data/*.graphqls'
Names are stable on purpose. operationId is the method name in every generated SDK and is
frozen from 1.0; the shared response envelopes are named components
(BadRequestResponse, NotFoundResponse) rather than status codes, so no generator has to
invent N400 and no two languages disagree about what the error type is called.
If you would rather not generate your own client, the official ones are one repository
away: mountayaapp/sdk-typescript and the packages in it.
Operation extensions
Seven extensions tell the SDK generators what OpenAPI has no field for. All seven are
per-operation, all seven are read by every language's generator, and all seven are checked by
npm run verify so none can drift into a comfortable lie.
x-sdk-namespace: where the method lives
get:
operationId: getItinerary
x-pkey-scope: publishing
x-sdk-namespace: studio.itineraries
The value is <group>.<resource>, and it is the client's shape:
await mountaya.studio.itineraries.getItinerary({ itinerary_id });
await mountaya.admin.organizations.getOrganizationSettings({ organization_id });
Three groups:
| Group | What it holds | Resources |
|---|---|---|
studio | the map itself | activities, collab, collections, itineraries, layers, zones |
admin | the organization behind it, and the user a request acts as | billing, invitations, keys, members, organizations, sessions, users |
agent | conversational route building | runs |
The method name is the operationId verbatim, so the namespace groups the surface without
renaming anything in it.
The Tile API and the Data API are deliberately not namespaced. They are separate
surfaces with their own base URLs, flat in the SDKs and reached as mountaya.tiles and
mountaya.data. An operation on those surfaces carrying an x-sdk-namespace is the
failure, not the absence of one, and check:extensions checks for exactly that.
x-pkey-scope: what the key must carry
OpenAPI cannot express scopes on an apiKey security scheme; the security requirement's
scope array is meaningful only for oauth2 and openIdConnect. So the blanket security
requirement says nothing about which scope, and this extension says it.
| Value | Surface | Meaning |
|---|---|---|
publishing | Map Studio | Map Publishing: the reads a published map makes, which are the layer and activity catalogs, and itineraries, collections, zones and the organization itself, read-only. A key carrying studio-embedded reaches them as well |
studio-embedded | Map Studio | Embedded Studio: every operation that acts as a member, which is creating, editing, publishing and sharing itineraries, collections and zones, the agent, live collaboration, and the person's own memberships, preferences and invitations under /v1/me. Every operation carrying it also requires a session token minted for studio-embedded naming that user, any other token being a 401 |
management | Map Studio | everything about the organization's account: its settings and AI configuration, its members, its publishable keys, its billing and its usage. Every operation carrying it also requires a session token |
tiles | Tile | every Tile API operation, its own layer catalog getTileLayers among them, so a map drawn with the Tile API alone finds its layers without publishing |
none | Map Studio | no publishable-key scope applies. postSession is authenticated by a secret key; postOrganizationLookup takes no credential at all, because the key is what it is being asked about |
This is a table of extension VALUES, not of key scopes, and the two lists are different
lengths on purpose. Two of a key's six scopes appear in no row above, because no operation on
either REST surface is authorized by them: data authorizes the Data API, which declares the
scope each root field needs with its own @pkeyScope directive and PkeyScope enum (DATA for
a request sent straight to it, MCP for one routed through Mountaya's MCP server), and mcp
authorizes that MCP server, which describes its own tools over the MCP protocol rather than in
a document here.
The value is one scope, and outside management the acting user decides which. An
operation takes studio-embedded when it acts as the user the session token names, and
publishing when it acts as nobody, which is always a GET. npm run check:auth holds every
operation to that rule through x-acting-user. So getItinerary and getZone take
publishing, while patchItinerary, and getItineraryEvents and getZoneEvents, the
collaboration streams for those same two resources, take studio-embedded.
It is a different question from the namespace, and the two deliberately disagree.
studio.itineraries holds getItinerary beside patchItinerary, one scope each, and
admin.organizations holds getOrganization, which takes publishing because an embed reads it
to paint itself, beside operations that take management. Grouping is about what you are
doing; the scope is about what your key must carry.
A key carries exactly the scopes it was given. None is added because another is present,
and a key create or update whose list is empty is a 400. A create or update listing
management beside another scope is a 400 too, one entry per scope beside it at its position
in the list, asked before any scope's product. So is one listing studio-embedded without
tiles or without data, one entry per missing scope naming scopes: an editor's key draws
its map under tiles and asks its previews under data, which studio-embedded does not
reach, so it carries both, and neither is added for it. The enum states that requirement once,
as x-pkey-scope-needs. One scope includes another, which is
a different thing: studio-embedded reaches everything publishing reaches on the Map Studio
API, so an editor's key needs that one scope there, and a key carrying publishing alone reads
an itinerary and is refused its editing stream. The enum states the relation once, as
x-pkey-scope-includes, set out under the enum extensions below. The inclusion stops at the
Map Studio API and at Mountaya's own hosted embed, part of Map Publishing, whose tiles are
checked against publishing on the Tile API and recorded under the embedding surface, so a
key carrying tiles alone is refused there. A tile
request is taken for the hosted embed's when its Origin, or failing that its Referer,
names a page on Mountaya's own domain, which only a browser guarantees. An
editor mounted on your own pages is a different case: the tiles it draws are requested under
tiles, and its search and isochrone previews reach the Data API under data. It sends them
on its studio-embedded session, so they are recorded as studio-embedded usage and never
counted. Every tile a publishable key requests is recorded as the organization's Tile API usage,
and only the tiles of its own clients, the api surface, and of its hosted embed, the
embedding surface, are counted.
A scope opens its product only while the organization holds it. A request under a scope
whose product the organization does not hold is a 402, checked before the key's scopes, so a
key that lacks the scope as well is answered 402 rather than 403. Map Publishing, Embedded
Studio and the Tile API are granted by the organization's agreement with Mountaya.
studio-embedded asks for Map Studio beside Embedded Studio, except on the acts that take
something down and on the operations under /v1/me, which ask for Embedded Studio alone, and on the
three operations on an agent run already started, postAgentRunInput, getAgentRunEvents and
postAgentRunFeedback, which ask for Embedded Studio alone and leave Map Studio to the run.
Publishing an itinerary or a zone asks for Map Publishing as well as Map Studio, and downloading a
draft asks for Map Publishing alone, whichever credential sends either. On the Map Studio API, a
publishing read reached through studio-embedded passes when the organization holds Map
Publishing, or Embedded Studio and Map Studio. The Data
API, which the data and mcp scopes open, is held while the metered subscription the
organization's first card opens is in force, or by its agreement. management belongs to no
product. The same
rule holds when a key is created or updated: a scope is granted only while the organization
holds its product, and a scope a key already carries is kept when the product is later switched
off, answering 402 until it returns.
Every operation on every surface declares it, none included. An absent value would be
indistinguishable from an unanswered one, and only one of those is a bug.
x-acting-user: who the request acts as
patch:
operationId: patchItinerary
x-pkey-scope: studio-embedded
x-acting-user: required
What an operation asks of the user its session token names. required: the operation acts as
that user, and a token naming no user is a 401, which an SDK refuses locally before a request
leaves the process. optional: a token naming no user is answered, asked for no member role,
unless it was minted for studio-embedded, which is the 401 a key alone gets, and a token
naming a user is answered only while that user holds the role the operation asks. none: the
operation acts as nobody, and whoever the token names changes nothing, but a token naming
nobody reads what the key alone does, as the read tiers say. It follows
from the scope and the method, which check:auth holds it to: required on every
studio-embedded operation, every management write and every operation under /v1/me,
optional on every management read, and none everywhere else, the Tile API included.
x-pending-invitee: whether an invited user may make it
get:
operationId: getMyInvitations
x-acting-user: required
x-pending-invitee: true
A user whose invitation to the key's organization is still waiting is not a member, holds no
role, and is answered 403 by every operation that asks for one. The operations marked
x-pending-invitee: true are the exceptions: they answer such a user as they answer a member,
so the person can see and answer an invitation before joining. They are getMyInvitations,
postMyInvitationAccept, deleteMyInvitation and getMyOrganizations, which answers an
invitee an empty page, since nobody has joined an organization through an invitation not yet
accepted. The mark is the bare boolean true, absent everywhere else, and goes only beside
x-acting-user: required on an operation under /v1/me, which check:auth holds.
x-timeout-ms: how long the request may run
post:
operationId: postItineraryImport
x-timeout-ms: 55000
The time budget the operation runs under, in milliseconds: past it, the request is answered
504 with whatever was in flight. 30,000 on every operation but postItineraryPreview and
postItineraryImport, which route a whole file and take 55,000. An SDK waits a little longer
than this before giving up on an attempt, so it never abandons an answer that was coming. A
stream runs under no budget and declares none.
x-sdk-ignore: why an operation has no method
get:
operationId: getItineraryTile
x-pkey-scope: tiles
x-sdk-ignore: true
The two extensions above answer what does the key need and where does the method live. This one answers whether there is a method at all. An operation carrying it is emitted by no generated client, in any language.
The value is the bare boolean true, and nothing else. Writing false, or the quoted
"true", is what a careless edit produces, and those two spellings read opposite ways to a
generator that checks the key and one that checks the value. A method is published by the
absence of the marker, so there is never a reason to write any other value.
The tile-coordinate routes carry it, and so do the base map's documents, and they are the
only operations that do. A tile answers application/x-protobuf or a raster image, and it is
consumed as a URL template
rather than called: the matching /specification endpoint returns it under sources[].tiles
with the {z}, {x} and {y} placeholders intact, and a map library expands and fetches
them on its own schedule. A method handing those bytes back to the caller would be one
nobody can use, and a header credential cannot be baked into a URL template anyway, which is
why the fetch has to be the map library's. The base map's style, its TileJSON documents, its
sprites and its glyphs are the same case: a map renderer is handed each one's address, by the
layer catalog or by the style itself, and reads what arrives, a JSON document outside the
envelope included, so no method could do more than hand the renderer's input back.
The operation stays in the bundle and in the reference, and that is the whole point of the
marker being this narrow. An integrator wiring MapLibre or Mapbox needs the URL shape, the
parameters and the response media types, so what has to go is the generated method and
nothing else: the route is documented and unmethodded. check:extensions checks both directions, so an
operation carrying the marker whose path neither ends in /{z}/{x}/{y} nor is one of the base
map's document routes it names fails, and so does a tile or document route that lost it.
x-idempotent: whether a client may repeat the request
post:
operationId: postItineraryPublication
x-idempotent: true
x-pkey-scope: studio-embedded
Per-operation, boolean, and declared on every operation on both surfaces. It answers one
question and only that one: if an attempt fails without an answer, a dropped socket or a
502, 503 or 504, may a client send the identical request again without risking a second
effect? true says repeating cannot produce one. It promises no second EFFECT, not the same
answer: a delete repeated after the first one landed finds nothing left to delete and
answers 404, unless it carries the first one's Idempotency-Key, which replays the first
200. false says a repeat can have a second effect, so a client that retries without a key
may create a second resource or send a second signal. An Idempotency-Key, which every write
but postSession, postOrganizationLookup and the two collaboration signals takes, makes such a
write safe to repeat, which is why every Mountaya SDK sends one on every write that declares it.
sdk/SPEC.md is what reads it. Every Mountaya SDK repeats a 429 after its Retry-After, within
a total wait budget, repeats a 502, 503, 504 or network failure only where this extension
says true or the request carries an Idempotency-Key, and repeats a 409 coded
IDEMPOTENCY_KEY_IN_USE only where the request carries one; a caller writing
HTTP by hand may use a simpler rule, and two generated clients may not disagree about
when a request was sent twice.
It is not the HTTP method. Several POST operations here declare true, because each
of them is a set-or-get rather than a create: postItineraryShareLink and
postCollectionShareLink return the existing link, postOrganizationBillingProvision opens
no second billing account, postCollectionItineraries ignores a membership that is already
there. Reading idempotence off the verb would refuse every one of them a retry.
Enum extensions
x-enum-varnames / x-enum-descriptions: what a generator calls each member
An enum member is a wire string, and a wire string is rarely a legal identifier in the
language you are generating into. Left alone, each generator invents its own spelling, so
the same contract produces Draft, DRAFT, ItineraryStatusDraft and
itinerary_status_draft across four SDKs, and a member like raster-dem, or the
application/x-protobuf in OverlayContentType, has no obvious spelling at all.
# openapi/api/components/enums/itinerary/ItineraryStatus.yaml
type: string
enum:
- draft
- published
x-enum-varnames:
- ItineraryStatusDraft
- ItineraryStatusPublished
x-enum-descriptions:
- The itinerary has never been published. …
- The itinerary has at least one publication. …
Both are positional: entry n names and documents enum[n], so reordering the enum
list without reordering these two silently renames every member after the edit.
x-enum-descriptions becomes the doc comment on each generated constant, which is the only
place a reader meets the difference between two members that look interchangeable from
their names.
Neither changes the wire. The value sent and received is the string in enum, whatever the
constant beside it is called.
x-pkey-scope-includes: which scope reaches what another does
# openapi/api/components/enums/apikey/PublishableKeyScope.yaml
type: string
enum:
- publishing
- studio-embedded
x-pkey-scope-includes:
studio-embedded:
- publishing
Declared once, on PublishableKeyScope, and on no other schema. It is a map from a scope to the
scopes it includes: an operation whose x-pkey-scope is an included scope accepts a key carrying
the including one. x-pkey-scope itself stays a single value, so the extension is the one place
that says a key carrying studio-embedded may call a publishing operation, and a generated
client reads it to say so in the documentation of that method.
It widens which key is accepted and, on the Map Studio API alone, which products answer: a
publishing operation reached through studio-embedded passes when the organization holds Map
Publishing, or Embedded Studio and Map Studio, while the tiles of Mountaya's hosted embed are
asked for Map Publishing whichever scope the key carries. A scope absent from the map includes
nothing. npm run check:auth fails a key or a value that is not a member of the enum, and a scope that includes
itself.
x-pkey-scope-needs: which scopes a key carries beside another
# openapi/api/components/enums/apikey/PublishableKeyScope.yaml
x-pkey-scope-needs:
studio-embedded:
- tiles
- data
Declared once, on PublishableKeyScope, and on no other schema. It is a map from a scope to the
scopes a key carrying it must carry as well: creating or updating a key whose list names the
scope without one of them is a 400, one entry per missing scope naming scopes, judged with
the rest of the body and before any scope's product. Nothing is added for the caller, so a key
still carries exactly the scopes it was given, and a generated client reads the map to say so
in the documentation of the key operations, or to fill a form's list before it is sent.
It is a different relation from x-pkey-scope-includes. An inclusion widens which key an
operation accepts; a requirement widens nothing, and says only which lists a key write refuses.
studio-embedded needs tiles and data because an Embedded Studio draws its map on the Tile
API and asks its search and isochrone previews of the Data API, neither of which that scope
reaches. A scope absent from the map needs nothing. npm run check:auth fails a key or a value
that is not a member of the enum, a scope that needs itself or management, and a map that
does not say studio-embedded needs both.
Rendering extensions
x-codeSamples: a worked call beside the reference
Per-operation, read by the reference renderer rather than by any SDK generator, and
emitted by none of them. Each entry is a lang, a label and a source, and an operation
may carry several so a reader can compare two shapes side by side:
x-codeSamples:
- lang: curl
label: Mint a session token
source: |
curl -s https://api.mountaya.com/v1/sessions \
-H "X-API-Key: sk_..." \
-d '{"publishable_key": "pk_..."}'
Every operation carries one in each language, and check:docs fails a document where one
does not. The pair is the point: curl shows the wire and js shows what a client does
with data, so an operation carrying only one is the one a reader has to translate. A
sample that only restates the schema earns nothing, which is why the js one is written
to show the decision a caller has to make rather than the request they could have copied
from the curl beside it.
x-max-bytes: the most an uploaded part or an open map holds
On a format: binary property of a multipart/form-data body, the most bytes the part
holds; a larger one is answered 413. On an open map a request body carries, an agent run's
context or a client tool's parameters, the most bytes it may weigh once serialized, beside
the maxProperties it may hold; a larger one is a 400 naming it. The sentence beside it states the same figure for a
reader, and the extension states it as data, so a client refuses a file it would have sent
in vain and a gate can hold a client's own limit at or under it:
file:
type: string
format: binary
x-max-bytes: 26214400
postItineraryPreview's file part carries it, and so does every open map a request body
can carry.
The behaviour beside the shapes
A bundle says what an operation takes and returns. It cannot say when a request is repeated, how a session token is kept fresh, which failure maps to which class, or how an elevation series is derived, and every Mountaya SDK has to do all of that identically.
sdk/ is where that lives, carried by the same tag: SPEC.md states the behaviour in
prose, registry.yaml names each capability, and fixtures/**.json holds golden inputs and
their exact outputs, so two SDKs are checked against JSON rather than against whichever one was
written first. If you are writing an SDK, that directory is the rest of the contract.
One file in that directory is built rather than authored. sdk/model.json is the two bundles
and the SDL restated as a single language-neutral document: every schema with its nullability
settled, every operation with the credentials it declares, every Data API query with its ordered
selection tree and its full document. npm run build writes it and npm run check rebuilds it
and compares bytes, as they do the bundles, so it cannot describe a contract the documents
beside it do not. Mountaya's native SDKs print their generated surface from it. If you are
generating your own client, generate from the bundles and the SDL: the model's format has its
own version and is free to change from one tag to the next.
Versioning
VERSION carries the tag these artifacts belong to. The tag versions the contract:
| Change | Bump |
|---|---|
Removing or renaming an operationId | MAJOR |
| Narrowing a type, or adding a required field | MAJOR |
| Adding an operation, or an optional field | MINOR |
| Description, example or documentation only | PATCH |
Adding a required field is the one people expect to be minor. It is not: an existing caller that omits it starts getting a 400.
Below 1.0 there is no compatibility window. A MINOR tag may remove or rename any
operation, field, parameter or enum member, and change any status; the changelog names each
one, and nothing is deprecated before it is removed. A MINOR bump below 1.0 carries what a MAJOR
carries from 1.0 on, the standing semver gives 0.y, so a breaking change ships as
0.1.0 → 0.2.0 below 1.0 and as 1.4.2 → 2.0.0 above it. check:breaking, part of
mise run ci, enforces exactly this: it diffs the contract against the previous tag and fails a
breaking change that arrives under a bump too small to carry it.
From 1.0, an operationId is frozen. It is a method name in every language an SDK is
generated for, so renaming it breaks every caller in all of them at once. Retiring one means
deprecated: true here, emitted as a language-idiomatic deprecation in each SDK, Deprecation
and Sunset headers on its answers, and at least 180 days and two MINOR releases before its
removal in the next MAJOR.
SDKs carry their own semver, and the one generated from these documents records the
contract tag it was built from in a SCHEMA_VERSION constant, so a client can always
answer which contract it speaks without anyone consulting a table. The SDKs layered on top
of it hold no generated code and so carry no such constant.
Requirements
| Tool | How it is installed | What needs it |
|---|---|---|
| Node, npm, npx | pinned in mise.toml | Every script. Redocly comes with npm ci |
| oasdiff | downloaded, at a pinned version, by scripts/check-breaking-changes.sh | The breaking-change check |
Setup
sh .agents/setup # Entire, mise and the pinned tools, the git hooks, the dependencies
sh .agents/setup --check # what is missing, changing nothing
Commands
| Command | What it does |
|---|---|
mise run lint | Both bundles and both sources lint as OpenAPI |
mise run build | The bundles and the SDK model |
mise run ci:quick | What pre-commit runs: lint |
mise run ci | Every gate, on the Node floor and then on the pinned Node |
Pre-commit runs mise run ci:quick. Every push to Entire runs mise run ci on Depot CI
(.depot/workflows/ci.yml), and its result shows as checks on the commit.
Reporting a problem
A wrong type, a missing required, a nullable that never is: email support@mountaya.com,
and report it against these documents rather than against an SDK. There is no issue tracker;
CONTRIBUTING.md says what a useful report
carries. Every client is generated, so a fix in an SDK would be overwritten on its next
release. The defect is almost always in these files, and fixing it here fixes it in every
language at once.
If you would rather send the fix than the report, CONTRIBUTING.md says how to edit a source, rebuild the bundles and get the change past the gates. CHANGELOG.md records what moved between contract tags. A security issue goes to the same address, but read SECURITY.md first and keep it out of anything public: a public report is a disclosure.
License
Apache-2.0, in full in LICENSE. Generating a client from these documents and shipping it is the point; nothing here asks for attribution in your output. An SDK carries its own terms in its own repository, so check there before redistributing one of those.