schemas

View all files

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

SurfaceContractBase URL
Map Studio APIopenapi/api.yamlhttps://api.mountaya.com
Tile APIopenapi/tiles.yamlhttps://tiles.mountaya.com
Data APIgraphql/data/*.graphqlshttps://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 with postSession (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 in X-API-Key and the secret key in X-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 is mcp.
  • A session token (sess_) rides X-Session-Token beside 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 read expires_at rather 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 its management and studio-embedded operations; and both REST surfaces require one on the publishable-key requests that remain, the Map Studio API's publishing reads and every Tile API request, when the organization enables the require_session_token safety 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: postSession takes a user_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's surface: api, the default, for your own clients, or studio-embedded, which every Embedded Studio needs, the editor and its read-only viewer alike, and which is the only token a studio-embedded operation accepts, any other being a 401 there. It is minted only for a key reaching the studio-embedded scope (a 403 otherwise) of an organization holding Embedded Studio (a 402 otherwise), 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_key and session_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 a 401, 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_) rides X-Share-Token or the share_token query 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 no Origin or Referer is not checked at all. Reading, minting, rotating and removing a link are published operations, each taking a publishable key with the studio-embedded scope and a session token minted for studio-embedded naming 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.

WhatLimit
Tile API, your own clients and the hosted embed600 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 Studio6,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 links600 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 surface6,000 a minute per calling address, in place of every other bound of its surface
Data API, your own clients60 requests a minute per organization unless its agreement says otherwise, each request carrying up to four root fields
Data API, an Embedded Studio300 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 readunder 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 waybeside 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, postOrganization10 a minute and 50 a day per user
Map Studio API, the operations that cost the mostimport 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/sessions60 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/lookup60 a minute per calling address
Every operation of the three APIs that takes a publishable key60 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 returns60 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:

GroupWhat it holdsResources
studiothe map itselfactivities, collab, collections, itineraries, layers, zones
adminthe organization behind it, and the user a request acts asbilling, invitations, keys, members, organizations, sessions, users
agentconversational route buildingruns

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.

ValueSurfaceMeaning
publishingMap StudioMap 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-embeddedMap StudioEmbedded 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
managementMap Studioeverything 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
tilesTileevery Tile API operation, its own layer catalog getTileLayers among them, so a map drawn with the Tile API alone finds its layers without publishing
noneMap Studiono 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:

ChangeBump
Removing or renaming an operationIdMAJOR
Narrowing a type, or adding a required fieldMAJOR
Adding an operation, or an optional fieldMINOR
Description, example or documentation onlyPATCH

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

ToolHow it is installedWhat needs it
Node, npm, npxpinned in mise.tomlEvery script. Redocly comes with npm ci
oasdiffdownloaded, at a pinned version, by scripts/check-breaking-changes.shThe 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

CommandWhat it does
mise run lintBoth bundles and both sources lint as OpenAPI
mise run buildThe bundles and the SDK model
mise run ci:quickWhat pre-commit runs: lint
mise run ciEvery 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.