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

# Instructions for AI agents

> Rules for LLMs and coding agents integrating Taberna — the mistakes that cost real money.

This page is written for **LLMs and coding agents** writing Taberna integrations, and
for the humans reviewing what they produce. It is self-contained: everything an agent
needs to avoid the expensive mistakes is stated here, without following links.

<Info>
  Machine-readable copies of this site are published automatically at `/llms.txt` and
  `/llms-full.txt`, and every page is available as Markdown by appending `.md` to its
  URL.
</Info>

## Non-negotiable rules

<Warning>
  **1. There is no test mode.** Every payment method is a live blockchain and every
  payment moves real funds. Never generate code that "tests the payment flow end to
  end" expecting a sandbox. Never suggest a `tbrn_test_` key — no such thing exists.
  The only key prefix is `tbrn_live_`.
</Warning>

<Warning>
  **2. Verify webhook signatures over the RAW request body, before parsing.** The
  signature is `HMAC_SHA256(secret, timestamp + "." + rawBody)` in hex. If the
  framework parses JSON and you re-serialize it, key order or whitespace will differ
  and every verification will fail.

  * Express: `express.raw({ type: 'application/json' })` on that route.
  * FastAPI / Starlette: `await request.body()`.
  * Next.js App Router: `await req.text()`.
  * Laravel: `$request->getContent()`.

  Compare in constant time — `crypto.timingSafeEqual`, `hmac.compare_digest`,
  `hash_equals`. Never `==`.
</Warning>

<Warning>
  **3. Never log, echo or commit a raw key or secret.** `tbrn_live_…` API keys and
  `tbrn_whsec_…` webhook secrets must come from environment variables or a secret
  manager. Do not print them in debug output, do not include them in error messages,
  do not write them into example files that get committed. An API key is shown exactly
  once at creation and cannot be recovered; a webhook signing secret, by contrast,
  stays readable in the dashboard, so a lost one is re-read rather than rotated.
</Warning>

<Warning>
  **4. Never ship an API key to a browser.** The `/v1/checkouts` endpoints exist
  precisely so the buyer's page needs no credential. If you find yourself putting an
  `Authorization` header in client-side code, the design is wrong.
</Warning>

<Warning>
  **5. Never grant value from a browser redirect.** `successUrl` is an unauthenticated
  redirect the buyer controls. Fulfil only on a signature-verified webhook, or on a
  server-side read of `GET /v1/orders/{id}` or `GET /v1/invoices/{id}`.
</Warning>

## Discover capabilities before assuming them

Call **`GET /v1/store`** at startup. It requires no scope, works with any valid key, and
returns the calling key's own scopes plus the store's enabled payment methods:

```bash theme={null}
curl https://api.taberna.io/v1/store -H "Authorization: Bearer $TABERNA_API_KEY"
```

```json theme={null}
{
  "id": "0197f8a0-2222-7bbb-9ddd-4f6e8dac2b35",
  "name": "My Store",
  "image": null,
  "enabledPaymentMethods": ["usdc_base", "sol"],
  "requireCustomerEmail": true,
  "createdAt": "2026-07-01T09:12:44.000Z",
  "slug": "my-store",
  "storefrontEnabled": true,
  "scopes": ["orders:read", "orders:write"],
  "storefrontUrl": "https://my-store.taberna.io"
}
```

Assert the scopes you need at boot and fail loudly, rather than discovering a missing
scope during a customer's checkout. **Do not probe by calling an endpoint and catching
the 403** — whoami exists so you never have to.

The eight scopes: `orders:read`, `orders:write`, `invoices:read`, `invoices:write`,
`products:read`, `products:write`, `webhooks:read`, `webhooks:write`.

## Error handling rules

| Status | Correct reaction                                                                                                           |
| ------ | -------------------------------------------------------------------------------------------------------------------------- |
| `400`  | Fix the request or the resource state. **Do not retry unchanged.**                                                         |
| `401`  | The credential is bad. Surface it. **Do not retry, do not rotate automatically.**                                          |
| `403`  | The key lacks a scope. Surface it as a **configuration error**. Never retry, never re-authenticate, never sign a user out. |
| `404`  | The resource does not exist for this key's store. Another store's resources also report as `404`.                          |
| `413`  | The body exceeded 8 MiB (encoded bytes). Split the batch.                                                                  |
| `429`  | Invoice email only. Honour `Retry-After` / `retryAfterSeconds`.                                                            |
| `502`  | Invoice email only. The send did not count against the allowance — safe to retry.                                          |

The error body is `{ "error": "…" }` everywhere except `POST /v1/invoices/{id}/send`,
which adds `code` and `retryAfterSeconds`. `error` is a human message, not a stable
machine code — branch on status and, where documented, on `code`.

## Money formatting rules

<Warning>
  **Fiat amounts are decimal strings, never numbers.** `"9.99"`, not `9.99`. Up to 3
  decimal places. Do not parse them into floats for arithmetic — use a decimal library
  or integer minor units. `"50"` means fifty, not fifty cents.

  **Crypto amounts are integer strings in the asset's smallest unit.** `"50010000"` is
  50.01 USDC, because USDC has 6 decimals. Do not compare them to fiat amounts, and do
  not assume 18 decimals.
</Warning>

## Idempotency and retries

Always send an `Idempotency-Key` header on `POST /v1/orders` and `POST /v1/invoices`,
derived from your own purchase id. 1 to 255 characters.

A replayed key returns the **original** resource, whatever the new body says — Taberna
does not compare bodies. Keys never expire. Never generate a random key per HTTP attempt:
that defeats the entire purpose, because the retry of a timed-out create would mint a
second charge.

## Webhook handler requirements

Delivery is **at-least-once**. A correct handler:

<Steps>
  <Step title="Reads the raw body" icon="file-code">
    Before any JSON parsing, for signature verification.
  </Step>

  <Step title="Verifies the HMAC in constant time" icon="shield-halved">
    Reject non-matching requests with a non-2xx and stop.
  </Step>

  <Step title="Rejects stale timestamps" icon="clock">
    Taberna does not enforce a tolerance for you. Reject when
    `abs(now_seconds - X-Taberna-Timestamp) > 300` — a 5-minute window. Without this,
    a captured request is replayable forever.
  </Step>

  <Step title="Applies the change idempotently against business state" icon="copy">
    **Never dedup on `X-Taberna-Delivery-Id` alone.** A manual resend re-queues the
    same delivery row, so it arrives with the **same** delivery id as the original —
    a handler that skips ids it has already seen silently drops every resend, which
    is precisely the traffic a merchant is replaying to fix a problem.

    Guard on your own state instead: has this `data.id` (or your
    `extraContext.purchaseId` / `metadata.purchaseId`) already been fulfilled? If
    yes, no-op. Record the delivery id for correlation if you like, but do not let it
    decide whether to act.
  </Step>

  <Step title="Responds 2xx fast" icon="bolt">
    Success means a `2xx` within about 10 seconds. Queue the real work; a handler that
    fulfils inline will time out under load and be replayed.
  </Step>
</Steps>

Reference handler:

```javascript theme={null}
import crypto from 'node:crypto';
import express from 'express';

const app = express();
const SECRET = process.env.TABERNA_WEBHOOK_SECRET; // tbrn_whsec_… never logged

app.post(
	'/webhooks/taberna',
	express.raw({ type: 'application/json' }), // raw bytes, not a parsed object
	async (req, res) => {
		const timestamp = req.get('x-taberna-timestamp');
		const received = req.get('x-taberna-signature');
		const deliveryId = req.get('x-taberna-delivery-id');
		if (!timestamp || !received) return res.sendStatus(400);

		// Replay defence.
		if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) {
			return res.sendStatus(400);
		}

		const expected = crypto
			.createHmac('sha256', SECRET)
			.update(`${timestamp}.${req.body}`)
			.digest('hex');
		const a = Buffer.from(expected);
		const b = Buffer.from(received);
		if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
			return res.sendStatus(400);
		}

		const { event, data } = JSON.parse(req.body.toString('utf8'));

		// Ack first, work after — the dispatcher gives up on slow endpoints.
		res.sendStatus(200);

		// The worker draining this queue does the idempotency check, against its
		// own state — e.g. skip if the order behind data.id is already fulfilled.
		// deliveryId is carried for correlation only; a resend reuses it.
		await enqueue({ event, data, deliveryId });
	},
);
```

## Polling as a fallback, not a primary

Webhooks are the completion signal. When a webhook did not arrive, reconcile from your
**server** against the authenticated endpoints — `GET /v1/orders/{id}` or
`GET /v1/invoices/{id}` — and act on the status.

Do not poll `GET /v1/checkouts/{id}` from your backend: it is the buyer-facing,
unauthenticated view and it omits `extraContext` and `metadata`, which is what you need
to correlate.

Stop polling as soon as the status leaves `pending` (orders) or `open` (invoices). Every
other state is terminal:

* Orders: `pending` → `completed` | `partial` | `expired`
* Invoices: `open` → `paid` | `partial` | `expired` | `voided`

Fulfil on `completed` / `paid` only. **Never fulfil on `partial`** unless a human has
explicitly decided to honour underpayments — there is no automatic refund, and
completion uses a strict `>=` comparison with no tolerance.

## Things that do not exist — do not invent them

An agent's most common failure here is fabricating a plausible endpoint. None of the
following exist:

* A test or sandbox mode, or a `tbrn_test_` key prefix.
* A refund endpoint, or any way to return funds through the API.
* API routes to create or edit **products**, **stores**, **invoice templates** or **API
  keys**. All four are dashboard-only.
* A second webhook URL, or per-event webhook subscriptions.
* Currencies beyond `usd` and `eur`.
* Product types beyond `single-purchase`.
* Subscriptions, recurring billing or metered usage. Model these as repeated charges
  from your own scheduler.
* A `storeId` parameter on any `/v1` endpoint — the key carries the store.

The complete `/v1` surface is: `/v1/orders`, `/v1/invoices`, `/v1/invoice-templates`,
`/v1/products`, `/v1/store`, `/v1/webhook-deliveries` (all API-key authenticated) and
`/v1/checkouts` (unauthenticated, buyer-facing). If a route is not in that list, it does
not exist — check the API Reference tab rather than guessing.

## Choosing orders versus invoices

| Situation                                                         | Use      |
| ----------------------------------------------------------------- | -------- |
| Fixed prices already modelled as catalogue items                  | Orders   |
| Amount computed at request time (credits, usage, a quoted fee)    | Invoices |
| You need digital delivery attached to the purchase                | Orders   |
| You need a sequential invoice number, a memo, or a billed-to name | Invoices |
| You want to email the customer a payment link                     | Invoices |

When in doubt for a dynamic amount, use invoices — they need no product setup and carry
`metadata` for correlation.

## Minimal correct integration

```javascript theme={null}
// 1. Boot: assert capability, never assume it.
const store = await api('/v1/store');
for (const scope of ['orders:read', 'orders:write']) {
	if (!store.scopes.includes(scope)) {
		throw new Error(`Taberna key is missing the ${scope} scope`);
	}
}

// 2. Charge: idempotent, with correlation data.
const order = await api('/v1/orders', {
	method: 'POST',
	headers: { 'Idempotency-Key': purchase.id },
	body: {
		items: [{ productId: PRODUCT_ID, quantity: 1 }],
		extraContext: { purchaseId: purchase.id, userId: user.id },
		successUrl: 'https://yourapp.com/thanks',
		cancelUrl: 'https://yourapp.com/pricing',
	},
});
await savePendingPurchase(purchase.id, order.id);

// 3. Redirect the buyer.
redirect(order.checkoutUrl);

// 4. Fulfil from the verified webhook (see the reference handler above),
//    guarded by your own business state. Reconcile with
//    GET /v1/orders/{id} only when no webhook arrived.
```
