Errors & recovery
A failed HTTP attempt is not always a failed mutation.
Use HTTP status + error.code to decide what to do next. Use error.message for human context, not string-matched program logic. Recovery depends on the operation, its idempotency contract, and the current resource state.
This page explains representative current failures, not a closed enum of every possible future error code. The committed API reference remains the public contract for each operation. Some response descriptions enumerate known codes; others give a generic status description. Absence from such a list is not proof that no other code can occur.
Read the canonical error envelope
A BusinessMode ErrorResponse has exactly this shape:
{
"error": {
"code": "license_inactive",
"message": "The license is inactive."
}
}
error.codeis a machine-readable, non-empty string.error.messageis a human-readable, non-empty string.
Branch on the status and code, not exact message text. Do not treat messages as an existence oracle or display every raw error indiscriminately to customer users.
The envelope has no field-level validation details, trace ID, request ID, retry delay, or documentation URL. For valid request fields and constraints, use the individual operation schema. A response may also be an operation-specific shape: readiness/database 503 responses use DatabaseUnavailableResponse, as documented in their references. Network/proxy failures need not contain an API envelope at all.
Capture one request ID per HTTP attempt
The API generates a UUID in the X-Request-Id response header for each API HTTP attempt. It is server-owned: sending your own header does not choose the API's correlation ID.
A retry gets a new X-Request-Id, including an idempotent replay. It is neither the Idempotency-Key nor a business resource ID. For one logical mutation, retain your own operation context, its Idempotency-Key where required, and the individual response request IDs.
A DNS failure, connection failure, or client timeout before a response may leave you with no HTTP response and no usable response request ID. Do not fabricate one or assume every network call reached the API. When escalating a problem, preserve the returned IDs from relevant attempts, or at least the latest plus your logical operation context.
Choose recovery before retrying
For an uncertain mutation, check the operation's idempotency contract:
For a read, bounded retry/backoff can be appropriate after a transient failure. For an uncertain mutation, first identify whether the operation is marked Idempotency-Key required in OpenAPI. A method such as POST does not determine retry safety by itself.
Never use a generic “retry every 409/500” or “retry every POST” policy. The following table is a recovery aid, not a global error registry or automatic middleware configuration.
| Condition | Typical status | Example code | Retry same request? | Change request/state? | Read canonical state first? |
|---|---|---|---|---|---|
| Invalid input | 400 | invalid_request | No unchanged retry | Correct invalid fields/headers | Use the operation schema |
| Authentication failure | 401 | invalid_api_key | Not with the same unusable credential | Repair credential/session | Check server configuration |
| Not found / unavailable scope | 404 | project_not_found | Not by guessing another scope | Only after confirming intended scope | Yes: memberships/navigation |
| Terminal lifecycle conflict | 409 | invitation_expired | No; obtain a valid provisioning path | Use another supported action | Inspect lifecycle as needed |
| Capacity conflict | 409 | seat_limit_exceeded | Only after relevant capacity changes | Change allocation/capacity state | Yes: the relevant limit and usage |
| Idempotency in progress | 409 | idempotency_request_in_progress | Deliberately, after waiting; same key | No: retain original request | Do not bypass the original execution |
| Idempotency key reused | 422 | idempotency_key_reused | Not the changed request under that key | Original request, or new mutation + new key | Resolve original intent first |
| Unexpected durable mutation failure | 500 | internal_error | Same request + retained key | No retargeting | Reconcile if replay cannot resolve it |
| Unexpected non-idempotent mutation failure | 500 | internal_error | No blind replay | Decide only after reconciliation | Yes, before another mutation |
| Provider unavailable | 503 | billing_provider_unavailable | Checkout: same request + key; Portal: deliberate retry | Do not replace an uncertain Checkout attempt | As required by the operation |
| Transport ambiguity | No reliable response | May be unavailable | Durable mutation: same request + key | Do not infer failure or retarget | Non-idempotent mutation: read first |
Understand the HTTP status
These are the current documented application error statuses; not every operation declares every status.
| Status | Meaning | Recovery direction |
|---|---|---|
400 | Invalid request | Correct the path, query, body, or required header. |
401 | Authentication failed | Repair the appropriate machine credential or human session. |
403 | Authenticated human not permitted | Resolve the operation-specific permission mismatch. |
404 | Authorized resource not found | Refresh scope; treat the target as unavailable to this caller. |
409 | Conflict with current state | Inspect error.code; conflicts do not share one retry policy. |
422 | Idempotency-Key reused for a different request | Resolve the intended logical request before sending again. |
429 | Protective rate limit exceeded | Wait for Retry-After; retain the same logical Idempotency-Key where required. |
500 | Unexpected server error | For mutations, recover according to the idempotency contract. |
503 | Required provider or PostgreSQL unavailable | Use operation-specific recovery; do not fabricate empty state. |
429: wait before retrying
Authenticated requests can return 429 with error.code = rate_limit_exceeded and the message "Too many requests. Retry after the indicated delay." The Retry-After response header gives the current wait in seconds; X-Request-Id still identifies this attempt. Wait at least that long before retrying, and spread retries to avoid another burst.
A rate-limited attempt stops before mutation execution or an idempotency lease. For a durable mutation, retry the same operation, body, scope, and same Idempotency-Key. These protective limits do not change seat capacity or Workspace organization-user capacity. See Rate limits for the generous runtime access budget and other policies.
Infrastructure-generated 408/502/504 responses are not promised BusinessMode application statuses. Handle unrecognized responses without inventing an API code.
400: correct an invalid request
invalid_request covers validation failures such as malformed UUID/path values, invalid bodies or queries, invalid enum values, and other contract constraints. Correct the request using the operation reference; the current envelope does not promise field-level validation errors.
For a marked durable mutation, idempotency_key_required and invalid_idempotency_key are request-construction errors. Supply a valid key, then send the intended logical request. This is different from replacing a key after an already-sent request has an ambiguous result. Do not blindly retry unchanged invalid input.
401: repair the correct authentication boundary
invalid_api_key is the machine boundary's deliberately generic failure for an unusable credential. It does not distinguish nonexistent, malformed, revoked, or expired API keys. Stop indefinite retries with the same key; check server-side configuration and replace the credential through supported developer/operator workflows. Keep bm_sk_... off customer clients. There is no API-key refresh endpoint.
invalid_session means a DeveloperBearer or HumanBearer session is unusable. The BusinessMode API does not refresh the caller's session. The human application must obtain/refresh a valid authenticated session through its auth integration, then retry only where appropriate. This is not the recovery workflow for a machine API key.
See Authentication for the exact human/machine boundaries.
403: an operation-specific human permission mismatch
Customer-payment operations use customer_payments_forbidden unless the developer currently owns the canonical Account. License payment requests and bindings additionally require direct membership in that License's Project. Project-only collaborators can still extend Licenses manually, but cannot inspect merchant Customers or subscriptions. Changing the browser's selected Workspace does not change these permissions or retarget a retained attempt.
403 is not the normal authorization response across BusinessMode. Organization-admin invitation acceptance can return organization_admin_invitation_email_mismatch. Account review creation and internal review moderation also use 403 when the authenticated person lacks the required account membership or moderator permission; ordinary users cannot publish reviews. Privacy deletion and ownership transfer additionally require the appropriate owner authority and recent authentication; a refreshed access token alone is not reauthentication.
The authenticated human must use the email identity that received that invitation. Generic customer-facing error handling should not disclose the invited email.
404: the authorized resource was not found
Treat a scoped 404 as unavailable to the current caller. It does not reliably distinguish “does not exist” from “not authorized for this scope.” It does not establish whether a UUID exists globally.
Re-read canonical membership/navigation state and confirm the intended Project, Account/Workspace, or Organization. Do not probe for existence or automatically retarget the request to another tenant/resource. Account membership and Project membership remain independent.
Representative codes include account_not_found, project_not_found, organization_not_found, license_not_found, assignment_not_found, invitation_not_found, access_code_not_found, api_key_not_found, webhook_endpoint_not_found, webhook_delivery_not_found, organization_admin_not_found, organization_admin_invitation_not_found, and entitlement_not_found. These examples are not a forever-closed enum.
409 is a category, not one retry policy
A conflict can concern lifecycle, capacity, uniqueness, configuration, concurrency, or idempotency. Inspect the code and the operation before deciding whether waiting could help.
Lifecycle conflicts
- Licenses:
license_inactive,license_revoked, andlicense_configuration_lockedrequire inspecting lifecycle or permitted configuration. Start date and Entitlements are locked after Assignment history; use Extend license to move expiration forward.license_non_expiring(409) means extension is unnecessary.invalid_license_extension(400) means a forward target must be after the start date and in the future at transaction time. Covered targets are successful no-ops. Do not treat every License conflict as temporary. - Customer Invitations:
invitation_expiredorinvitation_revokedrequire another valid provisioning path, not repeated use of the same credential. - Access Codes:
access_code_expired,access_code_revoked, oraccess_code_exhaustedare not fixed by blindly repeating redemption. Code exhaustion is lifetime credential usage, not License seat capacity. - Organization-admin invitations:
organization_admin_invitation_expired,organization_admin_invitation_revoked, andorganization_admin_invitation_already_acceptedrequire the corresponding human invitation/authority workflow. - Webhook deliveries:
webhook_delivery_already_deliveredis not an invitation to replay a successful delivery. Forwebhook_delivery_in_progress, let the current worker attempt finish, refresh, and reconsider a manual Retry only if needed. See Webhook delivery handling.
invitation_already_redeemed can mean another external user is trying to consume an already-redeemed Customer Invitation. Same-user replay may instead return the original success. Customer Invitation and Access Code redemption have domain-level same-user replay, not a generic Idempotency-Key requirement. Historical redemption is not an “ensure seat exists” operation. See Customer provisioning.
Keep the two allocation limits separate
seat_limit_exceeded — License seat capacity. The target License lacks assignment capacity for the attempted new allocation. An operator can release an appropriate seat, increase that License's seatLimit through supported developer management when appropriate, or choose another valid License/provisioning path. Do not retry in a tight loop waiting for capacity to appear.
account_org_user_limit_exceeded — Workspace organization-user capacity. A new distinct organization-user allocation would exceed the Account's effective limit. Reduce relevant allocated organization-user usage, increase effective Workspace capacity through billing, or reuse an existing organization-user allocation where the workflow permits. This is not the License's seat limit. Two Licenses for one external user in the same Organization count once toward this Workspace usage; that user in two Organizations counts twice.
Capacity conflicts protect new allocations; they do not mean existing users were deleted. Releasing one seat may leave the organization-user allocation and other valid access paths intact. Read the relevant canonical usage/state before choosing a remedy; see Members and seats and Account billing.
Direct assignment can return seat_already_assigned when that external user already has an unreleased assignment on the License. Read the existing assignment rather than treating all provisioning methods alike: credential redemption can reuse an assignment where direct assignment rejects a duplicate.
Fix configuration or uniqueness through the owning workflow
customer_invitation_accept_url_not_configured: a Project developer must configure the customer Invitation destination. An Organization portal caller cannot repair Project Settings through the portal. See Project provisioning configuration.billing_subscription_already_exists: refresh canonical Billing and use the current subscription/Billing Portal workflow. Do not loop new Checkout attempts with fresh keys.billing_customer_not_initialized: the Billing Portal cannot open for that Workspace yet. Do not fabricate a provider customer client-side.organization_external_id_exists,entitlement_key_exists, andseat_limit_below_assigned_countconcern operation-specific uniqueness/configuration. Inspect the existing resource and revise only a permitted request. See Create Organization, Create Entitlement, and Update License; these codes are not universal create/update responses.
409 and 422: resolve the logical request
409 idempotency_request_in_progress means another execution with that key is still in progress. Wait briefly for it to finish, then deliberately retry the same operation, path, normalized body, scope, and key. BusinessMode does not specify an exact retry delay here. Do not change the body, switch Project/Workspace, or generate a new key to bypass the execution.
422 idempotency_key_reused means the key was used for a different logical request fingerprint. It does not mean “still processing.” Decide which request you intend:
- To retry the original mutation, restore its original operation, path, body, and scope and reuse the original key.
- For a genuinely new logical mutation, generate a new Idempotency-Key after resolving the original attempt. Do not send the changed request under the old key.
Conceptually, durable idempotency is scoped to the principal and operation and binds the key to target/path values and request body. Retain that logical request; possession of the key does not bypass current authority. Replay is subject to the documented retention window, not indefinite recovery. If the original outcome cannot be reconciled, investigate canonical state rather than assuming a fresh key is safe. See Idempotency & retries.
500, 503, and transport ambiguity
500 does not prove the mutation failed before commit
Unexpected API failures use internal_error. An HTTP 500 means a server responded with a failed HTTP attempt; it does not prove that a mutation did not commit.
- Read: bounded retry/backoff according to your application policy can be appropriate.
- Durable idempotent mutation: retain and retry the same logical request with the same Idempotency-Key after ambiguity.
- Mutation without durable request idempotency: read canonical state first, then decide whether another mutation is appropriate. Inspect the exact assignment, Invitation, Delivery, or Project configuration where the operation permits it.
Some exact operations are safe no-ops on repeat, such as releasing an already-released exact assignment or disabling an already-disabled endpoint. No generic key does not mean “never repeat.” It means a blanket replay policy is wrong: reconcile first, then follow that operation's lifecycle. If canonical reads are also unavailable, preserve the uncertainty and wait/investigate rather than guessing.
A transport exception may not have any API response
DNS/TLS/connection failures or a client timeout can yield no reliable HTTP status, envelope, or X-Request-Id. A timeout or abort is not proof that the server cancelled its work. Treat an uncertain mutation according to the same durable-key versus canonical-read decision above.
A reverse proxy may return HTML or an unrecognized status/body. Do not manufacture internal_error for it, assume an empty result, or use its message as an API code.
503: distinguish Checkout, Portal, and reads
503 means a required operation-specific provider or PostgreSQL is unavailable, where documented by the operation.
For billing_provider_unavailable during Checkout creation, BusinessMode's durable external idempotency retains the provider execution context. Retain the same Account, plan, billing interval, and Idempotency-Key, and deliberately retry. Do not replace an uncertain Checkout attempt with a fresh key.
Billing Portal creation is an ephemeral session request without durable BusinessMode request-idempotency. The user may deliberately try again after provider unavailability. Do not persist/loop it as if it were a retained Checkout mutation.
For reads/readiness, bounded retry/backoff can be appropriate. Unavailability is not an empty resource list, a not-found result, or Developer/free Billing state. The readiness operation documents its own 503 body, DatabaseUnavailableResponse, rather than the error envelope above.
Five practical recovery decisions
A. The seat-assignment response was lost
You sent Create machine seat assignment with a retained Idempotency-Key. Keep the same License, externalUserId, request body, and key; deliberately retry. Generating another key first can turn recovery into a different mutation.
B. The License has no available seats
You received seat_limit_exceeded. This is a capacity conflict, not a lost response. Inspect the License and allocations; change the relevant capacity/allocation state or select an appropriate alternative before making another deliberate attempt.
C. The Customer Invitation expired
You received invitation_expired from Invitation redemption. That Invitation is terminal; repeating its token will not fix it. Obtain a new supported provisioning path. Do not add a generic Idempotency-Key to redemption. Access Code redemption likewise has its own lifecycle and same-user replay rules.
D. Checkout's provider is unavailable
You received billing_provider_unavailable. Retain the same Account, plan, interval, and key. Deliberately retry that Checkout attempt; do not spawn fresh attempts because a provider call was unavailable.
E. A non-idempotent mutation's result is uncertain
After an uncertain machine seat release, read canonical assignment/member/access state. An already-released exact assignment resolves the allocation uncertainty; other valid Grants may still preserve access. Use Check access for current authorization, not provisioning history.
The same reconciliation-first principle applies to an uncertain manual webhook Retry or Project provisioning PATCH: refresh the Delivery or configuration before submitting another mutation. A Retry may already have let the worker complete delivery; PATCH may already have changed the canonical value.
Parse failed fetch responses without inventing errors
This standalone TypeScript example uses the standard Response interface. It extracts code/message only when they are non-empty strings, captures the response header, and handles non-JSON, interrupted, or unrecognized bodies safely. It does not require the private SDK, retain raw response bodies, or implement retries.
export type ApiFailure =
| {
kind: "api-error";
status: number;
code: string;
message: string;
requestId: string | null;
}
| { kind: "unrecognized-response"; status: number; requestId: string | null };
function record(value: unknown): Record<string, unknown> | null {
return typeof value === "object" && value !== null && !Array.isArray(value)
? (value as Record<string, unknown>)
: null;
}
// Call after fetch returns. Consumes a failed response body once; never retries.
// requestId below is LOCAL diagnostic context, not a field in the API body.
export async function readApiFailure(
response: Response,
): Promise<ApiFailure | null> {
if (response.ok) return null;
const status = response.status;
const requestId = response.headers.get("X-Request-Id");
let body: unknown;
try {
body = await response.json();
} catch {
// HTML/proxy responses, malformed JSON, or an interrupted response body.
return { kind: "unrecognized-response", status, requestId };
}
const error = record(record(body)?.error);
if (
error &&
typeof error.code === "string" &&
error.code.length > 0 &&
typeof error.message === "string" &&
error.message.length > 0
) {
return {
kind: "api-error",
status,
code: error.code,
message: error.message,
requestId,
};
}
// Do not invent a code or retain/log the unrecognized response body.
return { kind: "unrecognized-response", status, requestId };
}
Call readApiFailure(response) after fetch returns. It returns null for a successful response, an api-error result for a recognized envelope, or an unrecognized-response result otherwise. The result's status and requestId are local diagnostic context, not extra API body fields.
Catch a thrown fetch separately: there may be no Response to parse. A known operation-specific response, such as readiness's unavailable body, should use its own schema when needed. Do not interpret unrecognized-response as permission to replay a mutation; use the recovery decision above.
Keep diagnostics useful and safe
For a customer-facing application, show a safe product message and preserve the canonical code internally. For operator/developer diagnostics, record the operation name, status, code, returned request ID, and safe logical-operation context.
The following is a local log record, not an API response:
{
"operation": "redeemCustomerInvitation",
"status": 409,
"code": "seat_limit_exceeded",
"requestId": "00000000-0000-4000-8000-000000000001"
}
Never log API keys, Invitation tokens, Access Codes, Webhook signing secrets, or the Authorization header. Avoid recording full request bodies, raw proxy responses, or other customer data unnecessarily. Retain relevant response request IDs for support across retries; equal IDs are not how an idempotent replay is identified.
The durable distinction is: X-Request-Id identifies one HTTP attempt. Idempotency-Key identifies one logical durable mutation. The recovery distinction is: classify the failure before repeating the operation.
Hosted merchant setup uses merchant_creation_requires_attention when an old uncertain account creation cannot safely replay. Reuse the stored merchant; a new HTTP key cannot create a replacement account. merchant_not_ready blocks new payable Checkout until canonical card capability and requirements are available. Onboarding return alone is not readiness. catalog_creation_requires_attention means the original Customer/Price creation needs Dashboard review beyond its safe provider retry window. These states do not revoke existing License access.