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

# Invoices

> Bill for an amount you compose per request, with no product catalogue.

An invoice is a bill with line items and amounts **you supply in the create call**. No
product setup, no catalogue — which makes it the right tool whenever the amount is
decided at request time: account credits, usage top-ups, consulting fees, one-off bills.

Every invoice gets a per-store sequential `number`, its own hosted payment page, and an
arbitrary `metadata` object echoed back in every webhook for it.

## Create an invoice

`POST /v1/invoices` — requires the `invoices:write` scope.

### Request body

| Field           | Type    | Required | Notes                                                                                                                                                                      |
| --------------- | ------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `items`         | array   | see note | Up to **100** line items. See the item shape below.                                                                                                                        |
| `templateId`    | string  | no       | UUID of a dashboard-created [invoice template](#invoice-templates). Supplies defaults for `items`, `memo` and expiry.                                                      |
| `currency`      | enum    | no       | `usd` (default) or `eur`.                                                                                                                                                  |
| `customerName`  | string  | no       | At most 255 characters. Shown on the hosted page as who the invoice is billed to.                                                                                          |
| `customerEmail` | string  | no       | Stored and echoed in webhooks; **not** shown on the public page. Required if you want to use the [send endpoint](#email-an-invoice), and a receipt is sent here once paid. |
| `memo`          | string  | no       | At most 5000 characters. Free-text note shown on the hosted page.                                                                                                          |
| `metadata`      | object  | no       | Arbitrary JSON, echoed back verbatim in every `invoice.*` webhook. **Use this to correlate with your own records.**                                                        |
| `successUrl`    | string  | no       | `http`/`https` URL, at most 2048 characters. A **Continue** button links here once paid.                                                                                   |
| `cancelUrl`     | string  | no       | `http`/`https` URL, at most 2048 characters. Shown on the dead-end states — expired, voided, partial.                                                                      |
| `expiresIn`     | integer | no       | Minutes the invoice stays payable: **10** to **129,600** (90 days). Defaults to the template's expiry, or **1440** (24 hours).                                             |

<Note>
  `items` and `templateId` are jointly required — supply at least one. A body with
  neither fails schema validation and comes back as the generic
  `{ "error": "Invalid request data" }`. When both are present, `items` **replaces**
  the template's items wholesale rather than merging with them. The same
  wholesale-replacement rule applies to `memo` and the expiry.
</Note>

Each entry in `items`:

| Field         | Type    | Required | Notes                                                                                 |
| ------------- | ------- | -------- | ------------------------------------------------------------------------------------- |
| `name`        | string  | yes      | 1 to 255 characters.                                                                  |
| `description` | string  | no       | At most 2000 characters.                                                              |
| `quantity`    | integer | yes      | Positive, at most 1,000,000.                                                          |
| `unitAmount`  | string  | yes      | A fiat **decimal string** — `"1"`, `"9.99"` — up to 3 decimal places. Never a number. |

The invoice total is Σ (`unitAmount` × `quantity`), and must come out greater than
zero.

### The `Idempotency-Key` header

Identical semantics to [orders](/orders#the-idempotency-key-header): optional, 1 to 255
characters, unique per store. A retried create with a key this store has used before
returns the **original** invoice instead of minting a second one, whatever the body
says. Keys never expire.

### Example

<CodeGroup>
  ```bash cURL theme={null}
  curl -X POST https://api.taberna.io/v1/invoices \
    -H 'Authorization: Bearer tbrn_live_…' \
    -H 'Content-Type: application/json' \
    -H 'Idempotency-Key: purchase_8213' \
    -d '{
      "items": [{ "name": "124 credits", "quantity": 1, "unitAmount": "124" }],
      "customerEmail": "buyer@example.com",
      "customerName": "Ada Lovelace",
      "expiresIn": 60,
      "metadata": { "userId": "u_123", "credits": 124, "purchaseId": "p_8213" },
      "successUrl": "https://yourapp.com/topup/success",
      "cancelUrl": "https://yourapp.com/topup"
    }'
  ```

  ```javascript Node.js theme={null}
  const res = await fetch('https://api.taberna.io/v1/invoices', {
  	method: 'POST',
  	headers: {
  		Authorization: `Bearer ${process.env.TABERNA_API_KEY}`,
  		'Content-Type': 'application/json',
  		'Idempotency-Key': purchaseId,
  	},
  	body: JSON.stringify({
  		items: [{ name: `${credits} credits`, quantity: 1, unitAmount: String(credits) }],
  		customerEmail: user.email,
  		expiresIn: 60,
  		metadata: { userId: user.id, credits, purchaseId },
  		successUrl: 'https://yourapp.com/topup/success',
  		cancelUrl: 'https://yourapp.com/topup',
  	}),
  });
  if (!res.ok) throw new Error((await res.json()).error);
  const invoice = await res.json();
  // Send the customer to invoice.url
  ```

  ```python Python theme={null}
  res = requests.post(
  	"https://api.taberna.io/v1/invoices",
  	headers={
  		"Authorization": f"Bearer {api_key}",
  		"Content-Type": "application/json",
  		"Idempotency-Key": purchase_id,
  	},
  	json={
  		"items": [{"name": f"{credits} credits", "quantity": 1, "unitAmount": str(credits)}],
  		"customerEmail": user.email,
  		"expiresIn": 60,
  		"metadata": {"userId": user.id, "credits": credits, "purchaseId": purchase_id},
  		"successUrl": "https://yourapp.com/topup/success",
  		"cancelUrl": "https://yourapp.com/topup",
  	},
  )
  res.raise_for_status()
  invoice = res.json()
  ```
</CodeGroup>

```json 201 Created theme={null}
{
  "id": "CcvnBwRQaKY1rXSMLrbYe",
  "number": 42,
  "status": "open",
  "url": "https://taberna.io/invoice/CcvnBwRQaKY1rXSMLrbYe",
  "items": [
    {
      "name": "124 credits",
      "description": null,
      "quantity": 1,
      "unitAmount": "124",
      "amount": "124"
    }
  ],
  "totalAmount": "124",
  "currency": "usd",
  "customerName": "Ada Lovelace",
  "customerEmail": "buyer@example.com",
  "memo": null,
  "metadata": { "userId": "u_123", "credits": 124, "purchaseId": "p_8213" },
  "successUrl": "https://yourapp.com/topup/success",
  "cancelUrl": "https://yourapp.com/topup",
  "createdVia": "api",
  "expiresAt": "2026-07-09T13:00:00.000Z",
  "paidAt": null,
  "voidedAt": null,
  "createdAt": "2026-07-09T12:00:00.000Z",
  "email": {
    "sendCount": 0,
    "sendsRemaining": 3,
    "maxSends": 3,
    "lastSentAt": null,
    "cooldownUntil": null,
    "canSend": true,
    "blockedReason": null
  }
}
```

Redirect the customer to `url`. The hosted invoice page shows the line items, the
total, your store branding, and the same pick-a-token → locked-quote payment flow as
the order checkout.

<Info>
  `createdVia` is `api` for invoices your server created and `dashboard` for ones a
  human issued. The `email` object is the live send allowance — see
  [Email an invoice](#email-an-invoice).
</Info>

### Errors

All `400` with a `{ "error": … }` body.

Anything the request **schema** rejects — neither `items` nor `templateId`, an empty
`items`, more than 100 line items, `expiresIn` outside 10 to 129,600, a `unitAmount`
that is not a decimal string of at most 3 places, a `name` over 255 characters — never
reaches the invoice logic. The validator answers first, always with the same generic
body:

```json 400 Bad Request theme={null}
{ "error": "Invalid request data" }
```

The same item-count and expiry bounds are enforced on templates when they are
authored, so resolving `items` or the expiry **from a template** cannot smuggle a
violation past this either.

The messages below are the ones the invoice logic itself produces, once the body's
shape is already known to be valid.

| Message                                   | Cause                                               |
| ----------------------------------------- | --------------------------------------------------- |
| `Invoice total must be greater than zero` | The computed total is zero — every line is `"0"`    |
| `Template not found`                      | Unknown `templateId`, or one from another store     |
| `Store not found`                         | The key's store no longer exists                    |
| `Store has no payment methods enabled`    | Enable at least one payment method in the dashboard |

## Retrieve an invoice

`GET /v1/invoices/{id}` — requires `invoices:read`. `{id}` is the invoice's short id.

Returns the same shape as the create response **plus** a `transactions` array: the
on-chain transfers that paid it, across every payment attempt.

```json 200 OK theme={null}
{
  "id": "CcvnBwRQaKY1rXSMLrbYe",
  "number": 42,
  "status": "paid",
  "totalAmount": "124",
  "currency": "usd",
  "paidAt": "2026-07-09T12:15:04.000Z",
  "transactions": [
    {
      "blockchain": "solana",
      "txHash": "5Nx…",
      "fromAddress": "9xQ…",
      "amount": "124000000",
      "blockNumber": "298471223",
      "blockTime": "2026-07-09T12:15:02.000Z"
    }
  ]
}
```

An id that is not a well-formed short id fails the route's validation and answers
`400` with `{ "error": "Invalid request data" }`. An id that is well-formed but does
not resolve — undecodable, unknown, or an invoice belonging to another store — answers
`404` with `{ "error": "Invoice not found" }`.

## List invoices

`GET /v1/invoices` — requires `invoices:read`. Newest first.

| Query parameter | Type    | Notes                                          |
| --------------- | ------- | ---------------------------------------------- |
| `page`          | integer | Defaults to `1`                                |
| `limit`         | integer | 1 to 100, defaults to `20`                     |
| `status`        | enum    | `open`, `paid`, `partial`, `expired`, `voided` |

```json 200 OK theme={null}
{
  "data": [ /* invoices */ ],
  "pagination": { "page": 1, "limit": 20, "total": 91, "totalPages": 5 }
}
```

## Void an invoice

`POST /v1/invoices/{id}/void` — requires `invoices:write`.

Marks an **open** invoice as `voided` so it can no longer be paid, and fires
`invoice.voided`. Returns `200` with the invoice in its new state.

### What a void does to the invoice's payment attempts

A **live** attempt — `processing`, quote not yet lapsed — refuses the call outright
with `Invoice has a payment in progress — try again after it expires`. A buyer
mid-payment does not get the invoice pulled out from under them.

Past that guard, the void **retires every still-watchable attempt first**. This is the
part worth understanding: the payment indexers select attempts on status alone, so an
attempt whose quote window has already lapsed but which no indexer tick has reaped yet
is still being watched and its deposit address is still payable. Voiding around such an
attempt would leave a live address collecting funds for an invoice that can never be
paid, so they are retired as part of the call.

If funds had already landed on one of those attempts, retiring it makes the **invoice
`partial`** — terminal, and resolved manually by you — rather than `voided`. The call
then fails with `Only open invoices can be voided`, because by the time the void would
apply the invoice is no longer open. Re-read the invoice to see the `partial` status.

| Status | Message                                                          | Cause                                                                                                        |
| ------ | ---------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------ |
| `400`  | `Invalid request data`                                           | `{id}` is not a well-formed short id                                                                         |
| `404`  | `Invoice not found`                                              | Well-formed id that does not decode                                                                          |
| `400`  | `Invoice not found`                                              | The id decodes but there is no such invoice for this key's store                                             |
| `400`  | `Only open invoices can be voided`                               | Already `paid`, `partial`, `expired` or `voided` — including an invoice this very call just turned `partial` |
| `400`  | `Invoice has a payment in progress — try again after it expires` | A live attempt blocks the void, or a fully-funded one is still settling                                      |

<Note>
  Note the status quirk in the table: on **this endpoint only**, a well-formed id with
  no matching invoice is reported as `400`, not `404` — `404` is reserved for an id
  that passes the short-id format check but does not decode. Do not branch on the
  status here; branch on the message.
</Note>

## Email an invoice

`POST /v1/invoices/{id}/send` — requires `invoices:write`. **Takes no request body.**

The recipient is always the invoice's own stored `customerEmail` and cannot be
overridden. That is what stops the endpoint from being an open relay. Creating an
invoice never emails anyone; this call is the only thing that does.

```json 200 OK theme={null}
{
  "invoiceId": "CcvnBwRQaKY1rXSMLrbYe",
  "sentTo": "buyer@example.com",
  "sentAt": "2026-07-09T12:05:00.000Z",
  "sendCount": 1,
  "sendsRemaining": 2,
  "maxSends": 3
}
```

### The rate-limit envelope

This is the **one endpoint whose error body differs** from the rest of the API. Every
failure the mailer produces carries three fields:

```json theme={null}
{
  "error": "This invoice was emailed less than 5 minutes ago. Try again in 4 minutes.",
  "code": "rate_limited",
  "retryAfterSeconds": 214
}
```

* `code` is one of `not_found`, `not_sendable`, `rate_limited`, `send_failed`.
* `retryAfterSeconds` is **always present**, and `null` when the block is not a
  cooldown you can wait out (an exhausted per-invoice allowance, for example).
* When `retryAfterSeconds` is set, the same value is mirrored in a **`Retry-After`
  header**.

| Status | `code`         | When                                                                                                        |
| ------ | -------------- | ----------------------------------------------------------------------------------------------------------- |
| `400`  | `not_sendable` | The invoice is not `open`, or has no stored `customerEmail`                                                 |
| `404`  | `not_found`    | No such invoice for this key's store                                                                        |
| `429`  | `rate_limited` | A per-invoice cap, a per-invoice cooldown, a per-store 24-hour cap, or a platform-wide pause                |
| `502`  | `send_failed`  | The email provider rejected the send. **The attempt does not count against the allowance — safe to retry.** |

<Warning>
  A request rejected **before** it reaches the mailer answers with the plain
  `{ "error": … }` shape instead, with no `code` and no `retryAfterSeconds`: a
  malformed `{id}` is `400 { "error": "Invalid request data" }`, and a well-formed id
  that does not decode is `404 { "error": "Invoice not found" }`. Read `code`
  defensively.
</Warning>

The allowance is also readable without attempting a send: every invoice payload carries
an `email` object.

| Field            | Meaning                                                             |
| ---------------- | ------------------------------------------------------------------- |
| `sendCount`      | Sends made so far for this invoice                                  |
| `sendsRemaining` | How many are left before the per-invoice cap                        |
| `maxSends`       | The per-invoice cap                                                 |
| `lastSentAt`     | Timestamp of the last send, or `null`                               |
| `cooldownUntil`  | When the minimum-interval cooldown lifts, or `null`                 |
| `canSend`        | Whether a send would be accepted right now                          |
| `blockedReason`  | A human-readable reason when `canSend` is `false`, otherwise `null` |

<Tip>
  Check `email.canSend` before calling the send endpoint rather than treating a `429`
  as your signal. It costs nothing — the field is on the invoice you already have.
</Tip>

## Invoice templates

`GET /v1/invoice-templates` — requires `invoices:read`.

Templates are reusable presets — default items, memo and expiry — **authored in the
dashboard only**. This read exists so your server can discover template ids instead of
hard-coding them. The response is the store's templates, newest first, **unpaginated**
(the set is small and merchant-curated).

```json 200 OK theme={null}
[
  {
    "id": "0197f8a0-4444-7ddd-8fff-6a8b0c2d4e57",
    "name": "Monthly retainer",
    "memo": "Payable on receipt.",
    "expiresInMinutes": 10080,
    "items": [
      {
        "name": "Retainer — standard",
        "description": null,
        "quantity": 1,
        "unitAmount": "1500"
      }
    ],
    "createdAt": "2026-06-02T08:00:00.000Z"
  }
]
```

Pass a row's `id` as `templateId` on `POST /v1/invoices`. Remember that anything you
send explicitly replaces the corresponding template default wholesale.

## Statuses

| Status    | Meaning                                                                                            |
| --------- | -------------------------------------------------------------------------------------------------- |
| `open`    | Payable. Buyers can start and retry payment attempts. Initial state.                               |
| `paid`    | An attempt received the full amount on-chain. **Terminal — fulfil here.**                          |
| `partial` | An attempt's window lapsed holding a non-zero but insufficient amount. Terminal; resolve manually. |
| `expired` | `expiresAt` passed with nothing received. Terminal.                                                |
| `voided`  | Cancelled before payment, by you through the API or by a human in the dashboard. Terminal.         |

```mermaid theme={null}
stateDiagram-v2
    [*] --> open: POST /v1/invoices
    open --> paid: an attempt receives the full amount
    open --> partial: an attempt lapses holding partial funds
    open --> expired: expiresAt passes with nothing received
    open --> voided: the void endpoint is called
    paid --> [*]
    partial --> [*]
    expired --> [*]
    voided --> [*]
```

See [Payment lifecycle](/payment-lifecycle) for what happens underneath, and
[Webhooks](/webhooks) for the five `invoice.*` events.
