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.

Event types

  • recovery.recovered — failed-payment campaign closed with payment received.
  • recovery.lost — campaign closed without recovery.
  • recovery.opened — a failed-payment campaign was opened (first decline).
  • cancel.saved — customer accepted a save offer.
  • cancel.lost — customer canceled despite the flow.
  • trial.reminder_sent — a trial-ending reminder email was sent.
  • reactivation.sent — the win-back / reactivation email was sent.
  • winback.recorded — a previously-canceled customer re-subscribed.

Payload shape

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 add is User-Agent: Backstop-Webhooks/1.

POST https://your.app/backstop-webhook
Content-Type: application/json
User-Agent: Backstop-Webhooks/1
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.

Verifying signatures

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()
})

Retries & failures

Each delivery runs as its own background step with an 8-second request timeout. A non-2xx response (or a timeout) is retried once automatically. We treat any 2xxas success and read at most the first 4 KB of your response body for the log — return 200 quickly and do your real work async.

After 10 consecutive failures an endpoint is automatically disabled and we stop sending to it. Fix your receiver, then toggle the endpoint back on from Settings → Outbound webhooks — that 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 fires a sample payload and shows the HTTP status it got back.

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.