> ## 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.

# Errors and limits

> The error shape, status conventions, request limits, and what Taberna does not do yet.

## The error shape

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

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

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

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

## 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 below, and a useful signal that a URL is
wrong rather than a resource missing.

Three conventions worth stating explicitly.

<AccordionGroup>
  <Accordion title="Validation failures are always the same 400" icon="circle-xmark">
    A body, query string or path parameter that fails schema validation answers:

    ```json theme={null}
    { "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.
  </Accordion>

  <Accordion title="Another store's resources are missing, not forbidden" icon="eye-slash">
    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.
  </Accordion>

  <Accordion title="401 and 403 are not interchangeable" icon="key">
    `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.

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

### 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.

## Request limits

| Limit                      | Value                                                                                                          |
| -------------------------- | -------------------------------------------------------------------------------------------------------------- |
| Request body               | **8 MiB** (8,388,608 bytes) — exceeded gives `413 { "error": "Payload too large" }`                            |
| Order line items           | 1 to 20                                                                                                        |
| Order item quantity        | 1 to 1,000,000                                                                                                 |
| Invoice line items         | 1 to 100                                                                                                       |
| Invoice item quantity      | 1 to 1,000,000                                                                                                 |
| `Idempotency-Key`          | 1 to 255 characters                                                                                            |
| `successUrl` / `cancelUrl` | 2048 characters, `http` or `https` only                                                                        |
| `memo`                     | 5000 characters                                                                                                |
| Invoice item `description` | 2000 characters                                                                                                |
| Stock append `text`        | 5,000,000 characters, and at most 10,000 lines per call — counted after repeats within the batch are collapsed |
| List `limit`               | 1 to 100, defaults to 20                                                                                       |
| Fiat decimal places        | 3                                                                                                              |

<Warning>
  The 8 MiB cap is on **encoded bytes**, not characters. A stock-append `text` well
  under its 5,000,000-character limit can still `413` once non-ASCII content is UTF-8
  encoded. See [Appending stock](/products-and-delivery#appending-stock).
</Warning>

### Invoice email limits

Emailing an invoice is rate-limited at three levels — per invoice, per store over a
rolling 24 hours, and platform-wide. A refusal is a `429` carrying `retryAfterSeconds`
and a matching `Retry-After` header when the block is a cooldown you can wait out.
Check `email.canSend` on the invoice rather than probing. Details in
[Email an invoice](/invoices#email-an-invoice).

## Idempotency

`POST /v1/orders` and `POST /v1/invoices` accept an optional `Idempotency-Key` header.

<Steps>
  <Step title="One key, one resource" icon="fingerprint">
    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.
  </Step>

  <Step title="The body is not part of the key" icon="triangle-exclamation">
    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.
  </Step>

  <Step title="Keys never expire" icon="infinity">
    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.
  </Step>
</Steps>

A key outside the 1–255 character range is rejected with
`400 { "error": "Idempotency-Key must be between 1 and 255 characters" }`.

<Tip>
  Sending the header is cheap and the failure mode it prevents — a network timeout on
  a create, retried, producing two charges — is expensive. Send it on every create.
</Tip>

## Current limitations

Stated plainly. These are the things people are most often surprised by.

<Warning>
  ### No test mode

  There is no sandbox and no test key prefix. **Every payment method is a live chain
  and every payment moves real funds.** Develop against small amounts on a low-fee
  chain, and expect that reaching `completed` requires an actual transfer.
</Warning>

<Warning>
  ### No refunds

  Taberna does not issue refunds. Funds settle to your own payout wallet, so returning
  money is something you do from that wallet, off-platform, on your own terms.

  This includes underpayments: an attempt that lapses holding insufficient funds
  settles as `partial`, terminal, with no automatic return. See
  [Partial payments](/payment-lifecycle#partial-payments).
</Warning>

<Warning>
  ### One webhook URL per store, no event filtering

  Every event for a store goes to a single endpoint. You cannot register a second URL,
  and you cannot subscribe to a subset of the ten event types — filter on `event` in
  your handler. See [Webhooks](/webhooks#current-limitations).
</Warning>

Also true today:

| Limitation                                                                 | What to do instead                                                                                   |
| -------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------- |
| **No API route to create products, stores, invoice templates or API keys** | Create them in the dashboard. API keys can read products, the store and templates, and append stock. |
| **Fiat currencies limited to USD and EUR**                                 | Price in one of the two; the buyer pays in whichever token you enabled.                              |
| **Orders default to a 30-minute window**                                   | Pass `expiresIn` (10 minutes to 90 days), or create orders close to checkout.                        |
| **Invoices default to a 24-hour window**                                   | Same `expiresIn` range.                                                                              |
| **Only one product type, `single-purchase`**                               | Model recurring or metered billing as repeated charges from your own scheduler.                      |
| **The delivery delimiter and selection order are dashboard-only**          | Configure them once in the dashboard; the API appends stock, it does not reconfigure.                |

## Getting help

When something is wrong and you need to report it, include:

* The **resource short id** (`data.id`, the value in the checkout link), or the
  **product UUID**.
* For a webhook problem, the **`X-Taberna-Delivery-Id`** — it is stable across retries
  and matches the dashboard's deliveries view exactly.
* The HTTP status and the `error` message you received, verbatim.
