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 sendsPOST with these headers:
Order payload
Identical shape for everyorder.* event.
data.idis the order’s public short id — the same valuePOST /v1/ordersreturned.data.extraContextis exactly what you passed at creation. This is how you find your user.data.itemsis what was bought, snapshotted at purchase time, so later product edits never rewrite a past order.amountis the line total,unitPrice×quantity.priceAmount,items[].unitPriceanditems[].amountare fiat decimal strings;cryptoAmountandreceivedAmountare crypto smallest-unit strings.paymentMethod,cryptoAmount,receivedAmountandipAddresscome from the payment attempt that triggered the event and arenullon events with no attempt yet —order.created, for instance, wheretransactionsis[]too.
Invoice payload
Identical shape for everyinvoice.* event.
data.metadatais exactly what you passed at creation.paymentisnullon events with no attempt involved —invoice.createdandinvoice.voided, for instance. Note the different nesting from the order payload, where the same facts are flat ondata.
Verifying the signature
The signature is: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.
- Constant-time comparison.
timingSafeEqual,hmac.compare_digest,hash_equals— never==. - 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.
Retries and delivery semantics
What counts as success
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
The backoff schedule
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.
Exhaustion
Exhaustion
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.At-least-once delivery
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.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.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.
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.