Skip to main content
Every non-2xx response on the API carries the same body:
There is one documented exception: POST /v1/invoices/{id}/send, which adds code and retryAfterSeconds on every failure the mailer produces.
error is a human-readable message, not a stable machine code. Log it, surface it to an operator, branch on the HTTP status — but do not build control flow on exact string equality unless a page here documents that string as the signal (as the /void and /expire endpoints do, where the status alone is ambiguous).

Status conventions

A request to a path the API does not serve answers 404 with { "error": "Not found" } — distinct from the per-resource 404 messages, and a useful signal that a URL is wrong rather than a resource missing.

Validation failures are always the same 400

A body, query string or path parameter that fails schema validation answers:
The specific field is deliberately not disclosed. Check your request against the API Reference schema rather than trying to parse the message. This includes malformed short ids, on every route that takes one. Order, invoice and checkout ids are validated against the short-id pattern — 21 or 22 base58 characters — before any lookup happens, so /v1/orders/nope is a 400 here, not a 404. 404 is reserved for an id that passes the pattern and then fails to decode, or decodes to a resource that does not exist. There is no per-route exception to this.

Another store’s resources are missing, not forbidden

A resource that exists but belongs to a different store answers exactly like one that does not exist — 404, same message. This is deliberate: the alternative would let anyone with a key confirm the existence of another store’s ids. The same principle drives the checkout 404: an unknown order id and an unknown invoice id both return { "error": "Checkout not found" }, so the endpoint never reveals which resource was probed. One place does not follow it. Creating an order with a productId that exists but belongs to another store answers 400 { "error": "Product does not belong to this store" }, which does confirm the id exists. A product id that exists nowhere answers Product not found instead, so on POST /v1/orders the two cases are distinguishable. Everywhere else, another store’s resource is indistinguishable from a missing one.

401 and 403 are not interchangeable

401 is a bad credential — retrying with the same key will not help, and the body is always exactly { "error": "Unauthorized" } with the reason withheld. The WWW-Authenticate header carries the RFC 6750 challenge (a standard “here is how to authenticate” hint). 403 is a good credential without the right scope, and names the scope: { "error": "Missing required scope: orders:write" }. Never retry it, and never re-authenticate or sign a user out on it. See Authentication.

Two endpoints where the status is ambiguous

On POST /v1/invoices/{id}/void and POST /v1/invoices/{id}/send, an id that is not a well-formed short id is a 400 { "error": "Invalid request data" } like everywhere else. Past that, both reserve 404 for an id that passes the pattern but fails to decode, and then report a decodable id with no invoice behind it differently:
  • /void answers 400 { "error": "Invoice not found" }.
  • /send answers 404 with the full { error, code, retryAfterSeconds } envelope and code: "not_found".
So on /void, “no such invoice” can arrive as either status depending on where the id failed. Branch on the message or the code for these two, not on the status alone.

Idempotency

POST /v1/orders and POST /v1/invoices accept an optional Idempotency-Key header. Send it on every create: the failure it prevents — a network timeout on a create, retried, producing two charges — is expensive, and the header is free. Three rules govern it:
  1. One key, one resource. A retried create with a key this store has already used returns the original resource — same id, same status code (201) — instead of minting a second one.
  2. The body is not part of the key. The replay returns the original resource no matter how the request body changed. Taberna does not compare bodies and does not error on a mismatch. Reuse a key only for a retry of the same logical purchase.
  3. Keys never expire. They are scoped to your store and kept indefinitely. A create months later that reuses an old key returns that old order or invoice. Derive keys from something naturally unique — your own purchase id — rather than a counter that could wrap.
A key outside the 1–255 character range is rejected with 400 { "error": "Idempotency-Key must be between 1 and 255 characters" }.
Never generate a fresh random key per HTTP attempt. That defeats the entire purpose: the retry of a timed-out create mints a second charge.

Request limits

Body size, item counts and string lengths, in one table.

What Taberna does not do

No test mode, no refunds, no CSV export, one webhook URL.

Getting help

What to include when you report a problem.