Skip to content

Errors

Almost every error the AppGantry API returns uses one flat envelope. This page is the catalogue — and it names the exception, because a client that assumes the envelope everywhere will fail to parse two of the reads it is most likely to try early.

Canonical page

This page is the canonical owner of the error taxonomy: codes, statuses, billing gates, and limits. Other pages link here rather than restating them. API conventions owns the upload contract and retry semantics.

The envelope

{
  "error": "<machine_code>",
  "message": "<human_string>",
  "details": { }
}
Field Notes
error Machine-readable code. Part of the API contract: match on this.
message Human-readable, safe to show in a UI, subject to rewording. Never match on it.
details Present on the full envelope, defaulting to {}. Absent on the two bare bodies described below.

Renaming an error code is a breaking change. Adding a new one is not, so treat an unrecognised code as a generic failure of its HTTP status class rather than crashing.

This envelope is published in the schema as ErrorResponse, and the operations that can return it advertise it per status code.

Request validation uses the same envelope. A missing field, a wrong type, or a malformed UUID in the path answers 400 with error: "validation_error" and the failing fields under details.errors — see Validation failures. It is published as ValidationErrorResponse, which is the envelope above with details.errors filled in.

The two exceptions

Read error defensively. Two responses are not the full envelope:

Exception Shape Where
Not-configured 404s A bare {"error": "…"}error only, no message, no details GET .../organizations/{id}/sso and GET .../organizations/{id}/byosa/onboarding-request

The bare 404 is deliberate. Those two reads answer the same 404 whether the feature is simply not configured or the organization does not exist / you cannot see it — the second case answers the full envelope, and the two are indistinguishable on purpose so the routes cannot be used to enumerate organizations. So on those two routes:

  • read error with a default rather than indexing details;
  • do not infer that an organization exists from the shape of its 404.

Everything else uses the full envelope, including the validation failures described next — they are the ones you will hit first.

Validation failures

A request that fails schema validation — a missing required field, a wrong type, a malformed UUID in the path, a query parameter that isn't a valid date — answers 400 Bad Request with the flat envelope and error: "validation_error":

{
  "error": "validation_error",
  "message": "Field required",
  "details": {
    "errors": [
      {
        "type": "missing",
        "loc": ["body", "version_name"],
        "msg": "Field required"
      }
    ]
  }
}

details.errors carries one entry per failing field, and each entry carries exactly these three keys:

Field Meaning
type Machine-readable validation failure kind, for example missing, string_type, uuid_parsing.
loc Path to the offending value: the first element is the part of the request (body, query, path, header), the rest addresses the field.
msg Human-readable explanation of that one failure.

message repeats the first entry's msg, so a client with nowhere to render a list still has something to show.

The value you sent is never echoed back. There is no input field and no ctx field: a rejected password or token would otherwise land in the response body and in every log downstream of it, so the response is restricted to the three keys above. Do not code against a fourth.

Handle it with the same parse as every other error:

body = response.json()
code = body.get("error")                       # flat or bare envelope
if code == "validation_error":
    problems = body["details"]["errors"]       # list of field failures

This is the ValidationErrorResponse shape in the schema, and it is the most widely declared error response there: any operation that takes a path parameter, a query parameter, or a body can return it.

A validation_error is never fixed by retrying the same request. Fix the request.

Codes by status

400 Bad Request

error Meaning
invalid_input A field was missing, malformed, or out of range.
validation_error The request failed schema validation: a missing field, a wrong type, a malformed identifier. details.errors names the offending fields — see Validation failures.
operation_not_allowed The request is well-formed but not legal in the resource's current state — for example rolling back a channel with nothing to roll back to.
quota_exceeded A structural count limit would be exceeded: too many organizations, projects, or passkeys.

validation_error is the most widely declared error response in the schema: any operation that takes a path parameter, a query parameter, or a body can return it.

quota_exceeded is not a billing error, and it is not a size error. It means a structural count limit, so retrying won't help until you delete something. Something that is too big rather than too many is a 413, not a 400. See Feature limits.

401 Unauthorized

error Meaning
unauthorized No credential, an unrecognised credential, or an expired one.

Refresh your access token and retry once. If a refresh token is rejected, the whole session is gone: sign in again. Refresh tokens are single-use, and presenting one twice revokes the entire session family as a theft-detection measure.

403 Forbidden

error Meaning
permission_denied The credential is valid but not allowed to do this — often a token used on a surface that requires an interactive sign-in, or one whose scope doesn't cover the operation.
insufficient_role The caller's role in the organization or project is too low.
email_not_verified The account exists but hasn't verified its email; state-changing calls are refused until it does.
mfa_enrollment_required An organization the caller belongs to requires multi-factor authentication and the caller has no factor enrolled.

A 403 is never fixed by retrying. See Authorization.

404 Not Found

error Meaning
not_found The resource doesn't exist, or the caller isn't allowed to know that it does.

AppGantry deliberately returns 404 rather than 403 for resources outside your tenancy, so a 404 on something you believe exists usually means you are addressing it from the wrong organization or with the wrong token.

Two reads answer 404 for a second reason as well, and with a different body: reading an organization's SSO connection, and reading its storage-account onboarding request. See The two exceptions.

402 Payment Required

Payment-required responses are the billing gate. Five codes, in two different categories — the distinction matters because three of them lock the organization and two don't:

Organization write locks

The organization is in a billing state that blocks all writes until a human fixes it. Nothing you change about the request will help.

Each of these codes maps to exactly one details.lock_reason:

error details.lock_reason Meaning How to clear it
billing_suspended suspended Payment problems went unresolved Settle the outstanding balance
payment_required awaiting_payment The organization committed to a paid plan but no payment method has been captured yet Add a payment method
trial_ended grace A no-card trial ran out of time and no card has been added since Add a payment method

So the response carries the state twice — once as the code you match on, once in details:

{
  "error": "billing_suspended",
  "message": "This organization is suspended.",
  "details": { "lock_reason": "suspended" }
}
{
  "error": "trial_ended",
  "message": "Your trial has ended. Add a payment method to continue.",
  "details": { "lock_reason": "grace" }
}

suspended, awaiting_payment, and grace are the only values to expect in lock_reason.

grace means an expired trial, not a failing card. The trial timer ran out on an organization that never had a payment method; adding one clears it.

Under trial_ended, uploads, downloads, and organization-scoped writes are all refused until a payment method is added.

Under suspended, new builds are refused while downloads of existing builds keep working for a configured period, so testers aren't cut off the moment a card expires.

A payment that is still being retried is not a write lock

While the payment provider is retrying a charge — the past-due, dunning-in-progress state — the organization is not locked and keeps writing normally. A 402 arrives only once the retries are exhausted and the organization becomes suspended.

Per-request billing gates

The organization is not locked. This particular request was refused because of the balance or cap it would consume. These responses do not carry details.lock_reason.

error Meaning How to clear it
prepaid_credit_exhausted The organization's pre-paid balance reached zero. A trial that burns through its starter credit reports this too — not trial_ended Top up, or add a payment method if you're on a trial
spend_cap_exceeded The request would exceed the organization's monthly spend cap Raise or clear the cap, or wait for the next period

What is gated, and what isn't

Gated: minting an upload URL, completing an upload, and minting a download URL. Never gated: reading metadata — build lists, tester lists, settings, the audit feed — so a locked organization is never a locked-out organization.

Reservations. Each admitted upload or download briefly reserves its projected cost while in flight. Two consequences:

  • A 402 can appear slightly before your displayed balance reaches zero, because concurrent requests count against it.
  • A download URL that is minted but never used holds a small reservation that releases itself shortly afterwards.

A 402 carries no side effect and is safe to retry once the underlying billing state is fixed.

409 Conflict

error Meaning
already_exists A uniqueness constraint would be violated — a duplicate name, email, or identifier.
dependency_exists Something still refers to the resource you're deleting. Remove the dependants first.

If you're retrying a create that may have partially succeeded, treating already_exists as success is usually the right call. POST /api/v1/builds/initiate is the exception — see below.

409 when initiating a build upload

Initiating an upload claims one identity: the project, its platform, the version name, and the build number. Two different situations answer 409 already_exists on that claim:

  • a completed build already holds that identity, or
  • an earlier initiate for that identity is still pending — the upload it minted was never completed.

The second one is the one that catches a pipeline out. A pending row holds its claim until it is swept, and it is swept only after it expires, so a re-initiate that follows a failed upload can be refused for a while even though no build was ever created.

So a 409 here does not tell you a build exists, and treating it as success reports a green pipeline for an artifact nobody can install. Recover by giving the upload a fresh identity (a new build number, or a new version name) or by waiting for the pending row to be swept and initiating again. See CI → Failure modes worth handling.

No operation declares a 409 in the published schema. It is raised while the request is being handled rather than declared per operation, so nothing derives it into this reference — the same gap as 429. Handle it by status.

413 Content Too Large

error Meaning
payload_too_large The request or the stored artifact exceeded its size limit.

Build artifacts are capped at 500 MiB, checked against the stored object at upload-completion time. Icons are capped at 1 MiB. Exceeding either is a 413 payload_too_large, not a 400 quota_exceeded — size limits and count limits are different things.

429 Too Many Requests

error Meaning
rate_limit_exceeded You're sending requests faster than allowed, or you hit one of the per-resource application limits below.

A 429 comes from one of two places, and they do not look the same:

  • The edge, in front of the API. The body may not be the AppGantry envelope at all, because the request never reached application code.
  • The application, which uses the flat envelope with error: "rate_limit_exceeded" and, for some limits, a populated details.

No operation declares a 429 in the published schema. That is a gap in the schema rather than a guarantee: handle 429 by status class on every call.

Application-level 429s

These are deliberate product limits rather than traffic shaping, so they are worth handling specifically rather than as generic throttling:

Refused request Limit Notes
Re-sending a pending organization or project member invitation One send per invitation per ~5 minutes, counting the original A missing invitation — one that never existed, or that expired past its 14 days and was swept — an already-accepted one, and an already-revoked one all answer 404 rather than 429, and those three are deliberately indistinguishable from one another so the endpoint can't be used to probe. The 404 and the 429 are not interchangeable: a 429 tells you the invitation is still pending and was sent too recently, whether that send was the original invitation or a later resend
Re-sending a pending organization or project tester notification One send per pending grant per ~5 minutes, counting the one sent on add The same shape. A grant that never existed, one that has already attached — a tester grant is not accepted, it attaches when the invited address is verified — and one that has been cancelled all answer 404, and those three are indistinguishable from one another; the throttle is the separate 429
Requesting a personal data export One new request per 24 hours, and at most 5 in a rolling 30 days Carries a structured details — see below
Downloading a ready export archive 20 downloads per archive A plain message; request a fresh export to download again
Initiating a build upload 25 uploads in flight per project Refused with 429 once the project holds 25 uploads that have been started and not completed. A ceiling on work in flight rather than a plan quota, so it clears as they complete or expire — it is not a 400 quota_exceeded

A repeat export request that lands while one is still being prepared is not refused: it collapses onto the in-flight job and consumes no quota.

The export-generation refusal carries an explanation in details because refusing a data-subject access request has to say why:

{
  "error": "rate_limit_exceeded",
  "message": "You've reached the limit for how often a fresh copy of your personal data can be generated. Your existing export is still available to download, and you can request a new one after the current period.",
  "details": {
    "reason": "repeat_export_rate_limited",
    "explanation": "…why the refusal is permitted, and that it never blocks a first request in a period…",
    "right_to_complain": "…your right to complain to a supervisory authority and to a judicial remedy…"
  }
}

Match on details.reason if you want to tell that refusal apart from ordinary throttling; treat the two prose fields as human-readable text that may be reworded.

One family of throttles deliberately does not produce a 429. Signup verification, password reset and the verification for an email change each have a ~5-minute per-recipient cooldown of their own, and a second email of that kind inside its own window is silently absorbed: the response looks like a success, because answering differently would confirm whether an address is on file. A send of one kind never absorbs another. See Authentication → Email verification.

See Rate limits for handling.

503 Service Unavailable

error Meaning
storage_backend_unavailable The storage backend couldn't be resolved or reached. Transient.
captcha_unavailable The captcha verification service couldn't be reached, so the request was refused rather than admitted unverified.
store_connection_unavailable An app store provider couldn't be reached to validate credentials.

These are retryable and the request had no side effect. Four operations advertise a Retry-After on their 503 in the schema: POST /api/v1/builds/initiate and GET /api/v1/builds/{build_id}/download (a transient storage-backend lookup failure), and the two audit reads (the audit store didn't answer within the read timeout). Honour the header when it is present and fall back to exponential backoff when it isn't. Other 503s — a captcha or store-provider outage, for example — carry no Retry-After, so don't assume every 5xx does.

500 Internal Server Error

error Meaning
internal_error Something went wrong on our side.

A 500 on a read can be retried with exponential backoff.

A 500 on a write is different. The failure may have happened after the write committed, and there is no Idempotency-Key for the server to collapse your retry onto the original attempt — so a blind retry can duplicate the work. Read back the resource or collection first to see whether the operation landed, then retry only if it didn't. See Idempotency and retries.

The audit reads are the exception to "500 means we broke": they return 500 specifically when the audit store rejected the query, so a 500 there is about the query, not about the service being down.

If it persists, tell us with the time, the operation, and the message.

Retry-After and other error headers

Headers carry information no status code can:

Header On Meaning
Retry-After 503 on upload initiate, build download, and the two audit reads; may appear on 429 Seconds to wait before retrying. Honour it in preference to your own backoff.
X-Audit-Truncated 200 on the two audit reads Present and true when the query hit its result cap, so the trail you got back is incomplete. Absent when the result is complete.

X-Audit-Truncated is not an error, but it is the most easily missed correctness signal in the API: a truncated audit read looks exactly like a complete one in the body. The caps are 1000 events for a single-day query and 5000 for a date-range export. Narrow the range, or walk it day by day, when you see it. See Audit events.

Feature limits

Some limits are counts and produce quota_exceeded (400):

Count limit Value
Organizations per developer 5
Projects per organization 50
Passkeys per developer 20

Others are sizes and produce payload_too_large (413):

Size limit Value
Build artifact size 500 MiB
Icon size 1 MiB (PNG, JPEG, or WebP)

Neither kind is a billing error. Clearing a count limit means deleting something; clearing a size limit means sending something smaller.

A third kind is refused with rate_limit_exceeded (429): a rate over time, or a ceiling on how many of something may be in flight at once. Clearing one means waiting:

Rate limit Value
Sends of one pending member invitation, original or resend 1 per ~5 minutes
Sends of one pending tester-grant notification, on add or resend 1 per ~5 minutes
New personal data exports 1 per 24 hours, and 5 per rolling 30 days
Downloads of one ready export archive 20
Uploads in flight per project 25

See 429 Too Many Requests.

Handling errors well

  1. Handle the bare 404s separately. They are the only responses that are not the full envelope — see The two exceptions. Indexing details on a bare 404 raises.
  2. Switch on error, not on message. Messages get reworded.
  3. Treat unknown codes by status class. New codes get added.
  4. Read details for the codes that use itlock_reason on organization write locks, errors on validation failures.
  5. Distinguish retryable from terminal. 429 and 503 are retryable with backoff, and so is a 500 on a read. A 500 on a non-idempotent write needs a check first — see Idempotency and retries. 400 (including validation_error), 401 (after one refresh), 403, 404, and 409 are not retryable. 402 is retryable only after a human fixes billing.
  6. Never log tokens when logging failed requests.

See also