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

# Orders

> Charge for a cart of catalogue products through a hosted checkout.

An order is a purchase of one or more **listed products**, each with a quantity.
Taberna computes the total from the products' own prices, reserves any finite delivery
stock for the order's lifetime, and hands you a hosted checkout link.

Use orders when your prices live in a catalogue. When the amount is composed per
request, use [Invoices](/invoices) instead.

Amounts, timestamps and ids follow the [conventions](/build/conventions) that hold
across the API.

## Create an order

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

### Request body

| Field           | Required | What it is for                                                                                                |
| --------------- | -------- | ------------------------------------------------------------------------------------------------------------- |
| `items`         | yes      | 1 to **20** line items, each `{ "productId": string, "quantity": integer }`                                   |
| `customerEmail` | no       | The receipt and any digital delivery, echoed in webhooks. If omitted, the hosted checkout can collect it      |
| `extraContext`  | no       | Arbitrary JSON **echoed back verbatim in every webhook for this order**. Put your user id or purchase id here |
| `successUrl`    | no       | Where the **Continue** button on the completed page points                                                    |
| `cancelUrl`     | no       | The return link on the dead-end states — expired and partial                                                  |
| `expiresIn`     | no       | Minutes the order stays payable. Defaults to **30**                                                           |

Exact types, bounds and formats for every field are in the
[API Reference](/api-reference); the bounds that bite are collected on
[Request limits](/build/limits).

About `items`:

* Every product must belong to the key's store, be `listed`, and share a single
  currency with the rest of the cart.
* A `productId` that appears more than once has its quantities **summed into one
  line** — a cart is deduplicated, not rejected.
* The order total is Σ (product price × quantity).

<Info>
  A longer `expiresIn` holds any **reserved delivery stock out of circulation for
  exactly as long**. Widen the window deliberately, not by default.
</Info>

### The `Idempotency-Key` header

Optional, 1 to 255 characters, unique per store. Retrying a create with a key this store
has already used returns the **original** order — same `id`, same everything — instead
of minting a duplicate, *no matter how the request body changed*. Keys never expire, so
use one per logical purchase; your own purchase id is the natural choice. Full semantics
in [Idempotency](/build/errors#idempotency).

### Example

<CodeGroup>
  ```bash cURL theme={"dark"}
  curl -X POST https://api.taberna.io/v1/orders \
    -H 'Authorization: Bearer tbrn_live_…' \
    -H 'Content-Type: application/json' \
    -H 'Idempotency-Key: purchase_8213' \
    -d '{
      "items": [
        { "productId": "0197f8a0-1111-7aaa-8ccc-3e5d7c9b1a24", "quantity": 2 },
        { "productId": "0197f8a0-3333-7ccc-8eee-5f7d9e1b3a46", "quantity": 1 }
      ],
      "customerEmail": "buyer@example.com",
      "extraContext": { "userId": "u_123", "cartId": "c_991" },
      "successUrl": "https://yourapp.com/thanks",
      "cancelUrl": "https://yourapp.com/cart",
      "expiresIn": 60
    }'
  ```

  ```javascript Node.js theme={"dark"}
  const res = await fetch('https://api.taberna.io/v1/orders', {
  	method: 'POST',
  	headers: {
  		Authorization: `Bearer ${process.env.TABERNA_API_KEY}`,
  		'Content-Type': 'application/json',
  		'Idempotency-Key': purchaseId,
  	},
  	body: JSON.stringify({
  		items: [
  			{ productId: '0197f8a0-1111-7aaa-8ccc-3e5d7c9b1a24', quantity: 2 },
  			{ productId: '0197f8a0-3333-7ccc-8eee-5f7d9e1b3a46', quantity: 1 },
  		],
  		customerEmail: 'buyer@example.com',
  		extraContext: { userId: 'u_123', cartId: 'c_991' },
  		successUrl: 'https://yourapp.com/thanks',
  		cancelUrl: 'https://yourapp.com/cart',
  		expiresIn: 60,
  	}),
  });
  ```

  ```python Python theme={"dark"}
  res = requests.post(
  	"https://api.taberna.io/v1/orders",
  	headers={
  		"Authorization": f"Bearer {api_key}",
  		"Content-Type": "application/json",
  		"Idempotency-Key": purchase_id,
  	},
  	json={
  		"items": [
  			{"productId": "0197f8a0-1111-7aaa-8ccc-3e5d7c9b1a24", "quantity": 2},
  			{"productId": "0197f8a0-3333-7ccc-8eee-5f7d9e1b3a46", "quantity": 1},
  		],
  		"customerEmail": "buyer@example.com",
  		"extraContext": {"userId": "u_123", "cartId": "c_991"},
  		"successUrl": "https://yourapp.com/thanks",
  		"cancelUrl": "https://yourapp.com/cart",
  		"expiresIn": 60,
  	},
  )
  ```
</CodeGroup>

```json 201 Created theme={"dark"}
{
  "id": "CcvnKdAXzvW8h3JaS1VJe",
  "checkoutUrl": "https://taberna.io/checkout/CcvnKdAXzvW8h3JaS1VJe",
  "status": "pending",
  "expiresAt": "2026-07-09T13:00:00.000Z",
  "items": [
    {
      "productId": "0197f8a0-1111-7aaa-8ccc-3e5d7c9b1a24",
      "title": "Pro licence",
      "quantity": 2,
      "unitPrice": "50",
      "currency": "usd"
    },
    {
      "productId": "0197f8a0-3333-7ccc-8eee-5f7d9e1b3a46",
      "title": "Priority support",
      "quantity": 1,
      "unitPrice": "9.99",
      "currency": "usd"
    }
  ]
}
```

`unitPrice` is a fiat decimal string with no trailing-zero padding — `"50"` is \$50.00.
The create response deliberately returns only what you need to proceed;
`GET /v1/orders/{id}` returns the full order.

### Errors

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

Anything the request **schema** rejects never reaches the order logic — the validator
answers first, always with the generic
[`{ "error": "Invalid request data" }`](/build/errors#validation-failures-are-always-the-same-400).
The messages below are the ones the order logic itself produces, once the body's shape is
already known to be valid.

| Message                                     | Cause                                                                         |
| ------------------------------------------- | ----------------------------------------------------------------------------- |
| `Product not found`                         | A `productId` does not exist                                                  |
| `Product does not belong to this store`     | A product belongs to a different store than the key                           |
| `Product is not available`                  | A product is not `listed`                                                     |
| `All items must use the same currency`      | The cart mixes USD and EUR products                                           |
| `Minimum order quantity for {title} is {n}` | Below the product's configured minimum                                        |
| `Maximum order quantity for {title} is {n}` | Above the product's configured maximum                                        |
| `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                           |
| `Product is out of stock`                   | A line item could not reserve delivery stock — **the whole order rolls back** |

<Info>
  Quantity bounds come from the product's delivery configuration and default to a
  minimum of 1 and no maximum. See
  [Quantity bounds](/build/products-api#quantity-bounds).
</Info>

`Product does not belong to this store` is the API's one deliberate exception to
reporting another store's resources as simply missing. Treat it as a signal you have
crossed keys and stores, not as a lookup to depend on — see
[Errors](/build/errors#another-stores-resources-are-missing-not-forbidden).

## Retrieve an order

`GET /v1/orders/{id}` — requires `orders:read`. `{id}` is the order's public short id.

```json 200 OK theme={"dark"}
{
  "id": "CcvnKdAXzvW8h3JaS1VJe",
  "status": "completed",
  "priceAmount": "109.99",
  "priceCurrency": "usd",
  "successUrl": "https://yourapp.com/thanks",
  "cancelUrl": "https://yourapp.com/cart",
  "customerEmail": "buyer@example.com",
  "extraContext": { "userId": "u_123", "cartId": "c_991" },
  "paymentMethod": "usdc_base",
  "cryptoAmount": "109990000",
  "receivedAmount": "109990000",
  "transactions": [
    {
      "blockchain": "base",
      "txHash": "0xabc…",
      "fromAddress": "0xdef…",
      "amount": "109990000",
      "blockNumber": "21458822",
      "blockTime": "2026-07-09T12:15:02.000Z"
    }
  ],
  "items": [
    {
      "productId": "0197f8a0-1111-7aaa-8ccc-3e5d7c9b1a24",
      "title": "Pro licence",
      "image": null,
      "quantity": 2,
      "unitPrice": "50",
      "amount": "100"
    }
  ],
  "checkoutUrl": "https://taberna.io/checkout/CcvnKdAXzvW8h3JaS1VJe",
  "createdAt": "2026-07-09T12:00:04.000Z",
  "expiresAt": "2026-07-09T13:00:00.000Z",
  "completedAt": "2026-07-09T12:15:04.000Z"
}
```

<Info>
  `paymentMethod`, `cryptoAmount`, `receivedAmount` and `transactions` all describe a
  **single payment attempt**, chosen in this order: the attempt that **completed**
  the order; otherwise the **best-funded** one, holding the largest `receivedAmount`;
  otherwise the **most recent**. They are `null` (and `transactions` is `[]`) while no
  attempt has been started.
</Info>

The best-funded tier is not a detail. Switching token abandons an attempt but leaves it
watched and payable, so the attempt that actually took the money is often **older than
the last quote the buyer requested**. Picking it here is what keeps these fields agreeing
with the `order.partial` webhook, which reports the attempt the indexer credited.

A well-formed id that does not resolve — undecodable, unknown, or an order belonging to
another store — answers `404` with `{ "error": "Order not found" }`. An order you cannot
see is reported as missing, never as forbidden. A malformed id is a `400`; see
[Errors](/build/errors#validation-failures-are-always-the-same-400).

## List orders

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

| Query parameter | Type    | Notes                                                       |
| --------------- | ------- | ----------------------------------------------------------- |
| `page`          | integer | Defaults to `1`                                             |
| `limit`         | integer | 1 to 100, defaults to `20`                                  |
| `status`        | enum    | `pending`, `completed`, `partial`, `expired`                |
| `createdAfter`  | string  | ISO-8601 with a `Z` or an offset. **Inclusive** lower bound |
| `createdBefore` | string  | ISO-8601 with a `Z` or an offset. **Inclusive** upper bound |

```json 200 OK theme={"dark"}
{
  "data": [ /* orders, in the shape above */ ],
  "pagination": { "page": 1, "limit": 20, "total": 137, "totalPages": 7 }
}
```

## Expire an order

`POST /v1/orders/{id}/expire` — requires `orders:write`.

Ends a `pending` order now rather than waiting out its window. This fires the
`order.expired` webhook and **returns any reserved delivery stock to the pool**, which
is the main reason to call it: an abandoned cart holding the last five licence keys is
worth reclaiming.

Returns `200` with the full order, now `expired`.

| Status | Message                                                        | Cause                                                                                                 |
| ------ | -------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------- |
| `400`  | `Invalid request data`                                         | `{id}` is not a well-formed short id                                                                  |
| `404`  | `Order not found`                                              | Well-formed id that does not resolve: unknown, or from another store                                  |
| `400`  | `Only pending orders can be expired`                           | Already `completed`, `partial` or `expired` — including an order this very call just turned `partial` |
| `400`  | `Order has already been completed`                             | It completed while the request was in flight                                                          |
| `400`  | `Order has a payment in progress — try again after it expires` | A payment attempt is live; its quote must lapse first                                                 |

Like [voiding an invoice](/invoices#what-a-void-does-to-the-invoices-payment-attempts),
the call **retires every still-watchable attempt first**, so no lapsed-but-unreaped
deposit address is left collecting funds for an order that can no longer be paid. If one
of those attempts was holding funds, retiring it turns the order `partial` — terminal —
and the call then fails with `Only pending orders can be expired`. Re-read the order to
see the `partial` status.

An order that was **already** `expired` when the call arrived is refused with
`Only pending orders can be expired`. The one case that returns a body instead is the
narrow race where a concurrent request expires it after this one read it — then you get
the expired order back, because your intent already holds.

## Statuses

| Status      | Meaning                                                                                            |
| ----------- | -------------------------------------------------------------------------------------------------- |
| `pending`   | Payable. The buyer may start and retry payment attempts. Initial state.                            |
| `completed` | 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`   | The window elapsed with nothing received, or you called the expire endpoint. Terminal.             |

```mermaid theme={"dark"}
stateDiagram-v2
    [*] --> pending: POST /v1/orders
    pending --> completed: an attempt receives the full amount
    pending --> partial: an attempt lapses holding partial funds
    pending --> expired: window elapses with nothing received
    pending --> expired: the expire endpoint is called
    completed --> [*]
    partial --> [*]
    expired --> [*]
```

Note that **picking a coin does not change the order's status** — it creates a payment
attempt *under* the order, which stays `pending`. See
[Payment lifecycle](/payment-lifecycle#payment-attempts) for the attempt-level state
machine and what each transition means.

## Multi-item and currency rules

### One currency per order

An order snapshots exactly one currency, taken from its products. Mixing a USD product
with a EUR product is `All items must use the same currency`. If you sell in both, keep
separate carts.

### Duplicate products are merged

The response's `items` array may therefore be shorter than the array you sent.

### Prices are snapshotted

`items[].title`, `unitPrice` and `image` are captured at creation. Editing the product
later never rewrites a past order — which is what makes webhooks and receipts
trustworthy after the fact.

### Stock reservation is all-or-nothing

Every line item that needs finite stock reserves it inside one transaction. If any line
cannot, the whole create fails with `Product is out of stock` and nothing is held.
Reservations are released when the order expires.
