API conventions¶
How the AppGantry REST API is shaped on the wire, regardless of which route you're calling.
Base URL & versioning¶
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.
The interactive API reference is generated from the service itself and is the authoritative description of request and response shapes.
What is still not in the schema
The OpenAPI document now describes organization creation, lookup and deletion, the audit feeds, BYOSA onboarding, and SSO configuration. What it does not describe is the internal operator endpoints; the canonical list is Feature availability. The audit CSV export is not an omission — it is a web-app download built from the audit feeds, not an API operation.
Content type¶
Most API request bodies are JSON, and responses are JSON, with these exceptions:
| Route | Content type |
|---|---|
| The signed upload URL returned by upload-initiate | Raw bytes (PUT), not an API route |
Profile avatar and organization/project icon uploads (POST) |
multipart/form-data |
Creating an organization (POST /api/v1/organizations) |
Request application/json; the 201 carries no Content-Type at all — the body is the new organization's UUID as raw text |
SAML metadata (GET .../sso/metadata) |
application/samlmetadata+xml |
| SSO sign-in, assertion-consumer and single-logout endpoints | No body at all on success — a 302/303 redirect carrying Location |
Email verification (POST) |
application/json or application/x-www-form-urlencoded |
Email verification (GET), captcha |
text/html |
| Over-the-air install manifest | Apple's property-list media type |
| Liveness and readiness probes | JSON |
Creating an organization does not answer JSON, and sends no Content-Type.
POST /api/v1/organizations takes a JSON body and answers 201 whose body
is the new organization's UUID and nothing else — no quotes, no wrapper object,
and no Content-Type response header. Read it as text. Calling .json() on it
raises, which is the failure most likely to be met first, because creating an
organization is one of the first calls a new integration makes. Its error
responses are the ordinary JSON envelope.
Why the reference shows text/plain here
OpenAPI has no way to describe a response that declares no media type, so
the schema models this 201 as text/plain — that is the closest
available description of a plain-text body. The wire omits the header
entirely. If your client picks a parser from Content-Type, give this one
route an explicit text parser rather than relying on the header.
SAML metadata is XML. GET /api/v1/organizations/{organization_id}/sso/metadata
answers application/samlmetadata+xml, because that is what an identity
provider expects to consume. Hand the URL to the identity provider
rather than parsing it yourself.
The three browser-facing SSO endpoints answer a redirect, not a body.
GET .../sso/login, POST .../sso/acs and GET .../sso/slo are driven
by the browser: on success they answer 302/303 with a Location
header and no content. Follow the redirect; do not expect a payload.
Their failures are the ordinary JSON envelope.
Image uploads are the one form-encoded exception. A profile avatar
(POST /api/v1/developers/{developer_id}/avatar) and an organization or
project icon (POST /api/v1/organizations/{organization_id}/icon,
POST /api/v1/organizations/{organization_id}/projects/{project_id}/icon,
POST /api/v1/projects/{project_id}/icon) are small images sent as a
form part rather than as JSON. The image type is determined from the
bytes rather than from the Content-Type you declare, and an oversized
body is refused with a 413 — icons
are capped at 1 MiB. The exact field names and response shapes are
in the interactive API reference.
That exception does not generalise to artifacts. There is no multipart build upload: build artifacts are never posted to an API route; see below.
Uploading a build¶
Canonical page
This section is the canonical description of the upload contract. Other pages link here rather than restating it, so this is the one to read — and the one to fix — if something disagrees.
Build upload is a two-phase flow, and the bytes never pass through the API:
POST /api/v1/builds/initiate?organization_id=…&project_id=…
→ { pending_upload_id, upload_url, ... }
PUT <upload_url> → the raw artifact bytes
POST /api/v1/builds/{pending_upload_id}/complete?organization_id=…&project_id=…
→ { the created build }
-
Initiate. Scope the call with the
organization_idandproject_idquery parameters, and describe the build in the body:version_nameandbuild_numberare required, andbundle_identifier,original_filename, andrelease_notesare optional. There is nosizefield — you don't declare the artifact's size up front. You get back apending_upload_idand a time-limited signed URL pointing at object storage. -
Upload.
PUTthe artifact bytes straight to that URL, and compute the SHA-256 as you stream.The signed URL addresses Azure Blob Storage, not AppGantry, which has two consequences:
- You must send the
x-ms-blob-type: BlockBlobheader. Azure rejects thePUTwith400 MissingRequiredHeaderwithout it. - You must not send an AppGantry
Authorizationheader. The signature in the URL is the credential; an extraAuthorizationheader can cause Azure to reject the request.
- You must send the
-
Complete. Tell the API the upload finished and pass the SHA-256 you computed, using the same
organization_idandproject_idscoping. AppGantry verifies the stored object's size against the artifact cap and records your SHA-256 as the build's integrity fingerprint, then promotes the pending upload into a real build.
What the checksum is, and isn't
The SHA-256 you send at complete is stored as a fingerprint you can check later — it is not re-derived from the uploaded bytes, so it is not a server-side detection mechanism for a corrupt upload. Verify your own artifact before uploading and after downloading. See Security.
| Constraint | Value |
|---|---|
| Signed URL / pending upload lifetime | 1 hour |
| Maximum artifact size | 500 MiB |
| Maximum pending uploads per project | 25 |
A pending upload that is never completed expires and is swept up; it is not billed. See CI uploads for a working example, and First build troubleshooting for what the common failures look like.
Authentication¶
| Mechanism | Header |
|---|---|
| Access JWT | Authorization: Bearer <jwt> |
| Personal access token | Authorization: Bearer ag_pat_… |
| Project access token | Authorization: Bearer ag_prj_… |
| Browser session | Cookie, set at login |
See Authentication for token lifetimes, refresh behaviour, and the rest of the credential types.
Success response envelopes¶
Single resource¶
A plain JSON object shaped by the route's response model:
Lists¶
Collection routes return a cursor page:
next_cursor is null on the last page. Walk pages by passing
?cursor=<token> until you get null back.
A few responses are deliberately not cursor-paginated because their size is bounded by something other than your data — for example the organizations a developer belongs to (capped by the per-developer organization limit) or a project's current release per platform (capped by the platform list). Those return a plain JSON array.
The interactive reference shows which shape each operation returns. Don't infer it from a list on this page.
Pagination details¶
?limit=<N>— page size. Default 100, hard cap 1000. Asking for more than the cap gives you the cap, not an error.?cursor=<token>— an opaque token taken verbatim from a previous response'snext_cursor.- Treat the cursor as a black box. Don't parse, construct, or persist one across API versions; the encoding can change.
Don't rely on lenient cursor handling
A malformed or stale cursor currently restarts from the first page
instead of returning an error. That behaviour is permissive, not
contractual — a future release may reject it with invalid_input.
Write your client so that it only ever passes back a next_cursor
it just received.
The audit feed is not cursor-paginated: it is read one UTC day at a time, or exported over a date range. See Audit events.
Parent scoping¶
Scoping is not uniform, and you should not guess it. Two patterns coexist:
| Pattern | Used by | Example shape |
|---|---|---|
| Query parameter | Most collection routes — builds, channels, releases, testers, tokens | ?organization_id=…, ?project_id=…, ?channel_id=… |
| Path segment | Organization-scoped governance — settings, members, invitations, storage configuration, SSO | /organizations/{organization_id}/… |
Path parameters otherwise carry the identifier of the resource the
operation acts on ({build_id}, {release_id}, {tester_id}).
Follow each operation's own parameter list in the interactive reference. There is no universal rule to apply, and older versions of this documentation were wrong to claim there was one.
Errors¶
Almost every 4xx and 5xx response from the API uses one flat envelope:
The error string is part of the contract — pattern-match on it
rather than on message, which is written for humans and can be
reworded.
There is one exception: the two not-configured 404s answer a bare
{"error": …} with no message and no details. Request-validation
failures are not an exception — they answer 400 with this envelope
and the failing fields under details.errors.
Errors documents every code, its status, the exception, and what to do about each.
Idempotency and retries¶
Canonical page
This section is the canonical description of retry semantics. Other pages link here.
The API does not implement an Idempotency-Key header. Sending one
has no effect. That single fact drives everything below: because there
is no key that lets the server recognise a replay, a retried write is
simply a second write. Retry safety comes from the shape of the
operation instead.
| Situation | What to do |
|---|---|
GET of any kind |
Retry freely, with backoff |
| A request that failed with 400, 401, 403, 404, 409, or 429 | Retry is safe — nothing happened. A validation_error will keep failing until you fix the request, and so will a 409 from builds/initiate until the identity it conflicts on is free |
| A 402 | Retry once the underlying billing state is fixed |
A 503 with Retry-After |
Retry after the indicated delay — the operation had no effect |
A PUT of artifact bytes to a signed URL |
Retry while the URL is valid |
| Upload-complete | Retry with the same pending_upload_id and checksum |
| A 500 on a read | Retry with exponential backoff |
| A 500 on a write | Check whether it landed first, then decide |
| A creation call that timed out with no response | Check first, then create |
Why a 500 on a write is different¶
A 500 tells you the request failed somewhere on the server. It does not
tell you when — the write may have been committed and the failure may
have happened while building the response. Without an Idempotency-Key
the server cannot collapse your retry onto the original attempt, so a
blind retry can create a duplicate.
For a non-idempotent write (creating a channel, inviting a tester,
minting a token, completing an upload against a fresh
pending_upload_id), the correct sequence is:
- Read back the collection or resource to see whether the operation landed.
- If it did, treat the 500 as a success you didn't get to see.
- If it didn't, retry with backoff.
For creates where a re-read is awkward, retry once and treat
already_exists (409) as success — that is the closest thing the API
has to an idempotent create.
Except on POST /builds/initiate. A 409 there can mean the identity
you asked for — project, platform, version name, build number — is
claimed by a pending upload that was never completed, rather than by a
build. Reading it as success reports a build that does not exist. Either
initiate again with a fresh build number or version name, or wait
for the pending row to be swept and initiate the same identity again.
Only a 201 from initiate, followed by a successful complete, means you
have a build. See
Errors → 409 when initiating a build upload.
Rate limits¶
Rate limiting is applied at the edge, before requests reach the API, and the thresholds are not published — they change with traffic conditions and abuse patterns. Build clients that cope with 429 rather than clients tuned to a specific number. See Rate limits.
Cookies¶
Browser sessions use two HttpOnly, SameSite=Lax cookies, Secure
outside local development: one carrying the short-lived access token and
one carrying the refresh token. The refresh cookie is path-scoped to
the login routes, so the browser doesn't send it on every API request.
API clients don't need cookies. Use a bearer token.
See also¶
- Authentication: credential types and lifetimes.
- Authorization: roles, scopes, and what refuses long-lived tokens.
- API surface map: which resource family owns the operation you're about to call.
- Errors: the full code taxonomy.
- Rate limits: 429 handling.
- Glossary: terms used across the API.