---
name: taberna
description: Integrate the Taberna payments API — charge for digital goods priced in fiat and paid in crypto by creating orders or invoices, redirecting the buyer to a hosted checkout, and fulfilling on signed webhooks. Use when the task mentions Taberna, api.taberna.io, a tbrn_live_ API key, a tbrn_whsec_ webhook secret, or when adding crypto checkout, crypto invoicing or on-chain payment handling to an application with Taberna.
compatibility: Requires network access to https://api.taberna.io and a Taberna store API key. There is no test mode — every payment method is a live blockchain moving real funds.
metadata:
  author: taberna
  version: "1.0"
  homepage: https://docs.taberna.io
---

# Integrating Taberna

Taberna is a payments API for digital goods. You create a charge, send the buyer to a
hosted payment page, and Taberna tells you when the money lands on-chain. Prices are
set in **fiat** (USD or EUR); buyers pay in **crypto** (13 methods across Solana,
Ethereum, Base, Polygon and BNB Chain). Funds settle to the merchant's own wallet —
there is no Taberna balance and no payout schedule.

Two surfaces, and they must not be mixed:

- **Merchant surface** — `https://api.taberna.io/v1/...`, authenticated with a store
  API key. Runs on your server only.
- **Buyer surface** — Taberna-hosted checkout pages on a separate host, plus the
  deliberately unauthenticated `/v1/checkouts` endpoints that drive them. **Never ship
  an API key to a browser.**

**Read these four rules before writing any code — each one costs real money.**

> 1. **There is no test mode.** No sandbox, no `tbrn_test_` prefix. The only key prefix
>    is `tbrn_live_`. Never write code that "tests the payment flow end to end"
>    expecting a sandbox.
> 2. **There are no refunds.** Nothing in the API returns funds, including underpayments.
> 3. **Verify webhook signatures over the RAW request body, before parsing JSON.** This
>    is the single most common integration bug.
> 4. **Never grant value from a browser redirect.** `successUrl` is unauthenticated and
>    buyer-controlled. Fulfil on a verified webhook or a server-side read.

## Step zero: discover the key's scopes

Do this at boot, before assuming any capability. `GET /v1/store` is "whoami": it
requires **no scope**, works with any valid key, and reports the calling key's own
scopes plus the store's enabled payment methods.

```bash
curl https://api.taberna.io/v1/store -H "Authorization: Bearer $TABERNA_API_KEY"
```

```json
{
  "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 here and fail startup loudly, rather than discovering a
missing scope during a customer's checkout. **Do not probe by calling an endpoint and
catching the 403** — this endpoint exists so you never have to.

`enabledPaymentMethods` is what the buyer will be offered; an empty list means
`POST /v1/orders` fails with `Store has no payment methods enabled`.
`requireCustomerEmail` is a **hosted-checkout setting** — it makes the payment page
collect an address. It does **not** make `customerEmail` required on
`POST /v1/orders`, which succeeds without one.

## Authentication

```http
Authorization: Bearer tbrn_live_3f1c2b9a7d4e4f889a102c6e5b4d1a90aabbccdd001122
```

A live key is `tbrn_live_` + 48 hex characters. **A key is bound to one store**, which
is why no `/v1` endpoint takes a store id — never invent a `storeId` parameter.

The eight scopes, fixed for a key's lifetime:

| Scope | 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` (replay only) |

**401 vs 403 are not interchangeable:**

- `401` — bad credential (missing, malformed, unknown, revoked, expired). Body is always
  exactly `{"error":"Unauthorized"}`; the reason is deliberately withheld, so never
  branch on it. Do not retry.
- `403` — valid key, missing scope. Body names it:
  `{"error":"Missing required scope: orders:write"}`. **Surface it as a configuration
  error. Never retry, never re-authenticate, never sign a user out.**

## Charging: orders or invoices

- **Order** — a cart of catalogue products the merchant created in the dashboard.
  Use when prices live in a catalogue and when you need digital delivery attached.
- **Invoice** — line items and amounts you supply in the request. Use when the amount is
  composed at request time (credits, usage, a quoted fee). No product setup needed.

Both return a hosted payment URL. When in doubt for a dynamic amount, use an invoice.

### Create an order

`POST /v1/orders` — needs `orders:write`. 1 to 20 items; `quantity` 1 to 1,000,000.

```bash
curl -X POST https://api.taberna.io/v1/orders \
  -H "Authorization: Bearer $TABERNA_API_KEY" \
  -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
  }'
```

```json
{
  "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" }
  ]
}
```

Three things to do with that response: persist `id` (it arrives back as `data.id` in
every webhook), note `expiresAt` (`expiresIn` is **minutes**, 10 to 129,600, default
**30**), and redirect the buyer to `checkoutUrl`.

`extraContext` is arbitrary JSON echoed back **verbatim in every webhook for the order** —
put your purchase id and user id there. Invoices use `metadata` for the same purpose.

### Create an invoice

`POST /v1/invoices` — needs `invoices:write`. Up to 100 items. `unitAmount` is a fiat
**decimal string**. Redirect the buyer to the returned `url`.

```bash
curl -X POST https://api.taberna.io/v1/invoices \
  -H "Authorization: Bearer $TABERNA_API_KEY" \
  -H 'Content-Type: application/json' \
  -H 'Idempotency-Key: topup_991' \
  -d '{
    "items": [{ "name": "124 credits", "quantity": 1, "unitAmount": "124" }],
    "customerEmail": "buyer@example.com",
    "metadata": { "userId": "u_123", "purchaseId": "p_8213" },
    "successUrl": "https://yourapp.com/topup/success"
  }'
```

Returns `201` with `id`, `number`, `status: "open"`, and `url`
(`https://taberna.io/invoice/{id}`). Invoices default to a **24-hour** window.

### Idempotency

Send `Idempotency-Key` (1 to 255 chars) on **every** create, derived from your own
purchase id — never randomly per HTTP attempt, which defeats the purpose. A replayed key
returns the **original** resource whatever the new body says; Taberna does not compare
bodies. Keys never expire.

Two things that follow from that, and bite in practice:

- **A replay is indistinguishable from a create.** It also answers `201`, with no header
  or field marking it as a replay. So never send a hard-coded or copy-pasted key (least of
  all a literal from an example): you will silently receive an unrelated older resource
  instead of the one you meant to create. Derive it, always.
- **The namespace is per resource type**, not global — the same key can create one order
  *and* one invoice. It is unique per store per endpoint.

### Redirecting

Always use the URL Taberna returned (`checkoutUrl` for orders, `url` for invoices).
**Do not construct it** — the host is environment-specific. The hosted page handles token
selection, the locked quote, the deposit address and confirmations.

## Getting paid: webhooks

Webhooks are the completion signal. Ten events; you receive **all** of them and filter on
`event` yourself. Fulfil on `order.completed` and `invoice.paid` only.

`order.created`, `order.processing`, `order.completed`, `order.partial`, `order.expired`,
`invoice.created`, `invoice.paid`, `invoice.partial`, `invoice.expired`, `invoice.voided`

Headers: `X-Taberna-Delivery-Id`, `X-Taberna-Timestamp` (unix **seconds**),
`X-Taberna-Signature`.

### Signature

```
X-Taberna-Signature = HMAC_SHA256(secret, "{timestamp}.{rawBody}")   // lowercase hex
```

The secret is `tbrn_whsec_` + 48 hex chars, and **the whole string including the prefix
is the HMAC key** — do not strip it. This is the same scheme Stripe uses, so
Stripe-shaped verification code ports directly.

Taberna does **not** enforce a timestamp tolerance for you; reject stale timestamps
yourself. Five minutes (300 s) is the recommended window.

```javascript
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 express.json()
	async (req, res) => {
		if (!verify(req.body, req.headers, SECRET)) return res.sendStatus(400);

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

		// Ack fast, work after: a delivery succeeds only on a 2xx within ~10s.
		res.sendStatus(200);

		// The worker draining this queue does the idempotency check against its
		// own state. deliveryId is for correlation only — a resend reuses it.
		await enqueue({ event, data, deliveryId: req.get('x-taberna-delivery-id') });
	},
);

function verify(rawBody, headers, secret) {
	const timestamp = headers['x-taberna-timestamp'];
	const received = headers['x-taberna-signature'];
	if (!timestamp || !received) return false;

	// Replay defence: reject anything outside a 5-minute window.
	if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false;

	const expected = crypto
		.createHmac('sha256', secret)
		.update(`${timestamp}.${rawBody}`)
		.digest('hex');

	const a = Buffer.from(expected);
	const b = Buffer.from(received);
	return a.length === b.length && crypto.timingSafeEqual(a, b); // constant time
}
```

**The raw-body rule is not stylistic.** Verifying against a parsed-and-re-serialized body
fails whenever key order, indentation or trailing whitespace differs — all three were
confirmed to break the HMAC. Use `express.raw({ type: 'application/json' })` (Express),
`await request.body()` (FastAPI/Starlette), `await req.text()` (Next.js App Router),
`$request->getContent()` (Laravel).

### Deduplicate on business state, never on delivery id

Delivery is **at-least-once**. The critical subtlety:

> A manual resend **re-queues the same delivery row**, so it arrives with the **same**
> `X-Taberna-Delivery-Id` as the original, and it buys exactly **one** more attempt (the
> counter is not reset). A handler that skips ids it has already seen will silently drop
> every resend — precisely the traffic a merchant is replaying to fix a problem.

So the guard must be your own state: *has this `data.id` (or your
`extraContext.purchaseId` / `metadata.purchaseId`) already been fulfilled?* Record the
delivery id for correlation, but never let it decide whether to act.

```javascript
async function handle(event, data, deliveryId) {
	if (event !== 'order.completed') return;

	const purchase = await findPurchaseByOrderId(data.id);
	if (!purchase || purchase.fulfilledAt) return; // already applied — no-op

	await grantEntitlement(data.extraContext.userId, purchase);
	await markFulfilled(purchase.id, { deliveryId }); // audit trail, not the key
}
```

Do this in one transaction, or put a unique constraint on the fulfilment row, so two
concurrent deliveries cannot both pass the check.

## Polling as the fallback

When no webhook arrived, reconcile from your **server** against the authenticated
endpoints — `GET /v1/orders/{id}` or `GET /v1/invoices/{id}`. **Never poll
`GET /v1/checkouts/{id}` from a backend**: it is the buyer-facing unauthenticated view
and it omits `extraContext` and `metadata`, which is what you need to correlate.

Stop 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`.** Completion is a strict
`>=` against the quoted amount with **no underpayment tolerance**, and there is no
automatic refund. Overpayment completes normally.

On an order, `paymentMethod`, `cryptoAmount`, `receivedAmount` and `transactions` describe
**one** payment attempt, selected in this order: the attempt that **completed** the order;
otherwise the **best-funded** one (largest `receivedAmount`); otherwise the **most
recent**. They are `null` (and `transactions` is `[]`) before any attempt exists. An empty
`transactions` array means attribution failed, **not** that no money moved — `status` is
the authoritative fact.

## Formats

- **Fiat amounts are decimal strings, never numbers** — `"9.99"`, up to 3 decimal places,
  no trailing-zero padding, so `"50"` means $50.00, not fifty cents. Sending a JSON number
  is rejected. Use a decimal library or integer minor units; never floats.
- **Crypto amounts are integer strings in the asset's smallest unit** — `"50010000"` is
  50.01 USDC (6 decimals). Never compare them against fiat amounts, and never assume 18
  decimals.
- **Order and invoice ids are short ids**: 21 or 22 base58 characters. Products, stores
  and webhook deliveries are UUIDs.
- Timestamps are ISO-8601 UTC.

## Sharp edges

- **Body, query and path schema violations all return the same generic
  `400 {"error":"Invalid request data"}`** with no field named. Check your request against
  the OpenAPI spec rather than parsing the message. (Header validation is the exception
  that proves the rule: an out-of-range `Idempotency-Key` names itself —
  `400 {"error":"Idempotency-Key must be between 1 and 255 characters"}`.)
- **A malformed short id is a `400`, not a `404`.** `404` is reserved for an id that passes
  the base58 pattern and then fails to resolve. Another store's resource is reported as
  `404`, never `403`.
- `error` is a **human message, not a stable machine code**. Branch on HTTP status.
- **One webhook URL per store, and no per-event filtering.** Fan out yourself.
- **These do not exist — do not invent them**: a test/sandbox mode, a refund endpoint,
  API routes to create products, stores, invoice templates or API keys (all dashboard-only),
  a second webhook URL, currencies beyond `usd`/`eur`, product types beyond
  `single-purchase`, subscriptions or recurring billing, a `storeId` parameter.

The complete `/v1` surface: `/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.

## Deeper reference

Read these only when the task needs them. Open the bundled file by path, relative to
this one; if it is not present (some installers ship `SKILL.md` alone), fetch the live
URL instead.

| Topic | Bundled file | Live equivalent |
| --- | --- | --- |
| Every endpoint, parameter, error string and status | `references/api-surface.md` | https://docs.taberna.io/errors-and-limits.md |
| Webhook payloads, retries, resend, Python/PHP verifiers | `references/webhooks.md` | https://docs.taberna.io/webhooks.md |
| Payment lifecycle, partials, products, delivery, limits | `references/pitfalls.md` | https://docs.taberna.io/payment-lifecycle.md |

Authoritative sources, always current:

- Documentation — https://docs.taberna.io (append `.md` to any page URL for Markdown,
  e.g. https://docs.taberna.io/orders.md; also `/llms.txt` and `/llms-full.txt`)
- OpenAPI 3.1 spec — https://docs.taberna.io/api-reference/openapi.json
