Error reference
Read this first. The same business error can reach you in a different shape on GraphQL and on REST, and on GraphQL a typed code is not always in extensions.code. This page tells you exactly where to look.
For the error categories, the retry policy, and idempotency basics, read Errors, pagination, and idempotency first. This page does not repeat them. For the throttle, size and query-shape ceilings, and a backoff recipe, see Rate limits and request limits. For credentials and token lifetimes, see Authentication. For what each permission allows, see Tenant and permissions. Every field and type is in the API reference.
The five shapes on the GraphQL wire
ARC's GraphQL server derives the error extensions from how the server raised the error. Errors arrive in one of five shapes.
| Shape | Raised when | extensions.code | extensions.status | extensions.originalError | message |
|---|---|---|---|---|---|
| A. Plain message with a status | The usual case: permission denied, wrong state, unknown id, illegal transition | BAD_REQUEST for 400, UNAUTHENTICATED for 401, FORBIDDEN for 403, BAD_USER_INPUT for 422. Any other status (402, 404, 409, 429, 503 and so on) gives INTERNAL_SERVER_ERROR | Present only with INTERNAL_SERVER_ERROR, holding the HTTP-equivalent status, for example 404 | The error body, for example {"message": "…", "error": "Not Found", "statusCode": 404} | The readable sentence |
B. Structured body that also has statusCode | LINE_QTY_RULE, plan 402s, AI_CREDITS_INSUFFICIENT 402, sign-in rate limit 429 | As shape A, from the status | As shape A | The whole body, including its code and extra fields | The body's message |
C. Structured body without statusCode | NO_PRICE_IN_CURRENCY, DISCOUNT_CEILING_EXCEEDED, VERSION_CONFLICT, IDEMPOTENCY_KEY_CONFLICT, IDEMPOTENCY_OUTCOME_UNKNOWN | INTERNAL_SERVER_ERROR, the generic default | Absent | Absent | The body's message. The code does not reach the client |
| D. Typed error that sets its own extensions | ACCOUNT_LOCKED, IDEMPOTENCY_IN_PROGRESS, CURRENCY_NOT_CONFIGURED, NO_ADMIN_PANEL_ACCESS | The specific code | Sometimes: 423 for ACCOUNT_LOCKED, 403 for NO_ADMIN_PANEL_ACCESS | Absent | The readable sentence |
| E. Throttle from the global limiter | Too many requests in a short window | INTERNAL_SERVER_ERROR | Absent | Absent | ThrottlerException: Too Many Requests. Detect it by the Retry-After header |
Three more cases sit outside those shapes:
- Framework errors. The GraphQL server library raises these, not ARC code. A malformed query, or an unknown field or argument written in the document, gives
GRAPHQL_VALIDATION_FAILED. A query that is too deep or too large gives the same code. Bad variable values, including an unknown key in an input object passed as a variable, giveBAD_USER_INPUT. Query text that does not parse givesGRAPHQL_PARSE_FAILED. - Input validation. When a request fails field validation, the code is
BAD_REQUEST. The per-field reasons are joined with"; "into the top-levelmessage, and are also left as an array inextensions.originalError.message. - Masked faults. A database-engine error is replaced with
{"message": "Internal server error", "extensions": {"code": "INTERNAL_SERVER_ERROR"}}, with no status. Other unexpected failures returnINTERNAL_SERVER_ERRORwith their own message. SoINTERNAL_SERVER_ERRORwith no status does not, on its own, prove a server fault, and shapes C and E look the same by code alone.
Detect an error
Run this routine on every entry in errors. It handles every code in the reference below, and falls through safely for operation-specific codes that the reference does not list.
- Read
extensions.code. If it is anything other thanINTERNAL_SERVER_ERROR, it is a specific code, so act on it. Ifextensions.originalError.codeis a string, use that instead.LINE_QTY_RULEis the example: it sits under a top-levelBAD_REQUEST. - If the code is
INTERNAL_SERVER_ERRORandextensions.statusis set, that number is the HTTP-equivalent status: 402, 404, 409, 429, 503, and so on. Ifextensions.originalError.codeis a string, it is the domain code, as withAI_CREDITS_INSUFFICIENT. - Otherwise, if the message starts with
NO_PRICE_IN_CURRENCY:, the code isNO_PRICE_IN_CURRENCY. This is the only message prefix you should match. Parse nothing else out of the sentence. - Otherwise, if the response has a
Retry-Afterheader, or the message isThrottlerException: Too Many Requests, you are throttled. Wait, then retry, following the retry policy and Rate limits and request limits. A browser client on another origin cannot read the header, so it must match the message. - Otherwise you have
INTERNAL_SERVER_ERRORwith no status and no code. It is either a server fault or a refusal with no machine code on GraphQL, such asVERSION_CONFLICT,IDEMPOTENCY_KEY_CONFLICT,IDEMPOTENCY_OUTCOME_UNKNOWN, orDISCOUNT_CEILING_EXCEEDED. Do not retry blindly. Re-read the document and compare itsversion, and reconcile before you send anything again. A retry is safe only with the same idempotency key, and never afterIDEMPOTENCY_OUTCOME_UNKNOWN. Record thex-request-idresponse header. - Always inspect
dataas well aserrors. See GraphQL responses.
On REST, use the HTTP status first, then body.code if the body has one. Use body.message for display only. The body's requestId and the x-request-id header carry the correlation id. Record it when you report a problem.
The same routine as code. It returns the code and status used in the reference below, and whether waiting and retrying is safe:
type GraphQLErrorEntry = {
message: string;
extensions?: {
code?: string;
status?: number;
originalError?: { code?: unknown };
};
};
export type Verdict = {
code: string | null; // ARC code from the reference, or null when none reaches the client
status: number | null; // HTTP-equivalent status, when the server sent one
retryable: boolean; // safe to resend after a wait; for writes, only with the same idempotency key
};
export function classifyGraphQLError(e: GraphQLErrorEntry, headers: Headers): Verdict {
const ext = e.extensions ?? {};
const original: { code?: unknown } = ext.originalError ?? {};
const domain = typeof original.code === "string" ? original.code : null;
// 1. A specific top-level code. originalError.code (LINE_QTY_RULE) wins when present.
if (ext.code && ext.code !== "INTERNAL_SERVER_ERROR") {
return {
code: domain ?? ext.code,
status: ext.status ?? null,
retryable: ext.code === "IDEMPOTENCY_IN_PROGRESS",
};
}
// 2. INTERNAL_SERVER_ERROR that carries an HTTP-equivalent status.
if (ext.status !== undefined) {
return {
code: domain,
status: ext.status,
retryable: ext.status === 429 || ext.status === 503,
};
}
// 3. The one message prefix ARC documents.
if (e.message.startsWith("NO_PRICE_IN_CURRENCY:")) {
return { code: "NO_PRICE_IN_CURRENCY", status: null, retryable: false };
}
// 4. Throttled by the global limiter.
if (headers.has("retry-after") || e.message === "ThrottlerException: Too Many Requests") {
return { code: null, status: 429, retryable: true };
}
// 5. Unclassified: a server fault, or a refusal with no machine code on GraphQL.
return { code: null, status: null, retryable: false };
}
const RETRYABLE_REST_STATUS = new Set([429, 502, 503, 504]);
export function classifyRestError(status: number, body: { code?: string } | null): Verdict {
if (body?.code) {
// A typed refusal is final, except a write still in progress and a spent per-key budget.
const retryable = body.code === "IDEMPOTENCY_IN_PROGRESS" || body.code === "API_KEY_RATE_LIMITED";
return { code: body.code, status, retryable };
}
// Transient: wait for Retry-After when it is present, otherwise back off.
return { code: null, status, retryable: RETRYABLE_REST_STATUS.has(status) };
}
REST error body
REST routes return the HTTP status line and this body. The example is a rejected API key on a service route:
{
"statusCode": 401,
"timestamp": "<ISO-8601>",
"path": "/api/service/sales-orders",
"method": "POST",
"message": "API key not found",
"error": "Unauthorized",
"requestId": "<request-id>"
}
messageis a string, or an array of strings when validation fails.error, the HTTP reason phrase such asUnauthorized, appears on plain and validation errors. Typed errors may omit it, so do not rely on it.- Typed errors add a
codeand their own fields. For example,IDEMPOTENCY_KEY_CONFLICTon a service route also carriesoperationandidempotencyKey. - An unexpected failure returns a 500 whose
messageisInternal server error. - Success responses use the envelope
{"success": true, "data": …}. Error responses do not use asuccessfield. pathis the request URL with sensitive query values redacted. Do not parse it.- Every response echoes the
x-request-idheader, and the body'srequestIdcarries the correlation id. - A state-changing request that signs in with a browser cookie, and carries neither an
Authorizationheader nor a validx-csrf-tokenheader, gets a 403 with{"statusCode": 403, "message": "CSRF token missing or invalid"}. Integrations that sendAuthorization: Bearerare exempt.
GraphQL examples
These are the shapes you will meet most often, shown with the real wire. Production responses carry no stack trace. In non-production environments you may see extensions.stacktrace. Ignore it.
Typed code: IDEMPOTENCY_IN_PROGRESS
What you see:
{
"errors": [
{
"message": "A \"<operation>\" request with this idempotency key is already in progress. Retry shortly.",
"path": ["<operation>"],
"extensions": { "code": "IDEMPOTENCY_IN_PROGRESS" }
}
]
}
How to detect: read extensions.code. Wait 1 to 2 seconds with backoff, then resend the same request with the same idempotency key.
Status only: plan quota 402
What you see:
{
"errors": [
{
"message": "Monthly quote limit reached (<used>/<limit>). Please upgrade your plan.",
"path": ["createQuote"],
"extensions": {
"code": "INTERNAL_SERVER_ERROR",
"status": 402,
"originalError": {
"message": "Monthly quote limit reached (<used>/<limit>). Please upgrade your plan.",
"statusCode": 402
}
}
}
]
}
How to detect: extensions.code is INTERNAL_SERVER_ERROR, but extensions.status is 402, so this is a plan limit. Changing the plan or waiting for the next month is the fix. Retrying will not help.
Message prefix only: NO_PRICE_IN_CURRENCY
What you see:
{
"errors": [
{
"message": "NO_PRICE_IN_CURRENCY: no <currency> price is configured for variant <variant-id>. A price is never produced by conversion — add a <currency> price to the variant's price ladder before pricing this line.",
"path": ["<operation>"],
"extensions": { "code": "INTERNAL_SERVER_ERROR" }
}
]
}
How to detect: extensions.code is INTERNAL_SERVER_ERROR with no status. The only machine signal is the message prefix NO_PRICE_IN_CURRENCY:. Match that prefix and stop there.
Throttled
What you see: an HTTP response with a Retry-After header, and this body:
{
"errors": [
{
"message": "ThrottlerException: Too Many Requests",
"path": ["<operation>"],
"extensions": { "code": "INTERNAL_SERVER_ERROR" }
}
]
}
How to detect: the Retry-After header, or the message. There is no extensions.status. Honor the header and follow Back off and retry.
Reference
The cross-cutting codes and conditions an integration is likely to meet, grouped by cause. Some operations raise further, operation-specific refusals; the guide for that operation describes them, and the routine above handles them safely. The REST column is the HTTP status. The GraphQL column says how to recognize the error. Where a GraphQL client cannot read the code, the column says so and names what to match instead.
Authentication and access
| Code or condition | REST | How to detect on GraphQL | What to do |
|---|---|---|---|
Sign-in refused: Invalid credentials. The same message covers an unknown user, a wrong password, a suspended account, and a wrong workspace, by design | Not applicable: sign-in is GraphQL only | UNAUTHENTICATED, with message Invalid credentials | Check the email, password, and workspace subdomain. Do not loop. Repeated failures lock the account; see ACCOUNT_LOCKED |
ACCOUNT_LOCKED: sent only when the correct password is supplied while the account is locked. A wrong password still gets Invalid credentials. By default, 5 failed attempts lock the account for 15 minutes, and the workspace security policy can change both | Not applicable: sign-in is GraphQL only | Code ACCOUNT_LOCKED, status 423. extensions.lockedUntil (ISO-8601 UTC, or null) and extensions.timeZone | Wait until lockedUntil. This is not an expired token, so do not refresh or loop on sign-in |
NO_ADMIN_PANEL_ACCESS: a verified user whose role has no admin console access, signing in as the console client. Integrations that identify themselves differently rarely see it | Not applicable | Code NO_ADMIN_PANEL_ACCESS, status 403 | Ask your workspace administrator for console access |
Refresh failure: Invalid or expired session, User not found or inactive, or Invalid refresh token | Not applicable | UNAUTHENTICATED | Sign in again. Refresh tokens rotate on use, so replaying an old one fails once a short grace period has passed |
Refresh unavailable: Could not resolve account permissions. Please retry. | Not applicable | INTERNAL_SERVER_ERROR with status 503 | Nothing was rotated, so the same refresh token still works. Retry with backoff |
Missing or expired access token: Authentication required, or another message from the auth strategy | 401 | UNAUTHENTICATED | Refresh the token, or sign in again |
Missing permission: Missing required permission: <module:resource:action>, or the list form … one of <a>, <b> | 403 | FORBIDDEN | Grant the permission to the user's role. Changing the payload will not help. See Tenant and permissions |
Service API key errors: Missing Authorization header, Invalid API key format. Expected gm_live_..., API key not found (an unknown, revoked, or inactive key), API key expired, Invalid API key, This API key is not permitted from your IP address | 401 | Not applicable: REST only | Fix the secret, the host, or the IP allow-list. Do not retry the same request unchanged |
READ_ONLY key on a write route: This API key has READ_ONLY scope. A READ_WRITE key is required for this operation. | 401, not 403 | Not applicable: REST only | Use a READ_WRITE key. See REST API |
Plan has no API access: <Feature> is not included in your current plan. Upgrade to <Plan> to enable it. Other plan-gated features, such as purchase invoices, use the same shape | 403 | FORBIDDEN | Change the plan. Retrying will not help |
Plan and billing
| Code or condition | REST | How to detect on GraphQL | What to do |
|---|---|---|---|
Monthly quote allowance reached: Monthly quote limit reached (<used>/<limit>). Please upgrade your plan. | 402 | INTERNAL_SERVER_ERROR with extensions.status 402 | Change the plan, or wait for the next month. Retrying will not help |
Feature not on the plan, for some quote operations: "<Feature>" is not available on your current plan (<plan>). Upgrade to <Plan> … | Not applicable | INTERNAL_SERVER_ERROR with extensions.status 402 and originalError.requiredPlan | Change the plan. Retrying will not help |
AI_CREDITS_INSUFFICIENT: the workspace's AI credit balance cannot cover an AI assistant request. Refused before any provider call | 402 | INTERNAL_SERVER_ERROR with status 402 and originalError.code AI_CREDITS_INSUFFICIENT. originalError also carries balanceCents and requiredCents | Top up the balance, then try again |
Pricing, money, and currency
| Code or condition | REST | How to detect on GraphQL | What to do |
|---|---|---|---|
NO_PRICE_IN_CURRENCY: a line in a currency has no price row in that currency. A price is never produced by conversion | 400, with body code, variantId, and currency | INTERNAL_SERVER_ERROR with no code and no status. Match the message prefix NO_PRICE_IN_CURRENCY: | Add a price in that currency to the variant (see Read the catalog and resolve prices), or use the document's base currency. A retry cannot succeed |
DISCOUNT_CEILING_EXCEEDED: the discount or price override is above the workspace's ceiling | 400, with body code, variantId, requestedDiscountPercent, and maxDiscountPercent | INTERNAL_SERVER_ERROR with no code and no status. Not identifiable by code on GraphQL, so treat it as a refusal. The message mentions a maximum allowed discount of <n>%; do not parse it | Lower the discount or override to within the ceiling. An override permission does not bypass it |
CURRENCY_NOT_CONFIGURED: a document, PDF, email, or export must show money, but it has no currency and the workspace has no base currency. Not raised when an order is created | 422 | Code CURRENCY_NOT_CONFIGURED | Set the base currency in the workspace's currency settings. See Money, pricing, and tax |
LINE_QTY_RULE: a line's quantity breaks the product's minimum, multiple, or unit rule | 400 | BAD_REQUEST. extensions.originalError.code is LINE_QTY_RULE, with rule, lineIndex, productId, variantId, uomId, and productName | Fix that line. lineIndex says which one |
Document state and stock
These are plain-message errors with no machine code of their own. Branch on extensions.code or the status, never on the text.
| Code or condition | REST | How to detect on GraphQL | What to do |
|---|---|---|---|
Illegal status transition: Illegal <document> status transition: <FROM> → <TO> (allowed from <FROM>: <a>, <b>)., or (<FROM> is a terminal state) | 400 | BAD_REQUEST | Re-read the status, then move through an allowed transition |
Insufficient stock, when negative stock is not allowed: <name> is out of stock., or Only <n> of <name> available (you requested <m>). | 400 | BAD_REQUEST | Reduce the quantity, wait for stock, or ask a workspace administrator about the negative-stock setting |
Credit limit exceeded, when an order on credit terms is confirmed: Credit limit exceeded for <side> <partner-id>: current exposure … exceeds limit …. Requires the '<permission>' permission to override. | 403 | FORBIDDEN | Take payment or raise the limit, or have a user with the override permission confirm the order. Not retryable as is. The override permission for customer credit is orders:sales-order:override-credit-limit; for vendor credit it is orders:purchase-order:override-credit-limit |
| Not found, or an id from another workspace | 404 | INTERNAL_SERVER_ERROR with extensions.status 404. The staff API has no NOT_FOUND code | Re-read the resource in your own workspace. Ids are scoped to the workspace, so another workspace's id reads as not found |
| Validation failed: the request body does not pass validation | 400 | BAD_REQUEST, with the reasons joined by "; " in message. A bad variable type or enum value, or an unknown key in a variable's input object, gives BAD_USER_INPUT; an unknown field written in the document gives GRAPHQL_VALIDATION_FAILED | Correct the field. On REST an unknown body property is dropped, not rejected |
Idempotency and concurrency
See Idempotency for writes and Optimistic concurrency for the general rules.
| Code or condition | REST | How to detect on GraphQL | What to do |
|---|---|---|---|
IDEMPOTENCY_KEY_CONFLICT: the same key was used for a different request. Message begins Idempotency key "<key>" was already used for a different "<operation>" request. | 409 | INTERNAL_SERVER_ERROR with no code and no status. Match the message only as a display string | If you meant a new command, use a new key. If you meant a retry, send the identical request with the same key |
IDEMPOTENCY_IN_PROGRESS: the first attempt with this key is still running. An attempt older than about two minutes is presumed crashed and taken over by the next retry | 409 | Code IDEMPOTENCY_IN_PROGRESS | Wait 1 to 2 seconds with backoff, then resend the same request with the same key |
IDEMPOTENCY_OUTCOME_UNKNOWN: an earlier attempt may have partly applied. Message begins The previous attempt may have changed data. | 409 | INTERNAL_SERVER_ERROR with no code and no status. Match the message only as a display string | Stop. Read back the document or payment state, then reconcile or undo. A new key does not fix this |
VERSION_CONFLICT: the document changed since you loaded it. Message This <entity> changed since you last loaded it (you have version <n>, current is <m>). Refresh, review the current values, and retry. The refusal happens only when the deployment enforces the concurrency check. Otherwise a stale expectedVersion is accepted and only logged. Omitting expectedVersion never blocks a write | 409, where a route raises it, with body code, entityType, entityId, expectedVersion, and currentVersion | INTERNAL_SERVER_ERROR with no code and no status. Match the message only as a display string | Do not rely on a refusal to protect your write. Re-read after every write and compare version. If the refusal does appear, re-read, reconcile the user's intent, and retry with the new version |
Throttling, limits, and server faults
| Code or condition | REST | How to detect on GraphQL | What to do |
|---|---|---|---|
| Throttle: too many requests in a short window | 429, with Retry-After | INTERNAL_SERVER_ERROR with message ThrottlerException: Too Many Requests and no status. Detect it by the Retry-After header | Back off and honor Retry-After. See Rate limits and request limits for the ceilings and a backoff recipe |
API_KEY_RATE_LIMITED: the API key used up its per-minute rateLimit. Message This API key is limited to <n> requests per minute. Retry after <s> seconds. | 429, with Retry-After, the X-ApiKey-RateLimit-* headers, and body code and retryAfterSeconds | Not applicable: REST only | Wait retryAfterSeconds, then retry. Nothing was done, so a retried write is safe; still send the same idempotency key. Slow the sync or pace workers that share the key. See Rate limits and request limits |
Sign-in, password-reset, or registration rate limit: Too many login attempts, please try again later, Too many password reset requests, or Too many registration attempts | Not applicable: these are GraphQL only | INTERNAL_SERVER_ERROR with status 429. originalError.retryAfter is the length of the window in seconds, not the time left. No Retry-After header | Wait out the window before trying again. See Rate limits and request limits |
Query too deep: Query is too deep: depth N exceeds maximum allowed depth of 12.. Query too large: Query is too large: N fields exceeds maximum allowed field count of 250. | Not applicable | GRAPHQL_VALIDATION_FAILED | Split the query, and request less nesting and fewer fields |
CSRF block on GraphQL: the message begins This operation has been blocked as a potential Cross-Site Request Forgery (CSRF) | Not applicable | HTTP 400 with this framework message. It is not an ARC code | Send Content-Type: application/json on the request |
Upload too large: File too large. It exceeds the maximum size for this upload. | 413 | Not applicable: REST only | Send a smaller file |
Server fault: {"message": "Internal server error"} | 500 | INTERNAL_SERVER_ERROR. Database faults show the message Internal server error and no status | Retry only safe operations, or writes with the same idempotency key. Reconcile before you create anything new |
MCP tool errors
MCP is the one surface where every error code is machine readable. A failed tool call returns a result with isError: true. Its first content block is a readable sentence, and the text of its second content block is JSON with code, message, hint, retryable, and retryAfterSeconds. hint and retryAfterSeconds can be null.
The closed set of codes:
UNAUTHENTICATED, PERMISSION_DENIED, FEATURE_DISABLED, PLAN_LIMIT, NOT_FOUND, VALIDATION, CONFIRM_REQUIRED, HUMAN_APPROVAL_REQUIRED, PROPOSAL_STALE (reserved, not currently emitted), PROPOSAL_ALREADY_APPLIED, IDEMPOTENCY_CONFLICT, NO_PRICE_IN_CURRENCY, CREDIT_LIMIT_EXCEEDED, INVALID_TRANSITION, INSUFFICIENT_STOCK, CROSS_CURRENCY_ALLOCATION, RATE_LIMITED, CONFLICT, INTERNAL, CONTROL_BLOCKED, CONTROL_APPROVAL_REQUIRED, CONTROL_APPROVAL_PENDING, CONTROL_APPROVAL_STALE, CONTROL_NO_ELIGIBLE_APPROVER.
Branch on retryable. A rate limit is HTTP 429 with a Retry-After header, and a JSON-RPC error whose data is {"code": "RATE_LIMITED", "retryable": true, "retryAfterSeconds": N}. HUMAN_APPROVAL_REQUIRED means stop and hand the step to a person. Never retry it. The CONTROL_* codes mean one of the workspace's approval rules refused the action or needs a person's approval first; follow the hint, and never treat them as approval to proceed. For how proposals and approval work, see MCP.
Buyer app and customer portal
The buyer app and the customer portal have their own closed sets of cart, checkout, and sign-in codes. They are not part of this reference.
What not to do
- Do not assume a domain code is in
extensions.code.INTERNAL_SERVER_ERRORdoes not mean the server broke. Checkextensions.status,extensions.originalError.code, and the message prefix first. - Do not parse messages for anything except the
NO_PRICE_IN_CURRENCY:prefix. Other sentences may be reworded. - Do not retry refusals (400, 401, 402, 403, 404, and 409) without changing the cause.
IDEMPOTENCY_IN_PROGRESSis the exception. - Do not answer a throttle with a tight loop. Honor
Retry-After; see Rate limits and request limits. - Do not answer
IDEMPOTENCY_OUTCOME_UNKNOWNwith a new key. - Do not rely on
VERSION_CONFLICTto protect your writes. Re-read the document and compareversion. - Do not treat
ACCOUNT_LOCKEDas an expired token, and do not loop on sign-in. - Do not expect
NOT_FOUNDon the staff GraphQL API.QUOTE_EXPIREDis a customer-surface code, so do not expect it there either. - Do not expect a
{"success": false}envelope on REST. - Do not rely on the HTTP status line for GraphQL errors. Read
errorsin the body.