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

# Webhooks

> Signed, retried lifecycle events — the signal you should build fulfilment on.

Taberna delivers order and invoice lifecycle events to **one HTTP endpoint per store**,
through a durable outbox and a retrying dispatcher. This — not a browser redirect, not
a polling loop — is what you should fulfil on.

## Setting up

Webhooks are configured in the dashboard, by a store owner.

<Steps>
  <Step title="Set the URL" icon="link">
    Point it at an HTTPS endpoint you control. A **signing secret is generated on
    first setup** and stays readable in the dashboard — unlike an API key, which is
    shown once and never again, you can come back for the signing secret whenever
    you need it.
  </Step>

  <Step title="Keep the secret across URL changes" icon="shuffle">
    Changing the URL later **keeps the existing secret**, so you can move
    infrastructure without a secret rollout. Rotating the secret is a separate,
    explicit action.
  </Step>

  <Step title="Verify before you rely on it" icon="shield-halved">
    The secret is always `tbrn_whsec_` followed by 48 hexadecimal characters. **The
    whole string, prefix included, is the HMAC key** — do not strip the prefix.
  </Step>
</Steps>

## Events

Ten event names, five per resource.

| Event              | Fires when                                                               |
| ------------------ | ------------------------------------------------------------------------ |
| `order.created`    | An order is created                                                      |
| `order.processing` | The **first** payment attempt for the order is started ("buyer engaged") |
| `order.completed`  | An attempt received the full amount on-chain. **Fulfil here.**           |
| `order.partial`    | An attempt lapsed holding a non-zero but insufficient amount             |
| `order.expired`    | The order's window elapsed, or it was expired through the API            |
| `invoice.created`  | An invoice is created                                                    |
| `invoice.paid`     | An attempt received the full amount on-chain. **Fulfil here.**           |
| `invoice.partial`  | An attempt lapsed holding a non-zero but insufficient amount             |
| `invoice.expired`  | The invoice's window elapsed with nothing received                       |
| `invoice.voided`   | The invoice was voided before payment                                    |

<Note>
  `order.processing` fires only for the **first** attempt on an order. A buyer who lets
  a quote lapse and picks a token again does not re-fire it, and there is no
  `invoice.processing` counterpart. Abandoning an attempt by switching tokens emits
  nothing at all — it is a UI affordance, not a lifecycle event.
</Note>

## Request format

Taberna sends `POST` with these headers:

| Header                  | Value                                                                                                                                                            |
| ----------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `Content-Type`          | `application/json`                                                                                                                                               |
| `User-Agent`            | `Taberna Dispatcher/1.0 (+https://taberna.io)`                                                                                                                   |
| `X-Taberna-Delivery-Id` | Id of the delivery row, **stable across automatic retries and manual resends alike** — a resend re-queues the same row. Matches the dashboard's deliveries view. |
| `X-Taberna-Timestamp`   | Unix epoch **seconds**, as a string. Part of the signature.                                                                                                      |
| `X-Taberna-Signature`   | Hex HMAC-SHA256 signature. See [Verifying the signature](#verifying-the-signature).                                                                              |

### Order payload

Identical shape for every `order.*` event.

```json theme={null}
{
  "event": "order.completed",
  "timestamp": "2026-07-09T12:15:04.123Z",
  "data": {
    "id": "CcvnKdAXzvW8h3JaS1VJe",
    "status": "completed",
    "priceAmount": "50",
    "priceCurrency": "usd",
    "paymentMethod": "usdc_base",
    "cryptoAmount": "50010000",
    "receivedAmount": "50010000",
    "transactions": [
      {
        "blockchain": "base",
        "txHash": "0xabc…",
        "fromAddress": "0xdef…",
        "amount": "50010000",
        "blockNumber": "21458822",
        "blockTime": "2026-07-09T12:15:02.000Z"
      }
    ],
    "customerEmail": "buyer@example.com",
    "extraContext": { "userId": "u_123", "cartId": "c_991" },
    "ipAddress": "203.0.113.7",
    "createdAt": "2026-07-09T12:00:04.000Z",
    "items": [
      {
        "productId": "0197f8a0-1111-7aaa-8ccc-3e5d7c9b1a24",
        "title": "Pro licence",
        "image": null,
        "quantity": 1,
        "unitPrice": "50",
        "amount": "50"
      }
    ]
  }
}
```

* `data.id` is the order's public short id — the same value `POST /v1/orders` returned.
* `data.extraContext` is **exactly** what you passed at creation. This is how you find
  your user.
* `data.items` is what was bought, snapshotted at purchase time, so later product edits
  never rewrite a past order. `amount` is the line total, `unitPrice` × `quantity`.
* `priceAmount`, `items[].unitPrice` and `items[].amount` are **fiat decimal strings**;
  `cryptoAmount` and `receivedAmount` are **crypto smallest-unit strings**.
* `paymentMethod`, `cryptoAmount`, `receivedAmount` and `ipAddress` come from the
  payment attempt that triggered the event and are **`null` on events with no attempt
  yet** — `order.created`, for instance, where `transactions` is `[]` too.

### Invoice payload

Identical shape for every `invoice.*` event.

```json theme={null}
{
  "event": "invoice.paid",
  "timestamp": "2026-07-09T12:15:04.123Z",
  "data": {
    "id": "CcvnBwRQaKY1rXSMLrbYe",
    "number": 42,
    "status": "paid",
    "items": [
      {
        "name": "124 credits",
        "description": null,
        "quantity": 1,
        "unitAmount": "124",
        "amount": "124"
      }
    ],
    "totalAmount": "124",
    "currency": "usd",
    "customerName": null,
    "customerEmail": "buyer@example.com",
    "memo": null,
    "metadata": { "userId": "u_123", "credits": 124, "purchaseId": "p_8213" },
    "payment": {
      "method": "usdc_solana",
      "cryptoAmount": "124000000",
      "receivedAmount": "124000000",
      "transactions": [
        {
          "blockchain": "solana",
          "txHash": "5Nx…",
          "fromAddress": "9xQ…",
          "amount": "124000000",
          "blockNumber": "298471223",
          "blockTime": "2026-07-09T12:15:02.000Z"
        }
      ]
    },
    "expiresAt": "2026-07-09T13:00:00.000Z",
    "paidAt": "2026-07-09T12:15:04.000Z",
    "createdAt": "2026-07-09T12:00:00.000Z"
  }
}
```

* `data.metadata` is **exactly** what you passed at creation.
* `payment` is **`null` on events with no attempt involved** — `invoice.created` and
  `invoice.voided`, for instance. Note the different nesting from the order payload,
  where the same facts are flat on `data`.

<Warning>
  `transactions` is **best effort**. An empty array means Taberna could not attribute a
  transfer — an RPC gap, or a native transfer made from inside a contract — **not that
  no money moved**. Never treat an empty array as evidence of non-payment. The
  authoritative fact is `status`.
</Warning>

## Verifying the signature

The signature is:

```
HMAC_SHA256(secret, "{timestamp}.{rawBody}")
```

in lowercase hex, where `timestamp` is the `X-Taberna-Timestamp` header (unix seconds)
and `rawBody` is the **exact raw request body**. This is the same scheme Stripe uses, so
Stripe-shaped verification code ports directly.

<Warning>
  **Verify against the raw bytes, before JSON parsing.** Almost every failed
  integration is a framework that parsed and re-serialized the body first: a single
  whitespace or key-order difference changes the hash and every signature check fails.

  In Express, mount `express.raw({ type: 'application/json' })` on the webhook route.
  In FastAPI, use `await request.body()`. In Laravel, use
  `$request->getContent()`. In Next.js App Router, use `await req.text()`.
</Warning>

<CodeGroup>
  ```javascript Node.js theme={null}
  import crypto from 'node:crypto';
  import express from 'express';

  const app = express();

  // The raw body — a parsed-and-re-serialized object will not match.
  app.post(
  	'/webhooks/taberna',
  	express.raw({ type: 'application/json' }),
  	(req, res) => {
  		if (!verify(req.body, req.headers, process.env.TABERNA_WEBHOOK_SECRET)) {
  			return res.sendStatus(400);
  		}

  		const { event, data } = JSON.parse(req.body.toString('utf8'));
  		handle(event, data, req.get('x-taberna-delivery-id'));

  		// Respond fast; do heavy work asynchronously.
  		res.sendStatus(200);
  	},
  );

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

  ```python Python theme={null}
  import hashlib
  import hmac
  import os
  import time

  from fastapi import FastAPI, Request, Response

  app = FastAPI()
  SECRET = os.environ["TABERNA_WEBHOOK_SECRET"].encode()


  def verify(raw_body: bytes, headers) -> bool:
      timestamp = headers.get("x-taberna-timestamp")
      received = headers.get("x-taberna-signature")
      if not timestamp or not received:
          return False

      # Replay defence: reject anything outside a 5-minute window.
      if abs(time.time() - int(timestamp)) > 300:
          return False

      signed = f"{timestamp}.".encode() + raw_body
      expected = hmac.new(SECRET, signed, hashlib.sha256).hexdigest()
      return hmac.compare_digest(expected, received)


  @app.post("/webhooks/taberna")
  async def taberna_webhook(request: Request):
      raw_body = await request.body()  # raw bytes, before any parsing
      if not verify(raw_body, request.headers):
          return Response(status_code=400)

      import json

      payload = json.loads(raw_body)
      handle(payload["event"], payload["data"], request.headers.get("x-taberna-delivery-id"))

      # Respond fast; do heavy work asynchronously.
      return Response(status_code=200)
  ```

  ```php PHP theme={null}
  <?php
  // The raw body — never json_decode() before verifying.
  $rawBody = file_get_contents('php://input');
  $timestamp = $_SERVER['HTTP_X_TABERNA_TIMESTAMP'] ?? '';
  $received = $_SERVER['HTTP_X_TABERNA_SIGNATURE'] ?? '';
  $secret = getenv('TABERNA_WEBHOOK_SECRET');

  if ($timestamp === '' || $received === '') {
      http_response_code(400);
      exit;
  }

  // Replay defence: reject anything outside a 5-minute window.
  if (abs(time() - (int) $timestamp) > 300) {
      http_response_code(400);
      exit;
  }

  $expected = hash_hmac('sha256', $timestamp . '.' . $rawBody, $secret);

  if (!hash_equals($expected, $received)) {
      http_response_code(400);
      exit;
  }

  $payload = json_decode($rawBody, true);
  handle($payload['event'], $payload['data'], $_SERVER['HTTP_X_TABERNA_DELIVERY_ID'] ?? null);

  // Respond fast; do heavy work asynchronously.
  http_response_code(200);
  ```
</CodeGroup>

Two rules the samples encode:

1. **Constant-time comparison.** `timingSafeEqual`, `hmac.compare_digest`, `hash_equals`
   — never `==`.
2. **A timestamp tolerance.** Taberna does not enforce one for you; reject stale
   timestamps yourself. **Five minutes** is the recommended window, matching what
   Stripe and Slack document. Without it, a captured request stays replayable forever.

If verification fails, respond non-2xx (or simply ignore the request). Do not process
the payload.

## Retries and delivery semantics

<AccordionGroup>
  <Accordion title="What counts as success" icon="circle-check">
    A delivery succeeds **only** if your endpoint returns a `2xx` within the request
    timeout — 10 seconds by default. Anything else, non-2xx or network error, is
    retried.

    Respond fast and queue the real work. A handler that does fulfilment inline is a
    handler that times out under load and gets replayed.
  </Accordion>

  <Accordion title="The backoff schedule" icon="clock-rotate-left">
    Exponential, capped:

    ```
    delay = min(baseDelay × multiplier^(attempt − 1), cap)
    ```

    With the defaults — base **15 s**, multiplier **2**, cap **8 hours**, up to **20
    attempts** — the wait doubles from 15 seconds until it reaches the 8-hour cap
    after about a dozen attempts, then stays there. That gives a total horizon of
    roughly **three days**:

    | After attempt | Next retry in |
    | ------------- | ------------- |
    | 1             | 15 seconds    |
    | 5             | 4 minutes     |
    | 11            | 4.3 hours     |
    | 12 – 19       | 8 hours (cap) |

    These are server-configurable, so treat the exact numbers as current defaults
    rather than a contract. The shape — fast at first, then hours apart, for days — is
    the part to design against.
  </Accordion>

  <Accordion title="Exhaustion" icon="triangle-exclamation">
    After the last attempt the delivery is marked **`exhausted`** and is **not retried
    automatically**. The store owner is emailed, at most once per webhook per 24
    hours, so a broken endpoint produces one alert rather than a flood.

    Once your endpoint is healthy again, replay exhausted deliveries — from the
    dashboard, or through [the resend endpoint](#listing-and-resending-deliveries).

    <Warning>
      **Resending an exhausted delivery buys exactly one more attempt.** The attempt
      counter is not reset: the delivery is pushed back into the queue at its
      existing count, one request is made, and if that request does not return a
      `2xx` the delivery is immediately `exhausted` again. It does **not** restart
      the multi-day retry schedule.

      So fix the endpoint first, verify it, and only then resend. If the resend
      fails you are back where you started and must resend again.
    </Warning>
  </Accordion>

  <Accordion title="At-least-once delivery" icon="rotate">
    The same event **can arrive more than once** — an automatic retry after your
    endpoint was slow but actually succeeded, or a manual resend. Your handler must be
    idempotent.

    <Warning>
      **A manual resend does not mint a new delivery id.** It re-queues the same
      delivery row, so the resent request arrives with the **same**
      `X-Taberna-Delivery-Id` as the original. A handler that dedups purely on
      delivery id will therefore silently drop every resend — exactly the deliveries
      a merchant is replaying because something went wrong.
    </Warning>

    **Idempotency has to live in your business state.** Before acting, ask whether the
    state change has already been applied — *is this order already fulfilled?* Look up
    `data.id`, or your own `data.extraContext.purchaseId` /
    `data.metadata.purchaseId`, and no-op if the work is done. That check is correct
    whether the duplicate came from a retry, a resend, or two workers racing.

    `X-Taberna-Delivery-Id` is still worth recording — for correlation with the
    dashboard, and as a cheap short-circuit for automatic retries piling onto an
    expensive handler — but it is **not sufficient on its own**. A handler that
    records the id and then crashes before applying the change has poisoned itself:
    the resend that would have fixed it gets skipped as a duplicate.
  </Accordion>
</AccordionGroup>

### An idempotent handler

Verify, parse, then apply the change against your own state — never gate on the
delivery id.

```javascript theme={null}
async function handle(event, data, deliveryId) {
	// Signature already verified against the raw body by the caller above.
	if (event !== 'order.completed') return;

	// The guard is business state, not the delivery id: a resend carries the
	// SAME delivery id, so id-based dedup would drop it.
	const purchase = await findPurchaseByOrderId(data.id);
	if (!purchase) return;
	if (purchase.fulfilledAt) return; // already applied — nothing to do

	await grantEntitlement(data.extraContext.userId, purchase);

	// Record the delivery id alongside the state change, for correlation with
	// the dashboard. It is an audit trail, not the idempotency key.
	await markFulfilled(purchase.id, { deliveryId });
}
```

Best done in one transaction, or with a unique constraint on `purchase.id` in whatever
table records the fulfilment, so two concurrent deliveries cannot both pass the
`fulfilledAt` check.

## Listing and resending deliveries

Two endpoints let your server monitor and replay without a dashboard session.

### List deliveries

`GET /v1/webhook-deliveries` — requires the `webhooks:read` scope. Newest first.

| Query parameter | Type    | Notes                                       |
| --------------- | ------- | ------------------------------------------- |
| `page`          | integer | Defaults to `1`                             |
| `limit`         | integer | 1 to 100, defaults to `20`                  |
| `status`        | enum    | `pending`, `success`, `failed`, `exhausted` |

```json 200 OK theme={null}
{
  "data": [
    {
      "id": "0197f8a0-5555-7eee-9aaa-7b9c1d3e5f68",
      "event": "order.completed",
      "orderId": "0197f8a0-6666-7fff-9bbb-8c0d2e4f6a79",
      "invoiceId": null,
      "status": "exhausted",
      "attempts": 20,
      "lastAttemptAt": "2026-07-12T18:02:11.000Z",
      "lastResponseStatus": 502,
      "createdAt": "2026-07-09T12:15:04.000Z"
    }
  ],
  "pagination": { "page": 1, "limit": 20, "total": 1, "totalPages": 1 }
}
```

`lastResponseStatus` is the HTTP status your endpoint last returned, or `null` if the
attempt failed at the network level. Exactly one of `orderId` / `invoiceId` is set.

<Note>
  A store with no webhook configured returns an **empty page**, not an error.
</Note>

### Resend a delivery

`POST /v1/webhook-deliveries/{deliveryId}/resend` — requires the `webhooks:write`
scope. Returns `204` with no body.

| Status | Message                                                                    | Cause                                                                     |
| ------ | -------------------------------------------------------------------------- | ------------------------------------------------------------------------- |
| `400`  | `Only failed or exhausted deliveries can be resent — this one is {status}` | A `pending` delivery is already queued; a `success` has nothing to replay |
| `404`  | `Delivery not found`                                                       | Unknown delivery, or one belonging to another store                       |

<Warning>
  `webhooks:write` covers **replay only**. It does not permit changing where a store's
  events are sent — repointing the webhook URL stays a dashboard action, deliberately.
</Warning>

<Note>
  A resend re-queues the existing delivery row. It reuses the original
  `X-Taberna-Delivery-Id`, and it does not reset the attempt counter — an exhausted
  delivery gets **one** further attempt, not a fresh retry schedule. Resend after the
  endpoint is fixed, not before.
</Note>

```javascript theme={null}
// Replay everything that gave up, once your endpoint is healthy again.
const res = await fetch(
	'https://api.taberna.io/v1/webhook-deliveries?status=exhausted&limit=100',
	{ headers: { Authorization: `Bearer ${process.env.TABERNA_API_KEY}` } },
);
const { data } = await res.json();

for (const delivery of data) {
	await fetch(
		`https://api.taberna.io/v1/webhook-deliveries/${delivery.id}/resend`,
		{
			method: 'POST',
			headers: { Authorization: `Bearer ${process.env.TABERNA_API_KEY}` },
		},
	);
}
```

## Current limitations

Stated plainly, because they shape your design:

<Warning>
  **One webhook URL per store.** Every event for the store goes to that single
  endpoint. If you need to fan out to several consumers, receive once and dispatch
  yourself.

  **No per-event subscription filtering.** You cannot subscribe to `order.completed`
  alone; you receive all ten event types and filter on `event` in your handler.
</Warning>

Both are tracked in [Current limitations](/errors-and-limits#current-limitations).
