> ## Documentation Index
> Fetch the complete documentation index at: https://docs.taberna.io/llms.txt
> Use this file to discover all available pages before exploring further.

> ## Agent Instructions
> Taberna is a crypto payments API. Amounts are priced in fiat (USD or EUR) and paid in crypto.
> Fiat amounts are ALWAYS decimal strings ("9.99"), never numbers. Crypto amounts are integer strings in the asset's smallest unit.
> Authenticate merchant endpoints with `Authorization: Bearer tbrn_live_...`. Every key is bound to one store and carries an explicit scope set, so never pass a store id to a /v1 endpoint.
> A 401 means the credential is bad; a 403 means the key lacks a scope. Never retry or re-authenticate on a 403 — surface it as a configuration error.
> The /v1/checkouts endpoints are deliberately unauthenticated and run in the buyer's browser. Never send an API key to a browser.
> Verify webhook signatures over the RAW request body before parsing JSON, and treat delivery as at-least-once: handlers must be idempotent.
> Never grant value based on a browser redirect to successUrl. Fulfil on a signature-verified webhook, or on a server-side read of GET /v1/orders/{id} or GET /v1/invoices/{id}.
> There is no test mode: every payment method is a live chain moving real funds.
> A merchant can run an entire shop with no code: the dashboard creates products and invoices, and every store can host a storefront at {slug}.taberna.io with its own theme, sections, branding and optional custom domain. Never assume the reader has an API integration.
> Taberna issues no refunds and performs no KYC. A refund is something the merchant sends from their own payout wallet, off-platform.

# Errors

> The error shape, what each status code means, the two endpoints where the status is ambiguous, and idempotency.

Every non-2xx response on the API carries the same body:

```json theme={"dark"}
{ "error": "A human-readable message" }
```

There is **one documented exception**:
[`POST /v1/invoices/{id}/send`](/invoices-email#the-rate-limit-envelope), which adds
`code` and `retryAfterSeconds` on every failure the mailer produces.

<Info>
  `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).
</Info>

## Status conventions

| Status | When                                                                                        |
| ------ | ------------------------------------------------------------------------------------------- |
| `200`  | Success with a body                                                                         |
| `201`  | A resource was created — `POST /v1/orders`, `POST /v1/invoices`                             |
| `204`  | Success with no body — `POST /v1/webhook-deliveries/{deliveryId}/resend`                    |
| `400`  | Validation failure, **or** an operation that is not allowed in the resource's current state |
| `401`  | Missing, malformed, unknown, revoked or expired API key                                     |
| `403`  | Valid key, but it lacks the scope the route requires                                        |
| `404`  | No such resource **for this API key's store**, or no such route                             |
| `413`  | The request body exceeded 8 MiB                                                             |
| `429`  | A send limit was hit (invoice email only)                                                   |
| `500`  | An unhandled server-side failure — body is always `{ "error": "Internal server error" }`    |
| `502`  | The email provider rejected a send (invoice email only)                                     |

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:

```json theme={"dark"}
{ "error": "Invalid request data" }
```

The specific field is deliberately not disclosed. Check your request against the
[API Reference](/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](/authentication#401-versus-403).

## 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" }`.

<Info>
  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.
</Info>

## What to read next

<CardGroup cols={2}>
  <Card title="Request limits" icon="ruler" href="/build/limits">
    Body size, item counts and string lengths, in one table.
  </Card>

  <Card title="What Taberna does not do" icon="ban" href="/guides/what-taberna-does-not-do">
    No test mode, no refunds, no CSV export, one webhook URL.
  </Card>

  <Card title="Getting help" icon="life-ring" href="/guides/getting-help">
    What to include when you report a problem.
  </Card>
</CardGroup>
