Skip to main content
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.

Set the URL

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.

Keep the secret across URL changes

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.

Verify before you rely on it

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.

Events

Ten event names, five per resource.
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.

Request format

Taberna sends POST with these headers:

Order payload

Identical shape for every order.* event.
  • 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 yetorder.created, for instance, where transactions is [] too.

Invoice payload

Identical shape for every invoice.* event.
  • data.metadata is exactly what you passed at creation.
  • payment is null on events with no attempt involvedinvoice.created and invoice.voided, for instance. Note the different nesting from the order payload, where the same facts are flat on data.
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.

Verifying the signature

The signature is:
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.
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().
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

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.
Exponential, capped:
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: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.
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.
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.
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.
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.
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.
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.
200 OK
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.
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.
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.

Current limitations

Stated plainly, because they shape your design:
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 tracked in Current limitations.