Skip to main content

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.

ShapeRaised whenextensions.codeextensions.statusextensions.originalErrormessage
A. Plain message with a statusThe usual case: permission denied, wrong state, unknown id, illegal transitionBAD_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_ERRORPresent only with INTERNAL_SERVER_ERROR, holding the HTTP-equivalent status, for example 404The error body, for example {"message": "…", "error": "Not Found", "statusCode": 404}The readable sentence
B. Structured body that also has statusCodeLINE_QTY_RULE, plan 402s, AI_CREDITS_INSUFFICIENT 402, sign-in rate limit 429As shape A, from the statusAs shape AThe whole body, including its code and extra fieldsThe body's message
C. Structured body without statusCodeNO_PRICE_IN_CURRENCY, DISCOUNT_CEILING_EXCEEDED, VERSION_CONFLICT, IDEMPOTENCY_KEY_CONFLICT, IDEMPOTENCY_OUTCOME_UNKNOWNINTERNAL_SERVER_ERROR, the generic defaultAbsentAbsentThe body's message. The code does not reach the client
D. Typed error that sets its own extensionsACCOUNT_LOCKED, IDEMPOTENCY_IN_PROGRESS, CURRENCY_NOT_CONFIGURED, NO_ADMIN_PANEL_ACCESSThe specific codeSometimes: 423 for ACCOUNT_LOCKED, 403 for NO_ADMIN_PANEL_ACCESSAbsentThe readable sentence
E. Throttle from the global limiterToo many requests in a short windowINTERNAL_SERVER_ERRORAbsentAbsentThrottlerException: 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, give BAD_USER_INPUT. Query text that does not parse gives GRAPHQL_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-level message, and are also left as an array in extensions.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 return INTERNAL_SERVER_ERROR with their own message. So INTERNAL_SERVER_ERROR with 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.

  1. Read extensions.code. If it is anything other than INTERNAL_SERVER_ERROR, it is a specific code, so act on it. If extensions.originalError.code is a string, use that instead. LINE_QTY_RULE is the example: it sits under a top-level BAD_REQUEST.
  2. If the code is INTERNAL_SERVER_ERROR and extensions.status is set, that number is the HTTP-equivalent status: 402, 404, 409, 429, 503, and so on. If extensions.originalError.code is a string, it is the domain code, as with AI_CREDITS_INSUFFICIENT.
  3. Otherwise, if the message starts with NO_PRICE_IN_CURRENCY:, the code is NO_PRICE_IN_CURRENCY. This is the only message prefix you should match. Parse nothing else out of the sentence.
  4. Otherwise, if the response has a Retry-After header, or the message is ThrottlerException: 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.
  5. Otherwise you have INTERNAL_SERVER_ERROR with no status and no code. It is either a server fault or a refusal with no machine code on GraphQL, such as VERSION_CONFLICT, IDEMPOTENCY_KEY_CONFLICT, IDEMPOTENCY_OUTCOME_UNKNOWN, or DISCOUNT_CEILING_EXCEEDED. Do not retry blindly. Re-read the document and compare its version, and reconcile before you send anything again. A retry is safe only with the same idempotency key, and never after IDEMPOTENCY_OUTCOME_UNKNOWN. Record the x-request-id response header.
  6. Always inspect data as well as errors. 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>"
}
  • message is a string, or an array of strings when validation fails.
  • error, the HTTP reason phrase such as Unauthorized, appears on plain and validation errors. Typed errors may omit it, so do not rely on it.
  • Typed errors add a code and their own fields. For example, IDEMPOTENCY_KEY_CONFLICT on a service route also carries operation and idempotencyKey.
  • An unexpected failure returns a 500 whose message is Internal server error.
  • Success responses use the envelope {"success": true, "data": …}. Error responses do not use a success field.
  • path is the request URL with sensitive query values redacted. Do not parse it.
  • Every response echoes the x-request-id header, and the body's requestId carries the correlation id.
  • A state-changing request that signs in with a browser cookie, and carries neither an Authorization header nor a valid x-csrf-token header, gets a 403 with {"statusCode": 403, "message": "CSRF token missing or invalid"}. Integrations that send Authorization: Bearer are 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 conditionRESTHow to detect on GraphQLWhat 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 designNot applicable: sign-in is GraphQL onlyUNAUTHENTICATED, with message Invalid credentialsCheck 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 bothNot applicable: sign-in is GraphQL onlyCode ACCOUNT_LOCKED, status 423. extensions.lockedUntil (ISO-8601 UTC, or null) and extensions.timeZoneWait 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 itNot applicableCode NO_ADMIN_PANEL_ACCESS, status 403Ask your workspace administrator for console access
Refresh failure: Invalid or expired session, User not found or inactive, or Invalid refresh tokenNot applicableUNAUTHENTICATEDSign 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 applicableINTERNAL_SERVER_ERROR with status 503Nothing 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 strategy401UNAUTHENTICATEDRefresh the token, or sign in again
Missing permission: Missing required permission: <module:resource:action>, or the list form … one of <a>, <b>403FORBIDDENGrant 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 address401Not applicable: REST onlyFix 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 403Not applicable: REST onlyUse 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 shape403FORBIDDENChange the plan. Retrying will not help

Plan and billing​

Code or conditionRESTHow to detect on GraphQLWhat to do
Monthly quote allowance reached: Monthly quote limit reached (<used>/<limit>). Please upgrade your plan.402INTERNAL_SERVER_ERROR with extensions.status 402Change 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 applicableINTERNAL_SERVER_ERROR with extensions.status 402 and originalError.requiredPlanChange 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 call402INTERNAL_SERVER_ERROR with status 402 and originalError.code AI_CREDITS_INSUFFICIENT. originalError also carries balanceCents and requiredCentsTop up the balance, then try again

Pricing, money, and currency​

Code or conditionRESTHow to detect on GraphQLWhat to do
NO_PRICE_IN_CURRENCY: a line in a currency has no price row in that currency. A price is never produced by conversion400, with body code, variantId, and currencyINTERNAL_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 ceiling400, with body code, variantId, requestedDiscountPercent, and maxDiscountPercentINTERNAL_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 itLower 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 created422Code CURRENCY_NOT_CONFIGUREDSet 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 rule400BAD_REQUEST. extensions.originalError.code is LINE_QTY_RULE, with rule, lineIndex, productId, variantId, uomId, and productNameFix 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 conditionRESTHow to detect on GraphQLWhat to do
Illegal status transition: Illegal <document> status transition: <FROM> → <TO> (allowed from <FROM>: <a>, <b>)., or (<FROM> is a terminal state)400BAD_REQUESTRe-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>).400BAD_REQUESTReduce 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.403FORBIDDENTake 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 workspace404INTERNAL_SERVER_ERROR with extensions.status 404. The staff API has no NOT_FOUND codeRe-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 validation400BAD_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_FAILEDCorrect 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 conditionRESTHow to detect on GraphQLWhat 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.409INTERNAL_SERVER_ERROR with no code and no status. Match the message only as a display stringIf 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 retry409Code IDEMPOTENCY_IN_PROGRESSWait 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.409INTERNAL_SERVER_ERROR with no code and no status. Match the message only as a display stringStop. 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 write409, where a route raises it, with body code, entityType, entityId, expectedVersion, and currentVersionINTERNAL_SERVER_ERROR with no code and no status. Match the message only as a display stringDo 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 conditionRESTHow to detect on GraphQLWhat to do
Throttle: too many requests in a short window429, with Retry-AfterINTERNAL_SERVER_ERROR with message ThrottlerException: Too Many Requests and no status. Detect it by the Retry-After headerBack 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 retryAfterSecondsNot applicable: REST onlyWait 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 attemptsNot applicable: these are GraphQL onlyINTERNAL_SERVER_ERROR with status 429. originalError.retryAfter is the length of the window in seconds, not the time left. No Retry-After headerWait 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 applicableGRAPHQL_VALIDATION_FAILEDSplit 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 applicableHTTP 400 with this framework message. It is not an ARC codeSend Content-Type: application/json on the request
Upload too large: File too large. It exceeds the maximum size for this upload.413Not applicable: REST onlySend a smaller file
Server fault: {"message": "Internal server error"}500INTERNAL_SERVER_ERROR. Database faults show the message Internal server error and no statusRetry 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_ERROR does not mean the server broke. Check extensions.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_PROGRESS is 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_UNKNOWN with a new key.
  • Do not rely on VERSION_CONFLICT to protect your writes. Re-read the document and compare version.
  • Do not treat ACCOUNT_LOCKED as an expired token, and do not loop on sign-in.
  • Do not expect NOT_FOUND on the staff GraphQL API. QUOTE_EXPIRED is 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 errors in the body.