Errors, pagination, and idempotency
ARC has one GraphQL transport and several REST controller families. Their error and list shapes are not interchangeable. Code to the operation's documented signature and use the generated reference for the exact current type. For the exact shape of each error and every code, see Error reference; for ceilings and backoff see Rate limits and request limits.
GraphQL responses
A successful HTTP response does not guarantee a successful GraphQL operation. Always inspect both data and errors:
{
"data": null,
"errors": [
{
"message": "<human-readable message>",
"path": ["quote"],
"extensions": {
"code": "INTERNAL_SERVER_ERROR",
"status": 404,
"originalError": {
"message": "<human-readable message>",
"error": "Not Found",
"statusCode": 404
}
}
}
]
}
A 404 is reported as extensions.status, not as a named code. Read the status and originalError before deciding what the failure means. Because quote is a non-null field, its failure makes the whole data null. See Error reference for how to read every shape.
Useful categories include:
| Category | Typical action |
|---|---|
UNAUTHENTICATED / 401 | Refresh the JWT or obtain a new credential. Do not retry forever. |
FORBIDDEN | The user lacks permission, plan access, or data visibility. The message has the form Missing required permission: <module:resource:action>. Changing the payload will not fix it. |
BAD_REQUEST (400) and BAD_USER_INPUT | Correct the field shape, enum, required input, or date format. Field-validation and business-rule refusals arrive as BAD_REQUEST; BAD_USER_INPUT is used for malformed variables, including an unknown key in an input object passed as a variable. An unknown field written in the document fails GraphQL validation with GRAPHQL_VALIDATION_FAILED. When several fields fail, their reasons are joined with "; " in message. |
A missing or other-tenant resource (extensions.status 404, reported as INTERNAL_SERVER_ERROR) | Re-read the resource in the current tenant; ids are tenant-scoped. |
| Domain refusal, such as no price in the document currency, a stale version, or an idempotency conflict | Follow the operation-specific recovery described by the guide/reference. On GraphQL the machine-readable code is not always in extensions.code; see Error reference for how to detect each one. |
INTERNAL_SERVER_ERROR | GraphQL also uses this for statuses other than 400, 401, 403, and 422, such as 402, 404, 409, 429, and 503. Check extensions.status, extensions.originalError.code, and the message before treating it as a server fault. The global throttle and several typed refusals carry no status at all; see Error reference. Record the x-request-id response header and the operation name, then retry only when the operation is safe and idempotent. |
Some errors carry structured fields. Typed errors set their own extensions; for example, ACCOUNT_LOCKED adds lockedUntil. Errors raised with an HTTP status carry their body in extensions.originalError. Never rely on a field that the reference does not list.
An error whose extensions.effectOutcome is NOT_APPLIED has explicitly established that the transaction did not take effect. Otherwise, after a network failure, the outcome may be unknown. Re-read before submitting a new create.
REST responses
The documented service routes use ordinary HTTP status codes and a response envelope:
{ "success": true, "data": { "id": "…" } }
Paged service lists add "page": { "hasMore": …, "nextCursor": … }. See Read endpoints.
Treat 2xx as transport success, then validate the envelope and data. Treat 4xx as a caller, credential, scope, tenant, or business-state problem. Treat 5xx and network timeouts as potentially retryable only when the operation is safe to replay.
Errors are not wrapped in success: false. A failed call returns its HTTP status and a JSON body shaped like this:
{
"statusCode": "<HTTP status>",
"timestamp": "<ISO 8601 time>",
"path": "<request path>",
"method": "<HTTP method>",
"message": "<human-readable message>",
"requestId": "<request id>"
}
Typed errors add a stable code and their own fields. For example, IDEMPOTENCY_KEY_CONFLICT adds operation and idempotencyKey. See Error reference for the codes.
Request ids
Every response carries an x-request-id header. Send your own value on the request and ARC uses it; otherwise ARC generates one. REST error bodies also include it as requestId. Quote it in support requests.
Unknown fields
Unknown properties in a REST JSON body are silently ignored. They are stripped before validation, not rejected, so a misspelt optional field is dropped without an error. Check the response to confirm the field took effect.
GraphQL behaves differently. An unknown field written in the document fails validation with GRAPHQL_VALIDATION_FAILED, and an unknown key in an input object passed as a variable fails with BAD_USER_INPUT.
Pagination is operation-specific
ARC is not a single connection-shape API. The generated schema is authoritative for each field. Request at most 100 items per page; products caps a larger limit at 100 and quotes rejects a filter.limit above 100. See Rate limits and request limits.
| Shape | Example | Checkpoint |
|---|---|---|
Cursor + items | products(cursor, limit) | Store nextCursor; stop when hasNextPage is false. |
Cursor + items on a connection object | categoryChildren, customer/mobile connections | Store nextCursor and use the operation's cursor argument. |
| Offset | invoices(filter, skip, take), payments(filter, skip, take) | Store the next skip; use a stable sort/filter during a sync. |
| Page/pageSize | Selected admin/hierarchy responses | Store page and page size; check hasNextPage/totalCount. |
| Service API list | GET /api/service/customers?limit=&cursor= | Store page.nextCursor; stop when page.hasMore is false. |
| Plain bounded list | Some reference and settings queries | Do not assume the result is complete for an unbounded dataset; confirm the operation's limits. |
A robust sync stores a checkpoint per operation and per tenant. Do not assume that a cursor from one list works on another list, and do not decode cursors to infer database ids.
Example for the product connection:
query Products($cursor: String, $limit: Int!, $filter: ProductFilterInput) {
products(cursor: $cursor, limit: $limit, filter: $filter) {
items { id name updatedAt }
nextCursor
hasNextPage
totalCount
}
}
Idempotency for writes
Use a client-generated idempotency key for every create that may be retried after a timeout. A key represents one logical command, not one HTTP attempt.
ARC remembers a key for 48 hours by default. A retry after that is treated as a new command. Three operations keep keys for 30 days: createCustomerCollection, createSupplierPayment, and createPartner.
A key is scoped to the calling principal and the operation. The same string used on two different operations does not collide.
When you retry with the same key, ARC responds according to the state of the first attempt:
| Situation | Response | What to do |
|---|---|---|
| Same key and identical request, first attempt finished | The original result is replayed. | Treat it as the result of your command. |
| Same key and different request | 409 IDEMPOTENCY_KEY_CONFLICT | Do not mutate again with this key. |
| Same key, first attempt still running | 409 IDEMPOTENCY_IN_PROGRESS | Wait briefly and retry with the same key. An attempt older than about 2 minutes is presumed dead, and the retry runs it again. |
| An earlier attempt may have partly applied | 409 IDEMPOTENCY_OUTCOME_UNKNOWN | A new key does not help. Read back the resource and reconcile before doing anything else. |
See Error reference for how to detect each of these on GraphQL.
Supported contracts are visible in the schema as idempotencyKey inputs and in the service API as the Idempotency-Key header. Quote and sales-order service routes accept the header; many GraphQL document mutations expose the body field. Use the form shown by the operation you call. On the quote and sales-order service routes, the header is used only when the body has no idempotencyKey.
Good keys are unique to your integration and logical operation:
orders/<external-order-id>/create
quotes/<external-quote-id>/create
payments/<external-payment-id>/create
Do not use a random key for every retry. That defeats replay protection.
Optimistic concurrency
Several update and transition mutations expose expectedVersion. Read the document's current version and pass it back when you write.
Whether a stale expectedVersion is refused depends on the server's concurrency mode. When the server enforces it, the write is rejected with VERSION_CONFLICT (HTTP 409) and nothing is applied. Otherwise the write proceeds and the mismatch is only logged. A write sent without expectedVersion is never refused.
So never treat the absence of an error as proof that nobody else changed the document. Always re-read after writing and compare version. On a VERSION_CONFLICT, re-read, reconcile the user's intent, and retry with the new version. Do not blindly repeat the stale mutation. On GraphQL the code is not in extensions.code; see Error reference.
Retry policy
- Retry DNS/connectivity errors,
429responses, and transient 5xx responses with exponential backoff and jitter. - Honor
Retry-After(in seconds) when present, and watch theX-RateLimit-*headers. A throttled GraphQL call is recognizable by itsRetry-Afterheader or the messageThrottlerException: Too Many Requests; it has noextensions.status. See Rate limits and request limits. - Retry a mutation only with the same idempotency key, or after you have proved the first attempt had no effect. Keys are remembered for 48 hours by default; see the idempotency section above.
- On
IDEMPOTENCY_IN_PROGRESS, wait briefly and resend the same request with the same key. OnIDEMPOTENCY_OUTCOME_UNKNOWN, stop and reconcile. - Do not retry 401, 403, validation failures, domain conflicts, or expired documents without changing the cause.
- Cap attempts and surface a reconciliation task instead of creating duplicates.
See also: Error reference, Rate limits and request limits.
Next: Money, pricing, and tax.