Skip to main content
Subscribe to events instead of polling. Webhook endpoints are managed in the dashboard (Developers → Webhooks) — there is no public API for creating them. Add a URL, choose your events, copy the signing secret, and Duro delivers signed, retried POSTs for the whole subscription lifecycle to your server. The mechanics are in the webhooks chapter; this is the reference for the payloads you’ll receive.

The delivery envelope

Every delivery is a POST with this body:
{
  "id": "evt_01HKBZ4PQ9Z3K7T8V9X2Y1B5C6",
  "type": "subscription_payment_success",
  "createdAt": "2026-06-28T09:00:00.000Z",
  "data": {
    "subscriptionId": "sub_•••",
    "invoiceId": "inv_•••",
    "amount": 500000
  }
}
And these headers:
HeaderValue
Duro-Signaturet=<unix>,v1=<hex hmac-sha256>
Duro-Event-Idthe event id (dedupe on this)
Duro-Event-Typethe event type

The catalog

Event names are stable and canonical. Subscribe to exactly the ones you list when creating the endpoint (an empty list means all).
EventWhen
subscription_createdA subscription is created (incl. via checkout).
subscription_activatedTrial converted, or first charge succeeded.
subscription_updatedA mutable field changed (e.g. cancel-at-period-end set).
subscription_plan_changedPlan changed (immediate or scheduled).
subscription_paused / subscription_resumedPaused or resumed.
subscription_past_dueA renewal failed; dunning has begun.
subscription_recoveredDunning won; the subscription is active again.
subscription_unpaidDunning exhausted; written off.
subscription_expiredReached maxCycles.
subscription_cancelledCancelled (immediately or at period end).
EventWhen
subscription_payment_successA charge succeeded (renewal or checkout).
subscription_payment_failedA charge failed.
subscription_payment_recoveredA previously-failed charge was recovered.
subscription_payment_refundedAn invoice was refunded.
subscription_payment_action_requiredThe customer must update their card.
invoice_createdA renewal invoice was generated.

Verifying a signature

Recompute the HMAC and compare in constant time. The signed payload is ${t}.${rawBody} — use the raw request body, not a re-serialised object.
import { createHmac, timingSafeEqual } from "node:crypto";

function verify(rawBody, header, secret, toleranceSec = 300) {
  const parts = Object.fromEntries(header.split(",").map((kv) => kv.split("=")));
  const t = Number(parts.t);
  if (Math.abs(Date.now() / 1000 - t) > toleranceSec) return false; // replay window
  const expected = createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex");
  const a = Buffer.from(expected);
  const b = Buffer.from(parts.v1);
  return a.length === b.length && timingSafeEqual(a, b);
}
Always verify against the raw body bytes. If your framework parses JSON before you can read the raw body, configure it to expose the raw payload (Duro does this internally with a raw-body saver for exactly this reason).

Retries & replay

A non-2xx response is retried on an exponential backoff (1m, 5m, 30m, 2h, 6h, 24h, six attempts). Make your handler idempotent — dedupe on Duro-Event-Id — because a slow 200 can still be retried. You can also replay any delivery from the dashboard’s delivery inspector.