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.

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 }
  1. Initiate. Scope the call with the organization_id and project_id query parameters, and describe the build in the body: version_name and build_number are required, and bundle_identifier, original_filename, and release_notes are optional. There is no size field — you don't declare the artifact's size up front. You get back a pending_upload_id and a time-limited signed URL pointing at object storage.

  2. Upload. PUT the 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: BlockBlob header. Azure rejects the PUT with 400 MissingRequiredHeader without it.
    • You must not send an AppGantry Authorization header. The signature in the URL is the credential; an extra Authorization header can cause Azure to reject the request.
    curl --fail-with-body -X PUT "$UPLOAD_URL" \
      -H 'x-ms-blob-type: BlockBlob' \
      --data-binary @app.ipa
    
  3. Complete. Tell the API the upload finished and pass the SHA-256 you computed, using the same organization_id and project_id scoping. 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:

{
  "identifier": "0f1a...",
  "name": "Acme iOS"
}

Lists

Collection routes return a cursor page:

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

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's next_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:

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

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:

  1. Read back the collection or resource to see whether the operation landed.
  2. If it did, treat the 500 as a success you didn't get to see.
  3. 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