Reference
Webhooks.
Push notifications for cancel-flow outcomes, recovery campaign closes, and win-backs. HMAC-signed payloads (same shape as Stripe webhooks), automatic retry, and a per-endpoint delivery log + “Send test” button in the dashboard. Configure endpoints under Settings → Outbound webhooks.
Before you start
- A workspace with Stripe connected— webhooks fire off the same recovery and cancel-flow events the rest of Backstop runs on, so there's nothing to fire until your account is syncing.
- Owner or admin role. Webhook endpoints are an external delivery channel, so only owners and admins can add, edit, or delete them.
- A publicly reachable HTTPS URL on your side that accepts a
POSTwith a JSON body. The dashboard rejects any URL that isn'thttps://.
Step 1 — Add an endpoint
- Open Settings → Outbound webhooks in the dashboard.
- Click Add endpoint and give it a name (for your own reference), the HTTPS URL Backstop should POST to, and tick the event types you want. Leave every box unticked to receive all event types — an empty subscription list means “subscribe to everything.”
- On save, the endpoint shows its signing secret (a
whsec_…value) exactly once. Copy it now and store it as a server-side secret — you need it to verify signatures (Step 3), and it's not shown in full again.
Event types
Eight event types, grouped by what they're about. Every one is delivered in the same envelope (next section); the per-event fields below all live under the top-level data key.
recovery.opened— a failed-payment campaign was opened (first decline).data:campaign_id,customer_email,amount_cents,currency,decline_code,decline_category,stripe_invoice_id.recovery.recovered— a failed-payment campaign closed with the payment received.data:campaign_id,customer_email,amount_cents,currency,recovered_via(one ofretry,customer_update,spontaneous, ormanualwhen an operator marked it recovered by hand).recovery.lost— a campaign closed without recovery.data:campaign_id,customer_email,amount_cents,currency, plusreason(auto_close_stale) when we auto-close a stale campaign, orsourcewhen an operator marked it lost from the dashboard.cancel.saved— the customer accepted a save offer.data:session_id,outcome,reason_code,customer_email,monthly_mrr_cents,currency.cancel.lost— the customer canceled despite the flow. Samedatashape ascancel.saved.trial.reminder_sent— a trial-ending reminder email was sent.data:reminder_id,customer_email,subscription_id,trial_end.reactivation.sent— a win-back / reactivation email was sent.data:reactivation_id,customer_email,subscription_id.winback.recorded— a previously-canceled customer re-subscribed.data:winback_id,customer_email,stripe_subscription_id,days_away,amount_cents,currency.
Step 2 — The request we send
Every delivery is a POST with a JSON body and Content-Type: application/json. We send exactly one custom header, X-Backstop-Signature, in the Stripe-style combined format t=<unix>,v1=<hex> — the timestamp and signature live in that one header. The only other header we set is the User-Agent, Backstop-Webhooks/1 (+https://www.trybackstop.com).
POST https://your.app/backstop-webhook
Content-Type: application/json
User-Agent: Backstop-Webhooks/1 (+https://www.trybackstop.com)
X-Backstop-Signature: t=1715890200,v1=5257a869e7...
{
"type": "cancel.saved",
"created_at": "2026-05-03T10:50:00Z",
"data": {
"session_id": "cs_xxx",
"outcome": "saved_discount",
"reason_code": "too_expensive",
"customer_email": "alex@example.com"
}
}The top-level keys are type (the event type), created_at (ISO 8601), and data (the event-specific payload). The workspace is implied by the endpoint you registered, so it is not repeated in the body.
Step 3 — Verify the signature
Every request carries one X-Backstop-Signature header shaped like t=<unix>,v1=<hex>. Split it on the comma to pull out the timestamp (t) and the signature (v1). The signature is HMAC-SHA256 over {t}.{raw body}— the timestamp, a literal dot, then the exact raw request bytes — signed with your endpoint's signing secret (the whsec_… value shown once when you create the endpoint). The v1 value is bare lowercase hex with no sha256= prefix. Always verify before trusting a payload, and reject requests whose timestamp is more than ~5 minutes old to prevent replay attacks.
import crypto from 'node:crypto'
const SECRET = process.env.BACKSTOP_WEBHOOK_SECRET // your whsec_... endpoint secret
app.post('/backstop-webhook', express.raw({ type: 'application/json' }), (req, res) => {
// One header: "t=1715890200,v1=<hex>". Parse the parts out.
const header = req.header('x-backstop-signature') ?? ''
const parts = Object.fromEntries(
header.split(',').map((kv) => kv.split('=')),
) // { t, v1 }
const t = parts.t
const v1 = parts.v1 ?? ''
// Optional but recommended: reject stale timestamps (replay protection).
if (Math.abs(Date.now() / 1000 - Number(t)) > 300) return res.status(401).end()
const expected = crypto
.createHmac('sha256', SECRET)
.update(t + '.' + req.body.toString('utf8')) // raw body, not JSON.parse'd
.digest('hex')
const ok =
v1.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(v1, 'hex'), Buffer.from(expected, 'hex'))
if (!ok) return res.status(401).end()
// ...handle event...
res.status(200).end()
})Delivery, timeouts & failures
Each delivery runs as its own background step with an 8-second request timeout. We treat any 2xx as success and read at most the first 4 KB of your response body for the delivery log — so return 200 quickly and do your real work async. Anything outside 2xx, or a timeout / connection error, is recorded as a failed delivery.
A single event fans out to every enabled endpoint subscribed to that type, and each endpoint is delivered independently — one slow or broken URL doesn't hold up the others.
Auto-disable after repeated failures
After 10 consecutive failures an endpoint is automatically disabled and we stop sending to it. A single success resets the failure count to zero, so a flaky receiver that recovers on its own stays enabled. Once disabled, fix your receiver, then toggle the endpoint back on from Settings → Outbound webhooks — re-enabling clears the failure count and resumes deliveries.
To re-check a fixed endpoint without waiting for a real event, use the Send test button on that page. It bypasses the normal dispatch path and POSTs a synthetic payload — type: "ping" with a data object marked { test: true } — signed exactly like a real event, then shows you the HTTP status it got back and records the attempt in the delivery log.
Delivery log
Each endpoint on Settings → Outbound webhooks shows a Recent deliveries strip: event type, HTTP status, attempt, and the error on any failures, so you can see exactly what we sent and what came back. The endpoint also shows its last success / last failure and its current failure count.
Related
- REST API — pull-based access to the same data.