Conventions
These hold across every endpoint. Read this once and most of the reference becomes predictable.
Money is a string#
Amounts are always JSON strings with four decimal places.
{ "total": "367.6500", "currency": "USD" }
Not 367.65. Not 36765 in cents.
NolaPro stores currency totals as DECIMAL(19,4). If we sent that as a JSON number, most client libraries would parse it into an IEEE-754 double, and the fourth decimal would sometimes survive and sometimes not. In a system where a hundred lines get summed and compared against a bank statement, "sometimes" is the same as "no".
Four is the rule for totals, not for everything. Per-unit prices and costs are stored at six decimal places (priceach is DECIMAL(19,6)), quantities at six, and each field's reference entry states its own decimals. Trust the field, not a blanket rule.
Parse amounts with a decimal type. BigDecimal in Java, decimal.Decimal in Python, bcmath or a decimal library in PHP, decimal.js in JavaScript. Never parseFloat, never Number().
Every monetary value travels with a currency code. Do not assume it matches the company default: customers can be billed in their own currency.
Sending amounts back
The field's declared decimals is also the limit on the way in. Send more and the request is rejected:
{ "error": { "code": "invalid_precision", "message": "priceach accepts at most 6 decimal places" } }
We reject instead of rounding on purpose. If you send 10.00005 we do not know whether your system rounds half-up or half-even, so any value we picked would disagree with your books by a fraction that reconciles nowhere and never appears in either system's logs. A 400 tells you once, in development.
Quantities work the same way at six places.
Some fields are stricter than the general rule, and say so on their own reference page: a payment amount has to land on a real minor unit, because 10.005 is not an amount anyone can bank.
You can send a JSON string or a JSON number. Strings are safer for the same reason we return them. Scientific notation is rejected.
Quantities are strings too#
Six decimal places, same reasoning.
{ "quantity": "24.000000", "onhand": "412.000000" }
Six places exist because items get sold by weight, length, and fractional units, and because a rounding error in quantity becomes a rounding error in cost of goods sold.
Dates#
Dates are YYYY-MM-DD. Timestamps are RFC 3339 in UTC: 2026-08-04T14:22:05Z.
A date on an accounting document is not a timestamp. It determines the GL period the entry posts to, which determines which month's financials it lands in. Posting an invoice dated 2026-07-31 on August 4 is normal and correct. Posting one into a closed period is refused with 422 period_closed.
Pagination#
List endpoints take page (1-based) and perpage (default 50, maximum 200) and return:
{ "data": [], "page": 2, "perpage": 50, "total": 214 }
total is the count of all matching records, not the count in this page. Iterate until page * perpage >= total.
Do not paginate through a set you are also modifying. Creating records while paging shifts the ordering and you will see items twice or miss them. Read the whole set first, then act.
Filtering#
Each list endpoint accepts a fixed, documented set of filters. An unknown parameter is an error, not a silent no-op:
{
"error": {
"code": "unknown_parameter",
"message": "Unknown filter 'custmer'. Valid filters: companyname, cancel, page, perpage.",
"field": "custmer"
}
}
This is deliberate. Silently ignoring a misspelled filter means you get 10,000 records instead of 4 and do not notice until it matters.
v1 had a general expression search where you sent SQL-ish conditions and the server parsed them. It was powerful, and it was also a permanent injection risk and impossible to keep working across schema changes. v2 does not have it. If a filter you need is missing, ask for it and it gets added to the allow-list.
Idempotency#
Send Idempotency-Key on every POST. Any unique string per logical operation, a UUID is fine.
Idempotency-Key: order-88213-invoice
If a request with that key already succeeded, the original response is returned again and nothing new is created. Keys are remembered for 24 hours, per company.
This matters more here than in most APIs. Your request creates an invoice, the connection drops before the response arrives, you retry: without an idempotency key your customer is now billed twice, and someone has to issue a credit note. With one, the retry is free.
DELETE cancels, it does not delete#
DELETE does what you would expect from the verb, and nothing is ever removed from the database.
DELETE /!/api/v2/invoices/90412
The record is cancelled, not removed. It stays in the system and stays on your reports.
What else happens depends on the record and the state it was in. Cancelling is not one operation with one outcome, and it is not the same as reversing: for some documents it undoes a posting, for some it writes a correcting entry, and for some it does neither because there was nothing posted to undo. Each resource's reference page states what cancelling does to that resource. Do not assume a shape.
That variability is why the response is 200 with the cancelled record rather than 204 with an empty body. You get back "cancel": true and a fresh ETag without a second call, so you can see what actually happened rather than infer it.
Cancelling needs its own scope. invoices:write will not do it, invoices:cancel will. See Authentication for why those are split.
Deleting twice is not an error. A DELETE on an already-cancelled record returns 200, not 404. Retrying after a timeout is the normal case, and you should not have to tell the difference between "I cancelled it" and "I cancelled it twice".
Not everything can be cancelled. Two different answers, and they mean different things:
| Response | Meaning |
|---|---|
405 method_not_allowed | This resource has no cancel concept at all. It will never work, so do not retry. The Allow header lists what the resource does support. |
409 or 422 | It could be cancelled in principle, but not right now: the period is closed, payments are applied, the stock is already consumed. error.message says which. |
What will never happen is a DELETE that returns success without cancelling anything.
Two writers, one record#
Every record you can change comes back with an ETag:
ETag: "a4f1c9e2"
Send it back on a read and we will not send the record again if it has not changed:
GET /!/api/v2/customers/1187 If-None-Match: "a4f1c9e2"
HTTP/1.1 304 Not Modified
A 304 has no body. If-None-Match: * always matches, and the W/ weak prefix is accepted.
Send it back on a write and we will refuse to overwrite someone else's change:
PATCH /!/api/v2/customers/1187 If-Match: "a4f1c9e2"
If the record moved since you read it, you get 409 stale_record and the response body carries the current version, so you can merge and retry without another GET.
It guards DELETE as well as PATCH. Cancelling something that changed under you is the worse half of the problem, not the exempt half - so a stale If-Match on a DELETE refuses the same way and the record stays exactly as it was. If-Match: * means "whatever is there now" and is always accepted.
If-Match is optional, and leaving it off means last write wins. That is a real choice, not an oversight. Requiring it would force a GET before every write and make the simplest useful integration twice as expensive. But you should know which one you picked.
Use it when something other than your integration can touch the same record: a person editing orders in NolaPro while your sync runs, or two integrations that both write customers. Skip it when your integration is the only writer, which is the common case for a storefront pushing new orders.
Do not use lastchangedate as a version check. It has one-second resolution, so two writes inside the same second look identical and a stale write would sail through. It is there for delta polling, not for locking. The ETag is exact.
Pointing at another record#
An invoice names a customer and terms. A line names an item and a GL account. You will have our id for some of those and only your own code for others, so every reference is a set of sibling fields and you send whichever one you have:
| Field | Looks up by |
|---|---|
invoicetermsid | Our id. The one that keeps working |
invoiceterms | The code a person would type, here invoiceterms.verbal |
<name>externalid | Your own key, where the resource offers it |
{ "customerid": 1187, "invoicetermsid": 112 }
{ "customerid": 1187, "invoiceterms": "Net 30" }
Responses always carry the id and the code together, so you can start with codes and move to ids later without changing how you read:
{ "invoicetermsid": 1, "invoiceterms": "Net 30" }
The string field is not always a name. It is whatever people actually key that record by, and it differs: verbal for terms, customercode for customers, itemcode for items. Each reference page says which. Customer names are not unique and are never a lookup key.
Not every reference offers externalid. Parties and documents do, because you may prefer never to store our ids for those. Setup data like terms and units does not, because you map it once. A reference page lists the fields that resource accepts.
A reference you cannot see is refused. Whichever spelling you use, the record has to exist and belong to your company, or you get 422 unknown_value naming the field and nothing is written:
{ "error": { "code": "unknown_value", "field": "salesmanid",
"message": "No salesman with id 25 in this company." } }
The answer is deliberately the same whether the record is absent or belongs to someone else. "No such record, for you" tells you nothing you could use to work out what exists in another company.
A handful of references are marked id only on their reference page. Those point at a table we cannot identify from the column name alone, so there is no code to send instead and no company check to make - send an id you got from us.
Sending more than one
Allowed, as long as they agree — which is what makes it safe to GET a record, change one thing, and POST the whole object back. Since a response carries the id and the code together, echoing a record back sends two of them by construction, and that has to be the easy path.
When several are present they resolve in a fixed order and the first one present decides the value:
invoicetermsid— our idinvoicetermsexternalid— your keyinvoiceterms— the human code
Cheapest and least ambiguous first. The others are still resolved and still have to agree.
The sibling name is the id column with the trailing id removed, so invoicetermsid pairs with invoiceterms and customerid with customer.
If they disagree you get 422 conflicting_reference naming both fields and what each one resolved to. We do not quietly prefer the id, because then the other field is a lie you never find out about.
When a lookup fails
| Code | Meaning |
|---|---|
unknown_value | Nothing matched. field tells you which of the three you sent |
ambiguous_value | More than one active record matched, so we refused to pick |
ambiguous_value is real, not defensive: several of these tables have no unique index on the human key, so two active rows genuinely can share one code. Send the id.
Prefer ids once you are past setup
Not for speed. These are small tables and the difference does not matter.
Ids are stable and codes are editable. Renaming a terms record or a unit of measure in NolaPro breaks every integration that hardcoded the old string, and for the lists that are shared install-wide that rename hits every company at once. Codes are the easy way to get started. Ids are how you stay working.
Your own ids#
Every record takes an externalid — your key, stored verbatim, indexed, returned on every read, and never interpreted by us.
curl -X POST 'https://acme.nolapro.com/!/api/v2/customers' \ -H "Authorization: Bearer $NP_TOKEN" \ -H 'Content-Type: application/json' \ -d '{ "companyname": "Bridgewater Fabrication", "externalid": "crm-8842" }'
Then look the record up by it, instead of keeping a mapping table on your side:
GET /!/api/v2/customers?externalid=crm-8842
Send it on every create. It is the difference between an integration that can answer "have I already sent this one?" and one that cannot. Without it you must store our id against your record forever, and any gap in that mapping means duplicates.
It is unique per company, so two companies can both use crm-8842 for different customers without colliding.
A record can carry keys from several systems at once — a Shopify id, a CRM id and your own — because they are stored separately rather than in one field. If you need more than one, ask and we will confirm the tag to use for your system.
This install's own fields#
NolaPro lets each company define extra fields on its records — a warranty code on a customer, a rig number on an order. They are configured per install, so they are not part of this reference: the same integration talking to two customers sees two different sets.
Ask what exists before you read or write one:
GET /!/api/v2/customfields?resource=customers
{ "data": [
{ "key": "warranty_code", "resource": "customers", "label": "Warranty code",
"type": "text", "holdsvalue": true, "required": false, "readonly": false,
"default": "", "options": [] },
{ "key": "custom_2", "resource": "customers", "label": "Denomination",
"type": "select", "holdsvalue": true, "required": false, "readonly": false,
"default": "0", "options": [ { "value": "0", "label": "not applicable" },
{ "value": "4", "label": "Catholic" } ] }
] }
key is what you send, and it is not always the label: a definition with no internal name gets custom_<id>, so every field is addressable whether or not somebody named it. Filter by type and readonly to find the ones you can actually write.
Reading values
Values are opt-in, because most installs define none and nobody should pay for the two extra queries on every read:
GET /!/api/v2/customers/6?include=custom_fields
{ "id": 6, "companyname": "Bridgewater Fabrication",
"custom_fields": { "warranty_code": "AX-9", "custom_2": "4" } }
It works on lists too, and costs one extra query for the whole page rather than one per row.
Every record comes back with every key defined for that record type, whether or not anybody has filled it in — an unset field is null, not absent. Keying off whether the property exists would otherwise tell you "this customer has no warranty code" when it means "nobody has typed one yet".
An include we do not offer is refused rather than ignored. ?include=customfields, one character off, would otherwise answer 200 with no custom fields and you would conclude the record has none.
Writing values
Send them as one nested object on a create or a patch. They ride nested rather than as top-level fields so that custom_fields.status can never collide with a real column we add later, and so you can tell at a glance which half of the record is standard and which is this install's own.
curl -X PATCH 'https://acme.nolapro.com/!/api/v2/customers/6' \ -H "Authorization: Bearer $NP_TOKEN" \ -H 'Content-Type: application/json' \ -d '{ "custom_fields": { "warranty_code": "AX-9", "renewal_due": "2027-03-01" } }'
A patch carrying nothing but custom fields is a real patch, and it does not touch the record's own row — so it will not show up in a modifiedsince sync as a change to data that did not change.
The write answers with the stored values whether or not you asked to include them, because you just sent them and reading back what was stored is how you learn that "5" became 5.
Values are typed the way the definition says: an integer field comes back as a number, a checkbox as true/false, a date as YYYY-MM-DD. A select refuses a value that is not one of its options — 422 unknown_value, the same answer any other unresolvable reference gets.
[!WARNING] Sending null clears the field; it does not restore the default. A cleared field reads back as null even when the definition has a default, which matches what the screens do. There is no way to put a field back to "never set".
Two kinds cannot be written through the API at all. File and photo fields hold a reference to an uploaded file, and there is no route to send one — those answer 422 unsupported_value. Fields the administrator marked read-only are refused too, because something else owns their value.
Polling for what changed#
Every list endpoint takes modifiedsince, and it is how you should build any sync. Fetching everything and diffing it client-side is slower for you and harder on the server.
GET /!/api/v2/customers?modifiedsince=2026-08-04T02:15:00Z
{
"data": [ ],
"page": 1, "perpage": 50, "total": 12,
"_meta": { "synced_through": "2026-08-04T02:19:41Z" }
}
Store synced_through and send it back as your next modifiedsince. Do not use your own clock. Records written while your poll was running fall between your clock reading and ours, and if you ask from your own timestamp next time you will never request that window again — those records are lost from your sync permanently, with nothing to indicate it happened.
Cancellations come back in the delta, and this changes a default. Normally cancel defaults to true. When you pass modifiedsince it defaults to all, because a cancelled invoice is a change you need to know about. Cancelled records arrive with "cancel": true — handle them, or your system keeps showing documents that no longer exist.
A first run has no cursor. Either omit modifiedsince to take a full snapshot, or pass a date far enough back to cover your history, then switch to the returned cursor from then on.
Warnings#
HTTP gives you two answers: it worked, or it was refused. There is a third thing worth knowing — it worked, and you should look at something — and that arrives in a reserved _meta key:
{
"id": 90412,
"number": "10442-0",
"total": "367.6500",
"_meta": {
"warnings": [
{ "code": "credit_limit_exceeded",
"field": "customerid",
"message": "Posted. Customer is now 4,218.55 over their 25,000.00 limit." }
]
}
}
_meta is absent unless there is something to report. It is never {} and never {"warnings": []}, so if (res._meta) is a sufficient check.
Warnings use the same shape as errors — code, field, message — so you parse one structure for both. code is stable and safe to branch on; message is for humans and may be reworded.
A warning never changes the status code. A 201 with warnings is still a 201, and the record is created. Do not treat _meta as a failure.
Things that arrive this way rather than as a 422:
- an invoice posted, and the customer is now over their credit limit
- an item saved, but has no revenue GL account, so its sales will land in suspense
- a tax group was defaulted because you did not send one
- a field you sent is deprecated and goes away in a future version
The warning codes
code is stable and safe to branch on, so here is the whole set. Anything not on this list is not being emitted yet - the conditions in the examples above land here as their families are built.
| Code | Where | Means |
|---|---|---|
shared_across_companies | any write to a resource with no company column | The record you just changed is install-wide. Every company on this install sees it. A surprising number of setup tables work this way |
unit_reactivated | POST /unitnames | A cancelled unit of that name already existed and was revived rather than duplicated, so the id you got back is the ORIGINAL one, not a new record. record_reactivated is the generic form for resources that gain the behaviour later |
no_scopes | GET /me | The token carries no scopes at all. It authenticates and can do nothing - almost always a provisioning slip |
scope_feature_inactive | GET /me | The token holds a scope whose NolaPro feature is off on this install. The scope is real; the endpoints behind it answer 403 feature_not_licensed until the feature is enabled |
secret_shown_once | POST /webhooks/{id}/rotatesecret | The new signing secret is in this response and nowhere else. It cannot be read back, and the only recovery from losing it is to rotate again. Says whether the previous secret is still accepted, and until when. |
no_rate_configured | POST /serviceordertimes | No billing rate exists for the worker's work class on that work type, so the entry saved with rate 0.00 — the time screen does the same. Either send rate explicitly or configure the work class rate |
unit_reactivated is the one worth handling rather than logging. If you keyed a create against your own record and got back an id you have seen before, a reactivation is why.
That last one is worth wiring into your logs. It is how you find out a field is disappearing while there is still time to change, rather than when it stops working.
_meta is the only key in this API that starts with an underscore, and it is reserved. No NolaPro field will ever collide with it: underscores are not permitted in table or field names.
On list endpoints _meta sits beside data, page and total. The rule is the same everywhere: the body is the resource, _meta is what the server wants to tell you about the response.
Creating a parent and its children together#
A POST that creates a parent can carry its children in the same request. The parent is created first, each child is attached to the new id, and the whole thing is one database transaction.
POST /!/api/v2/teamprojects { "name": "Website rebuild", "projectcode": "TPM-050", "milestones": [ { "title": "Design", "duedate": "2026-09-01", "tasks": [ { "title": "Wireframes" }, { "title": "Mockups" } ] } ], "tasks": [ { "title": "Kickoff call" } ] }
A nested child never sends its parent id. The task above does not carry teamprojectid or teamprojectmilestoneid — those ids do not exist when you send the request, and the nesting already says where the task belongs. Sending one is a 400.
All of it or none of it. If the third task fails validation, the project is not created either. You never end up with half a project and no way to tell which half.
The response is the tree you sent, in order, with real ids. That is how you map what you sent to what was created, without a second request.
{
"id": 51,
"projectcode": "TPM-050",
"milestones": [ { "id": 140, "title": "Design",
"tasks": [ { "id": 902, ... }, { "id": 903, ... } ] } ],
"tasks": [ { "id": 904, "title": "Kickoff call" } ]
}
Errors name the position. error.field is milestones[0].tasks[2].title — with fifty nested records an error that just says title tells you nothing.
It is create-only
PATCH does not take children. {"tasks": [...]} on an update could reasonably mean "replace all of them" or "add these", and whichever we picked would destroy someone's data the day they meant the other. Add, change and cancel children through their own endpoint.
Reads do not nest
GET /teamprojects returns projects, not every task in them — a project with 500 tasks would make the list unusable. Fetch children from their own endpoint, filtered by parent:
GET /!/api/v2/teamprojecttasks/?&teamprojectid=51
What can nest
| Parent | Children |
|---|---|
teamprojects | milestones, tasks |
teamprojectmilestones | tasks |
teamprojecttasks | checklist, assignees, watchers, tags, comments |
teamprojecttemplates | items |
carriers | services |
invoices | lines |
gltransactions | lines |
The deepest tree is three levels — a project with milestones, each with tasks, each with a checklist. Beyond that it stops being one document and becomes a data import, which is what /batch is for.
Only things that belong to their parent can nest. A milestone is meaningless without its project, so it nests. A work type is a shared list that exists on its own, so it stays a reference field you point at by id or code. If you can imagine the record existing by itself, it is a reference.
The same shape everywhere
This is how invoices, orders and purchase orders take their lines. One convention, learned once.
What a write body may contain#
Send only the fields you want to change. Everything else keeps its current value, and on a create it takes the column's default.
An unknown field is refused, never ignored. Misspell companyname and you get 400 naming the field, not a 201 and a record missing the value you thought you set:
{ "error": { "code": "unknown_parameter", "field": "compayname",
"message": "Unknown field 'compayname'. Writable: companyname, companyname2, ..." } }
A field that you can read but not write says so specifically — Field 'id' is read-only. — so you are not left hunting for a typo in a name that is spelled correctly. Ids, entrydate, and the rest of the audit trail are ours to write; a caller who could set entryuserid could forge who did something.
Strings are trimmed. " Net 30 " is stored as "Net 30". Leading and trailing spaces are invisible in every screen and every export, and two values that differ only by them never match each other.
Too long is refused, not truncated. You get the limit and the length you sent. A silently shortened code is a record nobody can find again.
A create needs at least one field with a value in it. {} and {"unitname": ""} are both refused. Every column in NolaPro has a default, so an empty create would store a complete row of them — a real record, findable, and wrong.
Decimals must be strings (see Money is a string), and more decimal places than the field holds is 400 invalid_precision rather than a silent round.
One record per POST#
POST /customers takes one customer object. It does not accept an array, and it never will.
Sending many records is a separate endpoint, POST /{resource}/batch, because it needs a genuinely different response: when items 1, 2, 4 and 5 succeed and item 3 fails, there is no sensible way to express that in a single bare resource.
Batch arrives with its resource, not as a separate release. When a resource gets its write endpoint, it gets /batch at the same time — so check the resource's own reference page for whether it is live yet rather than looking for a batch milestone. Where it is not live, send records one at a time with an Idempotency-Key per record, which is safe to retry and is what batch does for you.
It answers 207 Multi-Status every time, including when everything succeeded, so there is one shape to handle:
{
"results": [
{ "index": 0, "status": 201, "id": 1187 },
{ "index": 1, "status": 422,
"error": { "code": "unknown_value", "field": "invoiceterms",
"message": "No terms code NET45 in this company." } }
],
"created": 1,
"failed": 1
}
A real 4xx on a batch means the envelope was wrong — not an array, more than 250 items, bad JSON. Individual items failing is reported inside results, never by the status code.
Each document stays atomic; the batch does not. An invoice's header and lines still succeed or fail together, but one rejected invoice does not roll back the others in the same batch. A 500-invoice import that unwound entirely because #499 had a bad tax code would be worse than useless.
Request ids and getting a call traced#
Every response carries a request id:
X-Request-Id: 01J9F3K2QW7ZC4M8
Quote it in a support ticket. It turns "an invoice failed sometime yesterday" into one exact call.
Request logging is off by default, so we usually cannot look up a call after the fact. Logging is a per-token setting and keeps only the last 100 requests for that token — minutes of history on a busy integration.
So if you need something traced, the sequence is: turn logging on for that token, reproduce the problem, then send us the request id. Enable it on the token itself, from the token list on My Info or Admin → user edit, on the token your integration uses.
Log entries are visible to admins and to the owner of the token that made the call. They record the route, status, duration and which token was used — never request or response bodies.
You can read your own log without asking us. GET /requestlog returns the requests this token made, newest first, filterable by httpstatus, failed, route and method — so "it returned a 422 an hour ago and I did not keep the body" is answerable on your side. The route is stored as the matched pattern (/invoices/{id}), never the URI you sent, which is why no ids or query values appear in it.
Two limits, so you do not build on more than is there: entries are written only while logging is on for the token, and only the last 100 are kept. It is a recent-history window for debugging, not an audit trail — if you need one of those, record the X-Request-Id yourself as you go. A token only ever sees its own requests, including against another token belonging to the same user.
Rate limits#
600 requests per minute per token. Exceeding it returns 429 rate_limited with a Retry-After header in seconds.
The window is a fixed clock minute, not a sliding one, so the counter resets at the top of each minute and Retry-After is simply the seconds remaining in the current one. It is never more than 60.
| Counted per | Token, not per user, IP or company |
| Window | Fixed clock minute |
| Limit | 600 |
POST /webhooks/{id}/test | 10 per minute, in its own separate count |
Two things worth knowing before you size a job around this:
A /batch call is one request, not 250. Batching is the intended way to stay well inside the limit
- 250 invoices in one call costs you a single request. That is the whole reason the endpoint exists.
The tighter limit on /webhooks/{id}/test has its own counter. It is limited because each call makes an outbound request to your server, so a loop there costs somebody else's machine, not just ours. Because the count is separate, ordinary traffic can never exhaust it: 600 reads do not make the next test call fail.
The limits are set to stop a runaway loop, not to meter normal use. If you are hitting them during ordinary operation, that is usually a sign of polling where you should be batching. Get in touch before you build retry-with-backoff around a design problem.
Failed sign-ins are limited separately
A request that does not authenticate has no token to count against, so it has its own limit: 20 per minute for any one token prefix, and 300 per minute overall. Past that you get 429 rate_limited instead of 401 until the minute turns over.
You will only meet this while something is misconfigured — a stale token in a config file, a deploy that lost its environment variable — and the honest failure mode is a retry loop hammering a token that will never work. Fix the token rather than waiting it out.
This can never lock out a token that works. A request that authenticates is counted somewhere else entirely, so no amount of guessing at your token affects the token itself.
Licensed record limits are a different thing
429 means slow down. 403 record_limit_reached means the install has as many customers, items, vendors or employees as its licence allows, and waiting will not help - somebody has to cancel records that are no longer used, or raise the limit on the licence. Do not fold it into your retry logic; surface it to a person.
HTTP status codes#
| Code | Meaning |
|---|---|
200 | Fine |
201 | Created |
204 | Done, nothing to return |
400 | The request is malformed: bad JSON, wrong type, unknown parameter |
401 | Authentication problem. Fix the token |
403 | Permission problem. Fix the token's scopes, or the user behind it |
404 | No such record in this token's company |
409 | Conflict: duplicate record, or an idempotency key replay |
422 | Well-formed, but it breaks an accounting rule |
429 | Rate limited |
500 | Our fault. Includes X-Request-Id, send it to us |
The distinction that matters most is 400 against 422. A 400 means your code built the request wrong and will always fail. A 422 means the request was understood and refused because it would have produced bad accounting: unbalanced entry, closed period, credit limit exceeded. Those are business outcomes your integration should surface to a person, not retry.