Webhooks
Sellf delivers events to customer-configured HTTPS endpoints with HMAC-signed payloads, automatic retry, a per-tenant dead-letter queue, and an admin Replay UI. The model deliberately mirrors what Stripe, Paddle, and Lemonsqueezy do.
Event payload
Section titled “Event payload”Every delivery carries this envelope:
{ "event": "purchase.completed", "timestamp": "2026-05-23T12:34:56.789Z", "data": { /* event-specific */ }}purchase.completed — VAT tax snapshot
Section titled “purchase.completed — VAT tax snapshot”Each product and every bumpProducts[] entry, plus the order, carry a tax
snapshot captured from Stripe at purchase. All amounts are in minor units
(cents/grosze), matching order.amount:
{ "product": { "id": "…", "name": "…", "slug": "…", "price": 100, "currency": "PLN", "net": 10000, "tax": 2300, "gross": 12300, "vatRate": 23, "vatExempt": false, "taxBehavior": "exclusive", "taxabilityReason": "standard_rated" }, "bumpProducts": [ { "id": "…", "net": 5000, "tax": 0, "vatRate": null, "vatExempt": true } ], "order": { "amount": 17300, "netTotal": 15000, "taxTotal": 2300 }}vatRateis the single applied rate, ornullwhen a line has 0 or multiple tax components (Stripe Tax can split jurisdictions — the full breakdown is on/api/v1/paymentsline_items[].tax_breakdown).vatExempt: truemarks a “zwolniony / zw.” line — distinct from a 0% rate. It reflects the seller’s per-product exemption only inlocaltax mode. Under Stripe Tax (stripe_tax) Stripe is the sole authority on taxability, sovatExemptis alwaysfalseand the real reason lives intaxabilityReason(a domestic “zw.” status never suppresses VAT Stripe legitimately charges in another jurisdiction).taxBehaviorisinclusive/exclusive;taxabilityReasoncarries Stripe’s reason instripe_taxmode (reverse_charge,customer_exempt,zero_rated, …).- The tax fields are present only when tax was captured. The order’s
tax_snapshot_status(on/api/v1/payments:none/captured/partial/unavailable) distinguishes “no VAT line” from “not computed”.
Bundles & licenses
Section titled “Bundles & licenses”When the purchased product is a bundle (a product that grants access to several component
products), purchase.completed also carries the granted components and any issued license keys:
{ "product": { "id": "…", "name": "Bundle: …", "slug": "…", "net": 19900, "tax": 0, "…": "…" }, "bundleComponents": [ { "id": "…", "name": "Course …", "slug": "course-…", "price": 149, "currency": "PLN", "icon": "🤖" } ], "licenses": [ { "productId": "…", "token": "<signed-license>", "kid": "…", "jwksUrl": "https://…/api/licenses/jwks?seller=…" } ]}bundleComponents[]lists the component products the buyer gained access to through the bundle. In tax mode 1a the bundle is a single line item carrying its own VAT — components are not separately taxed lines, so they carry basic product detail without a per-line tax snapshot. The field is[]for non-bundle purchases.licenses[]carries one entry per product in the order that has license issuance enabled — the purchased product itself and/or any bundle components. It is omitted when no product in the order issues a license.- ⚠️ Breaking change:
licenses[]replaces the previous singlelicenseobject. A consumer that readdata.licensemust switch todata.licenses[](e.g.data.licenses[0]). - Per-product webhook scoping: a bundle purchase fires for the bundle and every component product id, so an endpoint scoped to a component still receives the event.
invoice.paid — subscription VAT snapshot
Section titled “invoice.paid — subscription VAT snapshot”Recurring subscription charges emit invoice.paid (not purchase.completed). It carries
an order-level VAT snapshot plus the buyer’s faktura details, snapshotted by Stripe onto
each invoice at purchase (so they don’t change if the buyer later edits their profile):
{ "event": "invoice.paid", "invoice": { "stripeInvoiceId": "in_…", "amountPaid": 123.00, "currency": "PLN", "net": 100.00, "tax": 23.00, "vatRate": 23, "taxBehavior": "exclusive", "taxabilityReason": "standard_rated", "taxSnapshotStatus": "captured", "nip": "PL1181697228", "companyName": "Firma Sp. z o.o.", "address": "ul. Przykładowa 123", "city": "Warszawa", "postalCode": "00-000", "country": "PL" }}- ⚠️ Units differ from
purchase.completed.invoice.paidamounts (amountPaid,net,tax) are in MAJOR units (whole currency, e.g.123.00) to matchamountPaid, whereaspurchase.completeduses minor units (cents). An integration consuming both events must scale accordingly. nip/ address fields appear only for B2B (a tax id on the invoice);net/tax/vatRateonly when tax was captured (taxSnapshotStatus: captured).
refund.issued — refund + credit-note VAT
Section titled “refund.issued — refund + credit-note VAT”Fired on every refund (full or partial, from any path). Carries the VAT breakdown of the refunded amount so you can issue a credit note (faktura korygująca):
{ "event": "refund.issued", "payment": { "id": "…", "amount": 12300, "currency": "PLN", "statusBefore": "completed", "statusAfter": "refunded" }, "refund": { "stripeRefundId": "re_…", "amount": 6150, "currency": "PLN", "reason": "requested_by_customer", "status": "succeeded", "isFullRefund": false, "totalRefunded": 6150, "refundedAt": "…", "source": "stripe_webhook", "net": 5000, "tax": 1150, "vatRate": 23, "vatExempt": false, "taxabilityReason": "standard_rated" }}- All amounts are in minor units (cents/grosze), matching
payment.amount— likepurchase.completed, unlikeinvoice.paid. amountis the amount refunded in this event;totalRefundedis the cumulative total.net/tax/vatRateare the refund’s VAT split — present only when the original order had a tax snapshot. Across a sequence of partial refunds the creditedtaxsums exactly to the order’s VAT.vatRateis the order’s effective (blended) rate.vatExempt: truemarks a “zw.” order (legal exemption) — distinct from a 0% rate — andtaxabilityReasoncarries Stripe’s reason under Stripe Tax (reverse_charge,customer_exempt, …). Both are omitted when not applicable. Use them to label the credit note correctly.
Headers
Section titled “Headers”| Header | Notes |
|---|---|
Content-Type |
application/json |
X-Sellf-Event |
Event name, e.g. purchase.completed |
X-Sellf-Signature |
t=<unix_seconds>,v1=<sig> — v1 is HMAC-SHA256(secret, "<t>.<raw_body>") as lowercase hex. The send timestamp t is inside the signature (replay-resistant), and v1= is versioned so the algorithm can rotate. |
X-Sellf-Retry-Attempt |
Present on attempts 2 through max; integer ("2", "3", …) |
X-Sellf-Retry |
"true" on legacy admin Resend (the old /retry endpoint) |
The event time stays in the payload body (timestamp).
Breaking change (v2026.6.4):
X-Sellf-Signatureswitched from a bare body-only hex digest to the timestamped, versionedt=…,v1=…scheme below, and the separate unsignedX-Sellf-Timestampheader was removed (it was not covered by the MAC, so it could be replayed/tampered freely). Update receivers to the verifier below.
Signing verification (Node example)
Section titled “Signing verification (Node example)”import crypto from 'crypto';
// Reject deliveries whose signed timestamp is too old (replay protection).const TOLERANCE_SECONDS = 5 * 60;
function verify(rawBody, signatureHeader, secret) { // Parse "t=<unix>,v1=<sig>" let t = null; let v1 = null; for (const part of signatureHeader.split(',')) { const i = part.indexOf('='); if (i === -1) continue; const key = part.slice(0, i).trim(); const value = part.slice(i + 1).trim(); if (key === 't' && /^\d+$/.test(value)) t = Number(value); else if (key === 'v1') v1 = value; } if (t === null || !v1) return false;
// Reject stale / replayed timestamps. if (Math.abs(Math.floor(Date.now() / 1000) - t) > TOLERANCE_SECONDS) return false;
// Recompute over `${t}.${rawBody}` and constant-time compare. const expected = crypto.createHmac('sha256', secret).update(`${t}.${rawBody}`).digest('hex'); const a = Buffer.from(expected, 'hex'); const b = Buffer.from(v1, 'hex'); return a.length === b.length && crypto.timingSafeEqual(a, b);}Retry policy and DLQ
Section titled “Retry policy and DLQ”When a delivery fails (network error, non-2xx response, SSRF block) the worker retries with exponential backoff before declaring the delivery permanently failed:
| Attempt | Delay before next retry |
|---|---|
| 1 → 2 | 1 min |
| 2 → 3 | 5 min |
| 3 → 4 | 30 min |
| 4 → 5 | 2 hrs |
| 5 → 6 | 12 hrs |
After the 5th failed attempt the delivery enters the dead-letter queue (status permanently_failed). It stays there until an admin clicks Replay in /dashboard/webhooks/deliveries, at which point the attempt counter resets to 0 and the row goes back to pending_retry with next_retry_at = NOW() for the worker to pick up.
Concurrency safety
Section titled “Concurrency safety”pick_due_webhook_deliveries(limit) uses FOR UPDATE SKIP LOCKED plus a 60-second next_retry_at lease, so:
- two concurrent cron invocations never dispatch the same delivery
- a worker that crashes mid-dispatch automatically releases its rows after 60 s
State machine
Section titled “State machine”[INSERT after first dispatch] → success (dispatch ok) → pending_retry (fail, retries remain) → permanently_failed (fail, no retries — only when max_attempts=1)
pending_retry --(worker, ok)--> successpending_retry --(worker, fail, <max)--> pending_retry [attempt_count++, exp backoff]pending_retry --(worker, fail, >=max)--> permanently_failed [failed_permanently_at=now]permanently_failed --(admin Replay)--> pending_retry [attempt_count=0, next_retry_at=now]pending_retry --(admin Cancel)--> permanently_failedpending_retry --(admin Force now)--> pending_retry [next_retry_at=now]* --(admin Archive)--> archivedAdmin actions per status
Section titled “Admin actions per status”| Status | Actions |
|---|---|
success |
Resend |
pending_retry |
Retry now, Cancel |
permanently_failed |
Replay, Archive |
failed (legacy) |
Retry, Archive |
retried / archived |
view only |
failed and retried are pre-DLQ legacy statuses. New deliveries never land in failed — a failed first attempt now produces pending_retry with the retry already scheduled.
REST API
Section titled “REST API”All endpoints under /api/v1/webhooks/logs/[id]/* require the webhooks:write scope.
| Method & Path | Effect |
|---|---|
POST /retry |
Legacy. Creates a new log row and marks the original retried. Use only for old failed rows. |
POST /replay |
DLQ Replay. Only valid for permanently_failed; resets attempt_count to 0 and re-queues for immediate retry. |
POST /force-retry |
Pulls a pending_retry row forward to next_retry_at = NOW(). |
POST /cancel |
Flips a pending_retry row to permanently_failed. |
POST /archive |
Soft-archives any row. |
Listing logs supports filters status=pending_retry|permanently_failed|all_failed in addition to the existing success|failed|archived|retried|all values. all_failed is the union failed + pending_retry + permanently_failed.
Operator setup
Section titled “Operator setup”The worker is exposed at /api/cron?job=webhook-deliveries-retry. Schedule it to fire every minute from any cron source you trust (PM2, system cron, an external scheduler) with the shared CRON_SECRET bearer token:
* * * * * curl -fsS -H "Authorization: Bearer $CRON_SECRET" "$SELLF_URL/api/cron?job=webhook-deliveries-retry" > /dev/nullQueue driver selection
Section titled “Queue driver selection”The queue lives behind a small interface so the storage backend can swap without touching WebhookService or the UI:
WEBHOOK_QUEUE_DRIVER=supabase # defaultWEBHOOK_QUEUE_DRIVER=sqs # AWS SQS stub (throws NotImplemented)A future SQS implementation would replace pickDue with ReceiveMessage, markFailed with ChangeMessageVisibility, and markPermanentlyFailed with SendMessage to a configured DLQ queue. webhook_logs would remain the audit log of attempts in either case.