Skip to content

API conventions

How the AppGantry REST API is shaped on the wire, regardless of which route you're calling.

Base URL & versioning

https://api.appgantry.com/api/v1

All routes mount under /api/v1. Breaking changes go in a new prefix (/api/v2) running side-by-side rather than mutating v1 in place. Non-breaking additions land in v1.

Content type

JSON on the request and response, with these exceptions:

Route Content type
POST /api/v1/builds multipart/form-data (the binary is one of the parts)
POST /api/v1/developers/{id}/verify_email application/json or application/x-www-form-urlencoded
GET /api/v1/developers/{id}/verify_email text/html (verification landing page)
GET /api/v1/captcha text/html
GET /api/v1/live, GET /api/v1/ready JSON {status, [checks]}

Authentication

Mechanism Header
Access JWT Authorization: Bearer <jwt>
Project access token Authorization: Bearer ag_prj_...
Personal access token Authorization: Bearer ag_pat_...
Browser cookie Cookie: dfdeveloper=<jwt> (HttpOnly, set at login)

See Authentication for token lifetimes, refresh behaviour, and scope details.

Success response envelopes

Single resource

A plain JSON object shaped by the route's response model. Example:

{
  "identifier": "01HX...",
  "name": "Acme iOS"
}

Lists (cursor-paginated)

{
  "items": [...],
  "next_cursor": "<opaque base64 token or null>"
}

next_cursor is null on the last page. Walk pages by passing ?cursor=<token> until you see null.

Cursor-paginated endpoints:

  • GET /api/v1/buildsBuildPage
  • GET /api/v1/releasesReleasePage
  • GET /api/v1/testersTesterPage
  • GET /api/v1/project-access-tokensProjectAccessTokenPage
  • GET /api/v1/patsPatPage

Endpoints not enveloped because they're domain-bounded:

  • GET /api/v1/organizations: per-developer, capped at the configured per-developer org limit.
  • GET /api/v1/releases/current/all: bounded by the platform enum.

The audit-feed endpoint (GET /api/v1/audit/organization/{id}) is day-paginated, not cursor-paginated. See Audit & spend caps.

Pagination details

  • ?limit=<N>: page size. Default 100; hard cap 1000.
  • ?cursor=<token>: opaque cursor from a previous response's next_cursor.
  • Treat the cursor as a black box. The server may change its encoding between releases.
  • A malformed cursor returns the first page rather than an error Note: that conservative fallback means an outdated client cursor won't break pagination, it just resets it.

Error response envelope

Every 4xx and 5xx response from the API uses this shape. There are no hand-rolled alternatives:

{
  "error": "<machine_code>",
  "message": "<human_string>",
  "details": { ... }
}

details is always present (defaulting to {}) so clients can read the key without conditional handling.

Error codes → HTTP status

Error code HTTP status
not_found 404
already_exists, dependency_exists 409
invalid_input, validation_error 400
unauthorized 401
permission_denied, insufficient_role, email_not_verified 403
operation_not_allowed, quota_exceeded 400
rate_limit_exceeded 429
spend_cap_exceeded, prepaid_credit_exhausted 402
payload_too_large 413
storage_backend_unavailable 503 (+ Retry-After)
internal_error 500

Error codes are part of the API contract. The string value ("unauthorized", "permission_denied") is the canonical form clients pattern-match on. Renaming one is a breaking change.

402 on uploads and downloads

Upload-initiate and download-URL requests are billing-gated. A request returns 402 with:

  • error: "prepaid_credit_exhausted": pre-paid Team org with a $0 balance, or
  • error: "spend_cap_exceeded": post-paid org that hit a configured monthly cap.

Read endpoints (metadata, listings) are never billing-gated.

Each admitted upload/download briefly reserves its projected cost while in flight, so:

  • A 402 can appear slightly before your displayed balance reaches zero, because concurrent requests count against it.
  • A download URL that's minted but never used reserves credit briefly; that reservation auto-releases within ~30 minutes.

A 402 is safe to retry once the balance is topped up or the next billing period begins. It carries no side effect.

503 on storage-backend blips

storage_backend_unavailable (HTTP 503) is the retryable-backend signal: a transient blob-resolver / audit-feed-read failure carries a Retry-After header. Back off and retry; the request has no side effect.

Standard query parameters

Multi-tenant routes carry the parent id as a query parameter, not in the path:

  • ?organization_id=<uuid> on every project / build / release / channel / tester / PAT route.
  • ?project_id=<uuid> on every channel / build / release / tester route.
  • ?channel_id=<uuid> on every release / tester-invite route.

Path parameters carry the resource id of the current operation ({build_id}, {release_id}, {tester_id}).

Idempotency

Mutating routes that take an Idempotency-Key header are safe to retry with the same key. The server returns the original result rather than performing the action twice. Use a fresh UUID per logical operation in your client; reusing a key across logically distinct operations is undefined.

Rate limits

Routes carry per-route rate limits. When you hit one you get:

HTTP/1.1 429 Too Many Requests
Retry-After: 30
Content-Type: application/json

{
  "error": "rate_limit_exceeded",
  "message": "...",
  "details": {"retry_after_seconds": 30}
}

Respect Retry-After. Exponential backoff with jitter is good practice; sustained rate-limit floods will eventually be account-level flagged.

Cookies

Set on browser login:

Cookie Purpose Scope
dfdeveloper Access JWT Whole API
dfdeveloper_refresh Refresh token Path-scoped to /api/v1/login

Both are HttpOnly, SameSite=Lax, and Secure outside local dev. The refresh cookie's path scope means the browser only sends it to refresh / logout, not to every API request.

See also