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

# Checkout

> The hosted payment pages, and the unauthenticated endpoints that drive them.

Taberna hosts the page the buyer pays on. You never build coin selection, exchange-rate
display, deposit addresses, QR codes or confirmation progress — you redirect, and you
wait for the result.

| 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}`  |

<Warning>
  Always use the URL Taberna returned. Do not construct it yourself — the host is
  environment-specific and the path may change.
</Warning>

## What the buyer sees

<Steps>
  <Step title="Pick a token" icon="coins">
    The page offers the tokens in your store's `enabledPaymentMethods` and nothing
    else. Choosing one starts a **payment attempt**.
  </Step>

  <Step title="Pay a locked quote" icon="lock">
    Taberna locks a crypto quote, generates a **fresh deposit wallet for this one
    attempt**, and shows the exact amount and address. The quote holds for up to 30
    minutes — precisely, until `min(now + 30 minutes, the order or invoice's own
        		expiry)`, because a locked quote must never outlive its parent.
  </Step>

  <Step title="Watch it confirm" icon="hourglass">
    Taberna watches the chain and reports progress: detected, confirming, confirmed.
    See [confirmation progress](#confirmation-progress).
  </Step>

  <Step title="Land back in your app" icon="arrow-right">
    On completion the page shows a **Continue** button pointing at your `successUrl`.
    The dead-end states — expired, partial, and voided for invoices — link to
    `cancelUrl`. Without them the buyer simply stays on the hosted page, so setting at
    least `successUrl` is recommended.
  </Step>
</Steps>

### If the quote lapses

Nothing is lost. An attempt that expires holding **zero** funds leaves the parent order
or invoice untouched and still payable — the buyer picks a token again and gets a fresh
quote, and can keep doing that until the parent itself expires.

An attempt that expires holding a **non-zero but insufficient** amount turns the parent
`partial`, which is terminal. See [Payment lifecycle](/payment-lifecycle#partial-payments).

### If the buyer switches token mid-attempt

Also fine, and worth understanding precisely:

* **Re-selecting the same token** returns the *same* attempt — same address, same
  locked quote. Reloading the page is safe.
* **Selecting a different token** abandons the live attempt and locks a new quote on a
  newly generated address.

<Note>
  An abandoned attempt's address **stays watched until its own window ends**. Funds
  sent to it after the buyer switched still complete the order or invoice. Abandonment
  is a UI affordance, not a lifecycle event — it emits no webhook.
</Note>

## The public checkout endpoints

The hosted page reads and drives payment through the `/v1/checkouts` endpoints. These
are **deliberately unauthenticated**.

<Warning>
  **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.
</Warning>

### 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**: read `type` first, then the
branch fields.

<Tabs>
  <Tab title="type: order">
    ```json theme={null}
    {
      "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
    [Products and delivery](/products-and-delivery#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={null}
    {
      "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.

<Note>
  **A malformed id never reaches the lookup.** Every route that takes a short id
  validates it against the short-id pattern first — 21 or 22 base58 characters — and an
  id that fails that check is rejected by the validator as
  `400 { "error": "Invalid request data" }`. `404` is reserved for ids that pass the
  pattern and then either fail to decode or decode to nothing.
</Note>

### The active attempt

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

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

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

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

See [Payment lifecycle](/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={null}
{ "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                                                                      |

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

### Attach a receipt email (orders only)

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

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

```json 200 OK theme={null}
{ "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                                                   |

<Note>
  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`.
</Note>

### Get a delivery download link (orders only)

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

Mints a short-lived presigned URL for a `file_shared` delivery on a **completed** order
item.

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

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

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

<Warning>
  **Webhooks are the recommended completion signal.** If you poll from your own
  server, poll the authenticated endpoints — `GET /v1/orders/{id}` or
  `GET /v1/invoices/{id}` — not the public checkout resource. They are the merchant
  view, they carry `extraContext` and `metadata`, and they are not readable by anyone
  holding the link.
</Warning>

Reasonable polling shape, if you need one:

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