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

# Checkout API

> The unauthenticated /v1/checkouts endpoints that drive the hosted payment pages.

Taberna hosts the page the buyer pays on, and drives it through the `/v1/checkouts`
endpoints. You normally never call them — you redirect to the URL Taberna returned and
wait for a webhook. They are documented because you may need to read one, and because
their trust model explains why no API key is involved.

| Resource | Where you get the link               | Page                            |
| -------- | ------------------------------------ | ------------------------------- |
| Order    | `checkoutUrl` from `POST /v1/orders` | `{HOME_URL}/checkout/{shortId}` |
| Invoice  | `url` from `POST /v1/invoices`       | `{HOME_URL}/invoice/{shortId}`  |

Always use the URL Taberna returned. Do not construct it yourself — the host is
environment-specific and the path may change. What the buyer actually sees on that page
is described in [Orders and payments](/guides/orders-and-payments).

## The trust model

These endpoints are **deliberately unauthenticated**, because they run in the buyer's
browser and the buyer has no Taberna account.

<Info>
  **Possession of the short id is the credential.** It is unguessable in practice, and
  every payload served here is redacted for a buyer audience — no buyer email, no
  merchant `metadata`, no payer IP. **Never send an API key from a browser.**

  The corollary: do not treat the id as a secret beyond being unguessable, and **never
  grant value based on a status a browser reported**. Fulfil from a webhook or a
  server-side read.
</Info>

## Retrieve a checkout

`GET /v1/checkouts/{id}` — no auth.

`{id}` accepts **either an order short id or an invoice short id**; one resource serves
both hosted pages. The response is a **tagged union** — a payload whose shape depends on
a discriminator field. Read `type` first, then the branch fields.

<Tabs>
  <Tab title="type: order">
    ```json theme={"dark"}
    {
      "type": "order",
      "id": "CcvnKdAXzvW8h3JaS1VJe",
      "storeId": "0197f8a0-2222-7bbb-9ddd-4f6e8dac2b35",
      "status": "pending",
      "priceAmount": "50",
      "priceCurrency": "usd",
      "successUrl": "https://yourapp.com/thanks",
      "cancelUrl": "https://yourapp.com/cart",
      "expiresAt": "2026-07-09T12:30:00.000Z",
      "completedAt": null,
      "items": [
        {
          "productId": "0197f8a0-1111-7aaa-8ccc-3e5d7c9b1a24",
          "title": "Pro licence",
          "image": null,
          "quantity": 1,
          "unitPrice": "50",
          "delivery": null
        }
      ],
      "hasCustomerEmail": true,
      "store": {
        "id": "0197f8a0-2222-7bbb-9ddd-4f6e8dac2b35",
        "name": "My Store",
        "image": null,
        "enabledPaymentMethods": ["usdc_base", "sol"],
        "requireCustomerEmail": true
      },
      "activeAttempt": null
    }
    ```

    `items[].delivery` is `null` until the order is `completed` — see
    [what delivery looks like on a completed order](/build/products-api#what-delivery-looks-like-on-a-completed-order).

    Deliberately absent: `customerEmail`, `extraContext`, the payer IP. The page only
    learns **whether** an email is on file, via `hasCustomerEmail`.
  </Tab>

  <Tab title="type: invoice">
    ```json theme={"dark"}
    {
      "type": "invoice",
      "id": "CcvnBwRQaKY1rXSMLrbYe",
      "number": 42,
      "status": "open",
      "items": [
        {
          "name": "124 credits",
          "description": null,
          "quantity": 1,
          "unitAmount": "124",
          "amount": "124"
        }
      ],
      "totalAmount": "124",
      "currency": "usd",
      "customerName": "Ada Lovelace",
      "memo": null,
      "successUrl": "https://yourapp.com/topup/success",
      "cancelUrl": "https://yourapp.com/topup",
      "expiresAt": "2026-07-09T13:00:00.000Z",
      "paidAt": null,
      "store": {
        "id": "0197f8a0-2222-7bbb-9ddd-4f6e8dac2b35",
        "name": "My Store",
        "image": null,
        "enabledPaymentMethods": ["usdc_base", "sol"]
      },
      "activeAttempt": null
    }
    ```

    Deliberately absent: `metadata`, `customerEmail`, `createdVia`. The invoice branch
    also carries no `requireCustomerEmail` on `store` — an invoice's recipient is set
    by the merchant who issued it.
  </Tab>
</Tabs>

A well-formed id that resolves to nothing answers `404` with
`{ "error": "Checkout not found" }` — **the same message for both resources**, so the
endpoint never reveals which one you probed. A malformed short id is rejected before the
lookup as a `400`; see
[Errors](/build/errors#validation-failures-are-always-the-same-400).

## The active attempt

`activeAttempt` is `null` when no quote is live, and otherwise:

```json theme={"dark"}
{
  "status": "processing",
  "paymentMethod": "usdc_base",
  "cryptoAmount": "50010000",
  "receivedAmount": "50010000",
  "destinationAddress": "0x…",
  "transactions": [
    {
      "blockchain": "base",
      "txHash": "0xabc…",
      "fromAddress": "0xdef…",
      "amount": "50010000",
      "blockNumber": "21458822",
      "blockTime": "2026-07-09T12:15:02.000Z"
    }
  ],
  "confirmation": {
    "state": "confirming",
    "confirmations": 4,
    "required": 10,
    "stale": false
  },
  "expiresAt": "2026-07-09T12:30:00.000Z",
  "completedAt": null
}
```

`cryptoAmount` and `receivedAmount` are integer strings in the token's smallest unit.
`transactions` is `[]` until a transfer is attributed.

An attempt is created when the buyer picks a token. **Re-selecting the same token returns
the same attempt** — same address, same locked quote — so reloading the page is safe.
Selecting a different token abandons the live attempt and locks a new quote on a newly
generated address; the abandoned address **stays watched until its own window ends**, so
funds sent to it after the switch still complete the parent. Abandonment emits no
webhook. See [Payment lifecycle](/payment-lifecycle#payment-attempts).

## Confirmation progress

`confirmation` is payer-facing progress, and nothing gates on it.

| Field           | Meaning                                                            |
| --------------- | ------------------------------------------------------------------ |
| `state`         | `waiting`, `detected`, `confirming` or `confirmed`                 |
| `confirmations` | Depth reached, clamped to at most `required`. `null` when unknown. |
| `required`      | Depth the chain's indexer is waiting for. `null` when unknown.     |
| `stale`         | `true` when the counts cannot be trusted                           |

<Info>
  **Do not render progress while `stale` is `true`**, and do not treat `confirmed` as
  "paid". A `confirmed` state says the transfers on file are buried deeply enough — it
  says nothing about whether they add up to the amount owed. An underpayment can sit
  at `confirmed` indefinitely. The authoritative signal is the order or invoice
  `status`.
</Info>

See [Confirmations](/payment-lifecycle#confirmations) for how each state arises and what
the per-chain depths are.

## Start a payment attempt

`POST /v1/checkouts/{id}/attempts` — no auth. Driven by the checkout UI; you normally
do not call this.

```json Request theme={"dark"}
{ "paymentMethod": "usdc_base" }
```

Returns the refreshed checkout union — the same payload as the GET, now carrying the
new `activeAttempt`. Works on both branches.

| Status | Message                                                        | Cause                                                                                                      |
| ------ | -------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------- |
| `404`  | `Checkout not found`                                           | A well-formed id that resolves to nothing                                                                  |
| `400`  | `Invalid request data`                                         | The id is not a well-formed short id, or the body failed validation — e.g. an unrecognised `paymentMethod` |
| `400`  | `Payment method is not enabled for this store`                 | Valid token, but not in `enabledPaymentMethods`                                                            |
| `400`  | `Order is not payable` / `Invoice is not payable`              | The parent is no longer `pending` / `open`                                                                 |
| `400`  | `Order has expired` / `Invoice has expired`                    | The parent's window has passed                                                                             |
| `400`  | `Order is no longer payable` / `Invoice is no longer payable`  | The parent changed state while the attempt was being created                                               |
| `400`  | `Exchange rates are temporarily unavailable, please try again` | No fresh enough rate to quote against                                                                      |

Taberna refuses to price an attempt on a stale exchange rate rather than risk
mis-quoting it. That last error is transient — the buyer retrying a moment later
normally succeeds.

## Attach a receipt email (orders only)

`PUT /v1/checkouts/{id}/email` — no auth.

```json Request theme={"dark"}
{ "email": "buyer@example.com" }
```

```json 200 OK theme={"dark"}
{ "hasCustomerEmail": true }
```

The address is **never echoed back** — the checkout payload only ever reports the
boolean.

| Status | Message                                | Cause                                                                          |
| ------ | -------------------------------------- | ------------------------------------------------------------------------------ |
| `404`  | `Checkout not found`                   | A well-formed id that resolves to nothing                                      |
| `400`  | `Invalid request data`                 | The id is not a well-formed short id, or the body is not a valid email address |
| `400`  | `Order is no longer accepting changes` | The order is no longer `pending`                                               |
| `400`  | `Not applicable to invoice checkouts`  | The id belongs to an invoice                                                   |

This route is **order-only**. An invoice's recipient is set by the merchant who issued
it, so passing an invoice id is a `400` — not a `404`.

## Get a delivery download link (orders only)

`GET /v1/checkouts/{id}/download/{orderItemId}` — no auth.

Mints a short-lived presigned URL — a temporary, signed link to private storage — for a
`file_shared` delivery on a **completed** order item.

```json 200 OK theme={"dark"}
{
  "url": "https://…presigned…",
  "fileName": "handbook.pdf",
  "expiresIn": 300
}
```

`expiresIn` is the link's lifetime in seconds — **300**, five minutes.

Every substantive failure — unknown checkout, order not completed, wrong item, a
delivery that is not a file, an invoice id — answers with the **same** `404` and the
**same** message, `{ "error": "Download not available" }`, so the endpoint never
reveals which check failed.

Both path segments are short ids and both are validated first, as everywhere else: if
`{id}` or `{orderItemId}` is not a well-formed short id the request is rejected as
`400 { "error": "Invalid request data" }` before any lookup runs.

## Polling

You may poll `GET /v1/checkouts/{id}` as a fallback, and the hosted page does exactly
that while an attempt is live. But for **your** integration, webhooks are the recommended
completion signal — and if you poll from your own server, poll the **authenticated**
endpoints, `GET /v1/orders/{id}` or `GET /v1/invoices/{id}`. They are the merchant view,
they carry `extraContext` and `metadata`, and they are not readable by anyone holding the
link.

```javascript theme={"dark"}
// Reconciliation fallback — webhooks remain primary.
async function reconcile(orderId, { attempts = 20, intervalMs = 15_000 } = {}) {
	for (let i = 0; i < attempts; i++) {
		const res = await fetch(`https://api.taberna.io/v1/orders/${orderId}`, {
			headers: { Authorization: `Bearer ${process.env.TABERNA_API_KEY}` },
		});
		if (!res.ok) throw new Error((await res.json()).error);

		const order = await res.json();
		if (order.status !== 'pending') return order; // completed, partial or expired

		await new Promise((r) => setTimeout(r, intervalMs));
	}
	return null; // still pending — leave it to the webhook
}
```

Stop polling as soon as `status` leaves `pending` (or `open`, for invoices): every
other state is terminal.
