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 (GETrequests). -
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.
Rotating keys
Keys are independent, so rotate them by overlap rather than by a gap that takes your integration offline:
- Create a new key from your account's Developer section.
- Update your integration to use the new key and deploy it everywhere the old key was used.
- 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_ltesuffixes (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_nematches it. -
Sort with
sort=, comma-separated, prefixing a key with-for descending (sort=-created_at,title).idis 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
ETagback inIf-None-Match. If nothing changed you get304 Not Modifiedwith no body. -
Safe writes: send the resource's
GMA-RevisioninIf-GMA-Revision-Matchon aPATCH/PUT/DELETE. If the resource changed since you read it, the write is rejected with412 precondition_failedinstead 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.