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
  }
}
KeyContract
codeStable. Branch on this. It will not change for a given condition.
messageFor humans. May be reworded or translated at any time. Never parse it.
fieldThe offending field, when there is one. Omitted otherwise.
legacy_codeThe 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#

ClassRetry?What to do
400NeverYour code built the request wrong. Fix the code.
401NeverToken problem. Alert an operator, do not loop.
403NeverPermission problem. Alert an operator.
404NeverWrong id, or the record is in another company.
409NoFor duplicate, reconcile. For idempotency_replay, you already succeeded.
422NeverA person has to decide. Surface it, do not retry.
429YesWait Retry-After, then retry.
500Yes, carefullyRetry with backoff and an idempotency key. To get it traced, enable logging on the token, reproduce, then send the request id.
Careful

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#

CodeStatusCauseFix
unauthorized401Missing or malformed Authorization header, or unknown tokenCheck the header format is Bearer <token>
token_revoked401The token was revokedIssue a new token
token_expired401Past its expiry dateIssue a new token
insufficient_scope403Token lacks the required scopeAdd the scope, or use a different token
forbidden403The token's user is not a supervisor in that company, or is inactiveFix the user, or issue the token under one who is
feature_not_licensed403API feature not active on this installContact your account manager
record_limit_reached403The install is at its licensed limit. Only customers, items, vendors and employees are cappedRetrying will not help. The license holder has to raise the plan

Request shape#

CodeStatusCauseFix
invalid_json400Body is not valid JSONCheck Content-Type and the serializer
invalid_request400Missing required field, or wrong typeSee the errors array
invalid_type400Wrong type, commonly a number where a decimal string is requiredSend "14.2500", not 14.25
unknown_parameter400Filter or field not on the allow-listThe message lists the valid ones
unknown_value422A reference did not match anything. field names which one you sentCheck the code, or send the id
ambiguous_value422A reference matched more than one active recordSend the id. The human key is not unique on that table
conflicting_reference422You sent an id and a code (say invoicetermsid and invoiceterms) that resolve to different recordsSend one, or make them agree
not_posted409The record is still a draft and the operation only makes sense once it is posted — printing a document whose totals can still change, for instancePOST /{resource}/{id}/post first. Not not_found: the record is there, it is not ready
unsupported_value422The 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 sendSet it from the screen. Not unknown_parameter, which means the field is not there at all
not_found404No record with that id in this token's companyCheck the id and the company
duplicate409Unique value already usedLook up the existing record
idempotency_replay409Key reused with a different bodyUse a new key, or send the identical body
invalid_enum400A 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_large400More than 250 records in one /batchChunk the array. Not record_limit_reached, which is the 403 licence limit
invalid_precision400More decimal places than the field holdsEach field's reference entry declares its decimals (totals 4, unit prices and quantities 6). We reject rather than round
method_not_allowed405The resource does not support that verbRead Allow. Do not retry
not_batchable415The endpoint answers with a file — a PNG or a PDF — and a binary body cannot be carried inside a /batch results arrayCall it directly, one id at a time. Batching exists to save round trips on JSON writes, not to bundle downloads
rate_limited429Too many requestsHonour Retry-After
stale_record409If-Match did not match, someone else changed itMerge from the body returned and retry
request_in_progress409Same Idempotency-Key is still runningWait and retry. You will get the original response
cannot_cancel409Cancelling is blocked right now, e.g. closed period or payments appliedRead message. A person usually has to act
line_has_shipped409Deleting an order line, reducing it below what already shipped, or adding one to an order that has shippedCancel the shipment first, or raise another order
line_has_receipts409The same, for a purchase order with receipts against itReverse the receipt first, or raise another PO
order_already_billed409Adding hours to a service order that has reached billed, or amending a header field an invoice now referencesThe service order screen refuses it too. Hours belong on an unbilled order
hours_already_billed409PATCH or DELETE on a time entry whose status is 2 — billing already converted it into an order lineThe 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_invoiced409Invoicing a shipment that is cancelled, or already carries an invoiceidLook up the existing invoice. Re-invoicing would bill the customer twice
po_complete409PATCH, or adding a line, on a purchase order marked completeSet complete to false first, which is its own audited change
invoice_already_posted409Adding a line to an invoice that is already postedIts voucher was written for the current total. Credit it, or raise another invoice
apbill_already_posted409Coding a distribution line onto a bill that already carries a voucheridThe voucher was written for the distribution as it stood. Raise a separate bill or a debit memo
server_error500Something failed on our side and the write did not completeRetry with backoff and an idempotency key. If it persists, enable logging on the token, reproduce, and send us the request id
not_implemented501The path is declared in this spec and routed, but the code behind it is not written yetNot 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.

CodeCauseWhat it protects
gl_set_unbalancedSigned line amounts do not sum to zeroThe fundamental invariant of double-entry. An unbalanced set makes the trial balance wrong forever.
gl_set_too_shortFewer than two linesA single-sided entry is not a journal entry.
period_closedThe document date falls in a closed GL periodSomeone has already reported that month. Reopening is a decision for a person.
shipto_mismatchThe shiptoid belongs to a different customerPrevents shipping and taxing to the wrong party. The invoice screen only offers that customer's own addresses; this is the same rule.
cannot_ship_quoteShipping a document whose status is quoteA quote never committed inventory, so shipping one moves stock that was never reserved.
shipqty_exceeds_remainingShipping more than qtyorder - qtyshipPrevents shipping more than was ordered. The message carries the remaining quantity.
nothing_to_invoiceNothing on the document is billable: no shipped-and-unbilled quantity and no depositShip something first, or take a deposit. An empty invoice is not a useful document.
distribution_exceeds_totalA bill line would take the coded distribution past what is owed the vendorA 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_orderA deposit larger than what is left on the orderThe overage has to be refunded by hand, and this is a keying slip far more often than it is intentional
cannot_invoice_quoteInvoicing a document whose status is quoteA quote has shipped nothing, so there is nothing to bill
hours_require_service_orderPOST /serviceordertimes naming an order whose ordertype is not servicearserviceorder_time.orderid would accept the row, and nothing would ever bill it — hours only convert to lines on a service order
use_shipments_endpointPOST /shipmentlinesA 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_violationA rule with no more specific code yetRead 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.