Docs

Developer Conventions & Guidelines

The conventions the API follows and the expectations a well-behaved client should meet. They hold across every endpoint, so once you know them the interactive reference reads quickly.

Authentication & scopes

The API authenticates with a personal API key. Send it as a bearer token on every request:

Authorization: Bearer gma_<env>_<keyId>_<secret>

A request with a missing, malformed, unknown, or revoked key gets 401 unauthorized.

Scopes

A key holds one of two scopes:

  • read: read-only access (GET requests).
  • full_access: every operation, including writes. This is the default for a new key, and it covers any capability added in the future.

A key lacking the scope an operation requires gets 403 insufficient_scope.

API keys are server-side secrets. A key in browser, SPA, or mobile-app code is readable by anyone via devtools or view-source, and a leaked key exposes your account. A full-access key can also spend credits. If you're building a frontend, route requests through your own backend that holds the key.

Rotating keys

Keys are independent, so rotate them by overlap rather than by a gap that takes your integration offline:

  1. Create a new key from your account's Developer section.
  2. Update your integration to use the new key and deploy it everywhere the old key was used.
  3. Confirm traffic is flowing on the new key, then revoke the old one.

Both keys work during the overlap, so there's no downtime. Revocation takes effect immediately: the next request using the old key gets 401 unauthorized. Rotate on a schedule, and right away if a key may have leaked.

Versioning

Every path lives under /v1. Additive changes (new endpoints, fields, enum values, and error codes) ship within /v1; only breaking changes move to a future /v2. Detect features from the api_version returned by GET /v1/version, never from a build identifier such as git_sha.

Requests & formats

The API speaks JSON only. Send and expect application/json bodies.

Updates use JSON Merge Patch (RFC 7396), so PATCH bodies carry the content type application/merge-patch+json. An omitted field is left unchanged; an explicit null clears it.

Pagination

Lists are cursor-based. Pass limit (default 100; values above 500 are clamped to 500, not rejected) and read the page object on the response:

{
  "data": [ ... ],
  "page": { "next_cursor": "…", "has_more": true }
}

To fetch the next page, pass that next_cursor back as the cursor query parameter. Cursors are opaque and bound to the query's filters and sort (not its limit): echo them verbatim rather than building your own. next_cursor is null on the last page. An invalid or expired cursor returns 400; restart from the first page.

Filtering, sorting & fields

Filter, sort, and shape lists with flat query parameters over each list's allowlisted fields. Asking only for the rows and fields you need keeps responses smaller and lowers latency, so prefer narrowing the query over fetching everything and filtering client-side.

  • Filter by equality (status=active), membership (status_in=active,archived), comparison with the _ne, _gt, _gte, _lt, and _lte suffixes (created_at_gte=2026-01-01), or null-ness with _isnull (ttrpg_system_isnull=true / ttrpg_system_isnull=false). A null-valued field counts as “not equal” to any concrete value, so _ne matches it.
  • Sort with sort=, comma-separated, prefixing a key with - for descending (sort=-created_at,title). id is always appended as the final tiebreaker, so order is stable across pages.
  • Shape a response with fields=, a comma-separated allowlist (fields=id,title,created_at). Lists return a minimal field set by default and single GETs return the full representation; fields= narrows or widens either, and is honored on GET only.

Only allowlisted fields and operators are accepted; anything else returns 400 validation_error.

Conditional requests

Every read carries a weak ETag and an opaque GMA-Revision token. Use them to save bandwidth and to avoid lost updates.

  • Re-reads: send the prior ETag back in If-None-Match. If nothing changed you get 304 Not Modified with no body.
  • Safe writes: send the resource's GMA-Revision in If-GMA-Revision-Match on a PATCH/PUT/ DELETE. If the resource changed since you read it, the write is rejected with 412 precondition_failed instead of clobbering the newer state; re-read and retry.

Errors

Errors return the appropriate HTTP status and a JSON envelope:

{
  "error": {
    "code": "not_found",
    "message": "Campaign not found.",
    "status": 404,
    "details": [ { "field": "…", "code": "…", "message": "…" } ]
  }
}

code is a stable, machine-readable identifier; switch on it in code. message is for humans, not for programmatic use. details is optional and carries field-level errors, for example on a validation_error. code is an open enum: tolerate values you don't recognize and fall back to the HTTP status.

Rate limits

Responses carry X-RateLimit-Limit, X-RateLimit-Remaining, and X-RateLimit-Reset so you can pace requests. When you exceed a limit the API returns 429 rate_limited with a Retry-After header; honor it and back off before retrying.

Asynchronous operations

Long-running work (such as session analyses) returns 202 Accepted along with a resource you poll for progress. A failure surfaces on that resource's error object rather than as an HTTP error on a later poll, so check the resource state, not just the status code.

Forward compatibility

The API grows additively, so build clients that tolerate change: ignore unknown JSON fields, accept unknown enum values (every enum is open and may gain members), and map an unrecognized value to a safe default rather than failing. Following these rules means new additions within /v1 won't break your integration.