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
sequence_out_of_step409The id counter behind this resource has fallen behind the table, so the next id it would hand out is already in useRare. A few NolaPro tables take their id from a counter table rather than AUTO_INCREMENT. A counter that has fallen behind is normally repaired automatically and the write succeeds, carrying a sequence_healed warning. This code means the repair could not find a free id, or another request took it first. Retrying may work; if it does not, an administrator has to advance the counter past the highest id in use
already_deposited409A receipt you listed is already on another bank depositBanking it twice would credit the bank account twice. Cancel the deposit that holds it, or leave it out of this one
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
multipart_required415POST .../attachments was sent with any body other than multipart/form-dataSend the file as a file part. The body was not malformed, it was the wrong kind - a JSON body would have to carry the file base64-encoded and be decoded in full, which is why the API does not take one
payload_too_large413An uploaded file is over the cap: the smaller of the install's APIV2_MAX_UPLOAD_BYTES (64 MB by default) and PHP's upload_max_filesize / post_max_size. The message names the limit that appliedSplit the document, or ask the install administrator about the limit. A body over post_max_size arrives at PHP with nothing in it at all, which is why this is checked before "no file part"
upload_limit_reached409The file list has a fixed number of slots (a customer's before/after picture holds one) and they are fullRemove a file first. The screen refuses the same way
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.
deposit_refused422NolaPro would not accept the deposit. The message is its own reasonMost often the receipts do not add up to what the ledger says they are worth. Nothing was written
account_not_configured422The bank account has no GL account assigned, so nothing can post to itA setup problem on the account, not a problem with your request. Assign one in Banking setup
cannot_mix_cc_and_cash422A card deposit cannot also carry cashBank the card settlements and the cash as two deposits, which is what the Daily Close does
over_receipt422More than the outstanding quantity on that PO lineRead the line first and send what is actually left. NolaPro itself would silently receive only the remainder, which would leave you recording stock that never arrived, so we refuse instead
receipt_refused422NolaPro would not accept the receipt. The message is its own reasonUsually an item location or vendor record it could not create. Nothing was written
already_closed409A daily drawer close is already on file for that location, date and currencyOne close per drawer per day. The screen overwrites the earlier one; through an API a repeated request is far more likely to be a retry, and replacing a close whose takings are already banked would leave the deposits pointing at numbers that no longer exist. Cancel it, or PATCH it
close_refused422NolaPro would not accept the daily close. The message is its own reasonUsually the location has no bank account for the takings, or the Over/Short GL account is not set. Nothing was written: the close and any deposit made before the failure are undone
read_only_field422The field is real and stored, but NolaPro works it out - overshort from the counted cash, the float and the ledger; currencybreakdown from denominationsRefused rather than ignored, so a record never states a number nobody computed. Send the inputs it is derived from. Not not_supported_here, which means the field is only available on a screen
not_supported_here422The field is real, but this endpoint cannot record itRefused rather than ignored, so nothing is ever stored differently from what you sent. Receiving through the API covers quantities; damaged quantities, asset creation, serial and lot capture and prepaid handling are only available on the Receiving screen
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.