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 |
sequence_out_of_step | 409 | The id counter behind this resource has fallen behind the table, so the next id it would hand out is already in use | Rare. 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_deposited | 409 | A receipt you listed is already on another bank deposit | Banking it twice would credit the bank account twice. Cancel the deposit that holds it, or leave it out of this one |
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 |
multipart_required | 415 | POST .../attachments was sent with any body other than multipart/form-data | Send 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_large | 413 | An 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 applied | Split 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_reached | 409 | The file list has a fixed number of slots (a customer's before/after picture holds one) and they are full | Remove a file first. The screen refuses the same way |
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. | |
deposit_refused | 422 | NolaPro would not accept the deposit. The message is its own reason | Most often the receipts do not add up to what the ledger says they are worth. Nothing was written |
account_not_configured | 422 | The bank account has no GL account assigned, so nothing can post to it | A setup problem on the account, not a problem with your request. Assign one in Banking setup |
cannot_mix_cc_and_cash | 422 | A card deposit cannot also carry cash | Bank the card settlements and the cash as two deposits, which is what the Daily Close does |
over_receipt | 422 | More than the outstanding quantity on that PO line | Read 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_refused | 422 | NolaPro would not accept the receipt. The message is its own reason | Usually an item location or vendor record it could not create. Nothing was written |
already_closed | 409 | A daily drawer close is already on file for that location, date and currency | One 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_refused | 422 | NolaPro would not accept the daily close. The message is its own reason | Usually 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_field | 422 | The field is real and stored, but NolaPro works it out - overshort from the counted cash, the float and the ledger; currencybreakdown from denominations | Refused 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_here | 422 | The field is real, but this endpoint cannot record it | Refused 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_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.