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

# Authentication

> Bearer API keys, the eight scopes, and how Taberna answers a bad credential.

Every `/v1` merchant endpoint authenticates with a store API key sent as a bearer
token:

```http theme={null}
Authorization: Bearer tbrn_live_3f1c2b9a7d4e4f889a102c6e5b4d1a90aabbccdd001122
```

<Warning>
  The buyer-facing [`/v1/checkouts`](/checkout) endpoints are the exception: they are
  deliberately unauthenticated, because they run in the buyer's browser. **Never ship
  an API key to a page.**
</Warning>

## Key format

A live key is always `tbrn_live_` followed by 48 hexadecimal characters. That is the
only accepted shape — anything else fails validation before it reaches the store
lookup.

The `tbrn_live_` prefix exists so that a key leaked into a log, a screenshot or a
public repository is recognisable both to a human and to automated secret scanners.

<Info>
  A key is **bound to one store**. Taberna derives the store from the key, which is
  why no `/v1` endpoint takes a store id — `POST /v1/orders` creates an order for the
  key's store and nothing else.
</Info>

## Scopes

Scopes are chosen when the key is minted and are **fixed for its lifetime**. To change
them, create a new key and revoke the old one.

| Scope            | What it unlocks                                                                   |
| ---------------- | --------------------------------------------------------------------------------- |
| `orders:read`    | `GET /v1/orders`, `GET /v1/orders/{id}`                                           |
| `orders:write`   | `POST /v1/orders`, `POST /v1/orders/{id}/expire`                                  |
| `invoices:read`  | `GET /v1/invoices`, `GET /v1/invoices/{id}`, `GET /v1/invoice-templates`          |
| `invoices:write` | `POST /v1/invoices`, `POST /v1/invoices/{id}/void`, `POST /v1/invoices/{id}/send` |
| `products:read`  | `GET /v1/products`, `GET /v1/products/{id}`                                       |
| `products:write` | `POST /v1/products/{id}/stock`                                                    |
| `webhooks:read`  | `GET /v1/webhook-deliveries`                                                      |
| `webhooks:write` | `POST /v1/webhook-deliveries/{deliveryId}/resend`                                 |

Notes worth internalising:

* **Invoice templates read under `invoices:read`**, not a scope of their own — they
  are invoice authoring material.
* **`webhooks:write` is replay only.** It lets a key push a failed delivery back into
  the queue. It does **not** permit changing where a store's events are sent; that
  stays a dashboard action.
* **Choosing scopes is mandatory.** There is no implicit full-access key. An empty
  scope array, or an unknown scope name, is rejected at key creation.

<Tip>
  Grant the minimum. A server that only sells one thing needs `orders:write` and
  `orders:read` — nothing else. Every scope you add is blast radius if the key leaks.
</Tip>

### The one endpoint that needs no scope

`GET /v1/store` — "whoami" — is callable by any valid key, whatever it was granted.
Its response includes the **calling key's own** `scopes` array:

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

This exists so an integration can discover what it may do **without provoking a 403 to
find out**. Call it at boot, assert the scopes you need, and fail your own startup with
a clear message rather than failing a customer's checkout later.

`storefrontUrl` is the canonical public origin of the store's storefront (an active
custom domain wins over the slug subdomain) and is `null` while the storefront is
unclaimed or switched off.

## 401 versus 403

The two are not interchangeable and the distinction matters for how your client should
react.

<Tabs>
  <Tab title="401 — bad credential">
    Returned for **every** authentication failure: missing header, malformed header,
    unknown key, revoked key, expired key.

    ```http theme={null}
    HTTP/1.1 401 Unauthorized
    WWW-Authenticate: Bearer realm="api", error="invalid_token"
    Content-Type: application/json

    { "error": "Unauthorized" }
    ```

    The body is always exactly `{ "error": "Unauthorized" }`. The reason is
    **deliberately not disclosed** — telling a caller "revoked" rather than "unknown"
    would confirm to whoever is guessing that a key exists. Never branch on the body.

    The RFC 6750 challenge in the `WWW-Authenticate` header is the only signal that
    distinguishes cases:

    | Challenge                                     | Meaning                                         |
    | --------------------------------------------- | ----------------------------------------------- |
    | `Bearer realm="api"`                          | No `Authorization` header at all                |
    | `Bearer realm="api", error="invalid_request"` | The header was present but malformed            |
    | `Bearer realm="api", error="invalid_token"`   | A credential was supplied and failed validation |
  </Tab>

  <Tab title="403 — insufficient scope">
    Returned when the credential is **fine** but the key was not granted the scope
    the route requires. There is no `WWW-Authenticate` header, because re-challenging
    for a credential would be the wrong instruction.

    ```http theme={null}
    HTTP/1.1 403 Forbidden
    Content-Type: application/json

    { "error": "Missing required scope: orders:write" }
    ```

    The message names the exact scope, because it is actionable: mint a key that has
    it.

    <Warning>
      **Retrying a 403 is pointless, and so is re-authenticating.** Treat it as a
      configuration error to surface to an operator — never as a signal to refresh a
      token, drop the credential, or sign a user out.
    </Warning>
  </Tab>
</Tabs>

## Rotation and revocation

Keys are minted, listed and revoked from the dashboard by a store **owner**.

<Steps>
  <Step title="The raw key is shown once">
    At creation, and only at creation. Taberna keeps a SHA-256 hash and cannot
    recover the original. Put it straight into your secret manager.
  </Step>

  <Step title="Rotate by overlap, not by swap">
    Mint the new key first, deploy it, confirm traffic is flowing on it, then revoke
    the old one. Because scopes are immutable, a scope change is always a rotation.
  </Step>

  <Step title="Revocation is immediate">
    A revoked key answers `401` on the next request. So does a key past its optional
    `expiresAt`. Keys created without an expiry do not expire.
  </Step>
</Steps>

<Info>
  A key's `lastUsedAt` is tracked, so the key list in the dashboard will tell you
  whether a key you are about to revoke is still receiving traffic.
</Info>

## Handling auth in your client

```javascript theme={null}
async function tabernaFetch(path, init = {}) {
	const res = await fetch(`https://api.taberna.io${path}`, {
		...init,
		headers: {
			...init.headers,
			Authorization: `Bearer ${process.env.TABERNA_API_KEY}`,
		},
	});

	if (res.status === 401) {
		// The credential itself is bad. Retrying will not help.
		throw new Error('Taberna API key is missing, revoked or expired');
	}
	if (res.status === 403) {
		// Valid key, missing scope. Surface it — do not retry, do not re-auth.
		const { error } = await res.json();
		throw new Error(`Taberna key lacks a required scope: ${error}`);
	}
	if (!res.ok) {
		const body = await res.json();
		throw new Error(body.error ?? `Taberna request failed (${res.status})`);
	}

	return res;
}
```
