> ## 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.
> A merchant can run an entire shop with no code: the dashboard creates products and invoices, and every store can host a storefront at {slug}.taberna.io with its own theme, sections, branding and optional custom domain. Never assume the reader has an API integration.
> Taberna issues no refunds and performs no KYC. A refund is something the merchant sends from their own payout wallet, off-platform.

# Webhook reliability

> Retries, at-least-once delivery, writing a handler that survives a duplicate, and replaying what failed.

Taberna delivers webhooks through a durable outbox and a retrying dispatcher. That makes
delivery **at-least-once**: the same event can arrive more than once, and a failing
endpoint is retried for days rather than dropped. Your handler has to be built for both.

[Webhooks](/webhooks) covers setup, the ten events, the payloads and signature
verification. This page is what happens after the first request.

## What counts as success

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.

## The backoff schedule

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.

## Exhaustion

After the last attempt the delivery is marked **`exhausted`** and is **not retried
automatically**. The store's owners are 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
(owner, admin or manager), or through [the resend endpoint](#resend-a-delivery).

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

## At-least-once delivery

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.

## An idempotent handler

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

```javascript theme={"dark"}
async function handle(event, data, deliveryId) {
	// Signature already verified against the raw body by the caller.
	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.

## 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={"dark"}
{
  "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. A
store with no webhook configured returns an **empty page**, not an error.

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

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

```javascript theme={"dark"}
// 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

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

Both are listed in [What Taberna does not
do](/guides/what-taberna-does-not-do).
