Errors
The envelope#
Every failure, at every status code, returns the same shape:
{
"error": {
"code": "gl_set_unbalanced",
"message": "GL transaction set must balance. Debits and credits differ by 12.5000.",
"field": "lines",
"legacy_code": 30005
}
}
| Key | Contract |
|---|---|
code | Stable. Branch on this. It will not change for a given condition. |
message | For humans. May be reworded or translated at any time. Never parse it. |
field | The offending field, when there is one. Omitted otherwise. |
legacy_code | The equivalent v1 numeric code, when one exists. Present only to help you port. |
Some validation failures return multiple problems at once:
{
"error": {
"code": "invalid_request",
"message": "2 fields are invalid.",
"errors": [
{ "field": "customerid", "code": "required", "message": "customerid is required." },
{ "field": "lines[0].quantity", "code": "invalid_type", "message": "quantity must be a decimal string." }
]
}
}
The nested field uses dotted and indexed paths so you can point a user straight at the input that is wrong.
How to treat each class#
| Class | Retry? | What to do |
|---|---|---|
400 | Never | Your code built the request wrong. Fix the code. |
401 | Never | Token problem. Alert an operator, do not loop. |
403 | Never | Permission problem. Alert an operator. |
404 | Never | Wrong id, or the record is in another company. |
409 | No | For duplicate, reconcile. For idempotency_replay, you already succeeded. |
422 | Never | A person has to decide. Surface it, do not retry. |
429 | Yes | Wait Retry-After, then retry. |
500 | Yes, carefully | Retry with backoff and an idempotency key. To get it traced, enable logging on the token, reproduce, then send the request id. |
A 422 is not a transient failure. Retrying an unbalanced journal entry produces an unbalanced journal entry. The commonest integration bug we see is a generic retry wrapper that treats every non-2xx as worth another go, which turns one rejected document into thousands of identical rejections and a rate limit.
Authentication and access#
| Code | Status | Cause | Fix |
|---|---|---|---|
unauthorized | 401 | Missing or malformed Authorization header, or unknown token | Check the header format is Bearer <token> |
token_revoked | 401 | The token was revoked | Issue a new token |
token_expired | 401 | Past its expiry date | Issue a new token |
insufficient_scope | 403 | Token lacks the required scope | Add the scope, or use a different token |
forbidden | 403 | The token's user is not a supervisor in that company, or is inactive | Fix the user, or issue the token under one who is |
feature_not_licensed | 403 | API feature not active on this install | Contact your account manager |
record_limit_reached | 403 | The install is at its licensed limit. Only customers, items, vendors and employees are capped | Retrying will not help. The license holder has to raise the plan |
Request shape#
| Code | Status | Cause | Fix |
|---|---|---|---|
invalid_json | 400 | Body is not valid JSON | Check Content-Type and the serializer |
invalid_request | 400 | Missing required field, or wrong type | See the errors array |
invalid_type | 400 | Wrong type, commonly a number where a decimal string is required | Send "14.2500", not 14.25 |
unknown_parameter | 400 | Filter or field not on the allow-list | The message lists the valid ones |
unknown_value | 422 | A reference did not match anything. field names which one you sent | Check the code, or send the id |
ambiguous_value | 422 | A reference matched more than one active record | Send the id. The human key is not unique on that table |
conflicting_reference | 422 | You sent an id and a code (say invoicetermsid and invoiceterms) that resolve to different records | Send one, or make them agree |
not_posted | 409 | The record is still a draft and the operation only makes sense once it is posted — printing a document whose totals can still change, for instance | POST /{resource}/{id}/post first. Not not_found: the record is there, it is not ready |
unsupported_value | 422 | The field exists and the value is well-formed, but this API cannot store that kind of value — a custom field holding an uploaded file or photo, whose value is a reference to a file the API has no route to send | Set it from the screen. Not unknown_parameter, which means the field is not there at all |
not_found | 404 | No record with that id in this token's company | Check the id and the company |
duplicate | 409 | Unique value already used | Look up the existing record |
idempotency_replay | 409 | Key reused with a different body | Use a new key, or send the identical body |
invalid_enum | 400 | A value outside the field's published set, e.g. ordertype: "banana" | The reference entry lists the permitted values. Not unknown_value, which is a reference that did not resolve |
batch_too_large | 400 | More than 250 records in one /batch | Chunk the array. Not record_limit_reached, which is the 403 licence limit |
invalid_precision | 400 | More decimal places than the field holds | Each field's reference entry declares its decimals (totals 4, unit prices and quantities 6). We reject rather than round |
method_not_allowed | 405 | The resource does not support that verb | Read Allow. Do not retry |
not_batchable | 415 | The endpoint answers with a file — a PNG or a PDF — and a binary body cannot be carried inside a /batch results array | Call it directly, one id at a time. Batching exists to save round trips on JSON writes, not to bundle downloads |
rate_limited | 429 | Too many requests | Honour Retry-After |
stale_record | 409 | If-Match did not match, someone else changed it | Merge from the body returned and retry |
request_in_progress | 409 | Same Idempotency-Key is still running | Wait and retry. You will get the original response |
cannot_cancel | 409 | Cancelling is blocked right now, e.g. closed period or payments applied | Read message. A person usually has to act |
line_has_shipped | 409 | Deleting an order line, reducing it below what already shipped, or adding one to an order that has shipped | Cancel the shipment first, or raise another order |
line_has_receipts | 409 | The same, for a purchase order with receipts against it | Reverse the receipt first, or raise another PO |
order_already_billed | 409 | Adding hours to a service order that has reached billed, or amending a header field an invoice now references | The service order screen refuses it too. Hours belong on an unbilled order |
hours_already_billed | 409 | PATCH or DELETE on a time entry whose status is 2 — billing already converted it into an order line | The line may be on an invoice. Changing the hour underneath would leave the two disagreeing with no way to tell which is right. Credit the invoice first |
shipment_already_invoiced | 409 | Invoicing a shipment that is cancelled, or already carries an invoiceid | Look up the existing invoice. Re-invoicing would bill the customer twice |
po_complete | 409 | PATCH, or adding a line, on a purchase order marked complete | Set complete to false first, which is its own audited change |
invoice_already_posted | 409 | Adding a line to an invoice that is already posted | Its voucher was written for the current total. Credit it, or raise another invoice |
apbill_already_posted | 409 | Coding a distribution line onto a bill that already carries a voucherid | The voucher was written for the distribution as it stood. Raise a separate bill or a debit memo |
server_error | 500 | Something failed on our side and the write did not complete | Retry with backoff and an idempotency key. If it persists, enable logging on the token, reproduce, and send us the request id |
not_implemented | 501 | The path is declared in this spec and routed, but the code behind it is not written yet | Not a mistake on your side and not worth retrying. You get this rather than 404 so you can tell 'not built yet' apart from 'wrong URL' without guessing |
Accounting rules#
These are the 422s. Each one means the request was understood and refused because it would have put bad data into the books.
| Code | Cause | What it protects |
|---|---|---|
gl_set_unbalanced | Signed line amounts do not sum to zero | The fundamental invariant of double-entry. An unbalanced set makes the trial balance wrong forever. |
gl_set_too_short | Fewer than two lines | A single-sided entry is not a journal entry. |
period_closed | The document date falls in a closed GL period | Someone has already reported that month. Reopening is a decision for a person. |
shipto_mismatch | The shiptoid belongs to a different customer | Prevents shipping and taxing to the wrong party. The invoice screen only offers that customer's own addresses; this is the same rule. |
cannot_ship_quote | Shipping a document whose status is quote | A quote never committed inventory, so shipping one moves stock that was never reserved. |
shipqty_exceeds_remaining | Shipping more than qtyorder - qtyship | Prevents shipping more than was ordered. The message carries the remaining quantity. |
nothing_to_invoice | Nothing on the document is billable: no shipped-and-unbilled quantity and no deposit | Ship something first, or take a deposit. An empty invoice is not a useful document. |
distribution_exceeds_total | A bill line would take the coded distribution past what is owed the vendor | A bill cannot post while its distribution and its total disagree. apbill.total is what you owe; the lines are how it is coded |
deposit_exceeds_order | A deposit larger than what is left on the order | The overage has to be refunded by hand, and this is a keying slip far more often than it is intentional |
cannot_invoice_quote | Invoicing a document whose status is quote | A quote has shipped nothing, so there is nothing to bill |
hours_require_service_order | POST /serviceordertimes naming an order whose ordertype is not service | arserviceorder_time.orderid would accept the row, and nothing would ever bill it — hours only convert to lines on a service order |
use_shipments_endpoint | POST /shipmentlines | A shipment line is the record of stock LEAVING, and only the shipment path withdraws it. Writing the line alone would claim a movement that never happened. Use POST /shipments |
rule_violation | A rule with no more specific code yet | Read message. If you hit this repeatedly, tell us and it gets its own code. |
Coming from v1#
v1 used numeric codes grouped by module: 10000s for AP, 20000s AR, 30000s GL, 40000s inventory, 90000s general and auth. Where a v2 condition has a v1 equivalent, the response carries it as legacy_code so you can map old handling across.
Some v1 codes have no v2 equivalent, because they described failures that can no longer happen. The clock-skew and URL-mismatch signing errors (90015, 90017, 90019) are gone with the signing scheme that produced them.