Event delivery

Use Webhooks as Your Source of Truth

postMessage events make the browser feel instant. Signed webhooks tell your backend what actually happened, even if the buyer closes the tab.

Create an endpoint

Register an HTTPS endpoint and choose the events your backend can process. Store the returned signingSecret securely; it is shown once.

Create endpoint
curl -X POST https://api.usethrottle.dev/api/v1/webhook-endpoints \
  -H "x-api-key: $THROTTLE_API_KEY" \
  -H "content-type: application/json" \
  -d '{
    "url": "https://shop.example.com/api/throttle/webhook",
    "enabledEvents": ["order.created", "payment.captured", "payment.failed"]
  }'

enabledEvents must be exact event-type strings from the catalog — Throttle uses payment.captured and subscription.cancelled, not Stripe-style payment.succeeded or subscription.expired. Fetch the full list from GET /api/v1/event-types. An unknown name is rejected with 400; the error’s details[].allowedValues enumerates every accepted event type.

Use /webhook-endpoints, not the legacy /webhooks
/api/v1/webhook-endpoints is the canonical, signed outbound webhook system documented on this page (enabledEvents, a whsec_ signing secret, X-Throttle-Signature, plus /deliveries, /replay, and /test). The older /api/v1/webhooks family (events, no signed delivery) is deprecated and no longer delivers events — its delivery worker has been decommissioned. Responses carry Deprecation + Sunset: 2026-10-11 headers; the routes will be removed after 2026-10-11. Migrate any integration still pointing at it to /api/v1/webhook-endpoints.

Verify signatures

Each delivery includes X-Throttle-Signature (format t=<unix_seconds>,v1=<hex>). Compute HMAC-SHA256 over <timestamp>.<raw body>. The verifier below also rejects deliveries whose timestamp falls outside a tolerance window (default 300s) so a captured delivery cannot be replayed indefinitely, and guards against a malformed v1 so a bad header returns false instead of throwing in your handler.

Node verifier
import { createHmac, timingSafeEqual } from 'node:crypto';

// Verify a Throttle webhook signature (header: "t=<unix_seconds>,v1=<hex>").
// Rejects (returns false) when the signature is invalid OR the timestamp is
// outside the tolerance window — a replayed delivery is not accepted forever.
export function verifyThrottleWebhook(
  rawBody: string,
  header: string,
  secret: string,
  toleranceSeconds = 300,
): boolean {
  if (!header || typeof header !== 'string') return false;

  const parts = header.split(',').reduce((acc, kv) => {
    const idx = kv.indexOf('=');
    if (idx > 0) acc[kv.slice(0, idx).trim()] = kv.slice(idx + 1).trim();
    return acc;
  }, {} as Record<string, string>);

  const ts = parts.t ? Number.parseInt(parts.t, 10) : NaN;
  const v1 = parts.v1;
  // Guard the timestamp and require v1 to be hex — a malformed v1 would make a
  // short Buffer and timingSafeEqual would throw a RangeError, crashing your
  // handler (a malformed-header DoS).
  if (!Number.isFinite(ts) || !v1 || !/^[0-9a-f]+$/i.test(v1)) return false;

  // Reject deliveries whose timestamp is too old (or too far in the future) to
  // stop indefinite replay.
  const now = Math.floor(Date.now() / 1000);
  if (Math.abs(now - ts) > toleranceSeconds) return false;

  const expected = createHmac('sha256', secret)
    .update(`${ts}.${rawBody}`)
    .digest('hex');

  // Equal-length hex strings only, then a constant-time compare.
  if (expected.length !== v1.length) return false;
  try {
    return timingSafeEqual(Buffer.from(expected, 'hex'), Buffer.from(v1, 'hex'));
  } catch {
    return false;
  }
}
Use the raw body
Signature checks fail if your framework parses and reserializes JSON before verification. Capture the raw request body first, then parse JSON after the signature passes.

Process idempotently

Webhook delivery is at-least-once. Store each event id before applying side effects so retries do not double-fulfill, double-email, or double-book revenue.

Deduping on event id covers retries. It does not cover a genuine repeat: order.created fires exactly once per order, but every other order lifecycle event is at-least-once by design, and a repeat carries a new event id. Moving an order backwards is a supported merchant action, so fulfilled → processing → fulfilled sends order.fulfilled twice. That is deliberate — a merchant who rewinds and re-fulfils has usually re-shipped, and suppressing the second event would be the one case where a shipping notification must not go missing. Make handlers that must not act twice idempotent on the entity and target state, not on the event id alone.

Digital fulfillment can be automatic
If a paid order has line items marked fulfillmentType: 'digital' or fulfillmentType: 'access_grant', Throttle creates and completes those fulfillment rows after the order enters processing. Listen for fulfillment.completed and order.fulfilled in addition to payment events when you need to mirror delivery state.

Recommended fields

  • event_id unique identifier from the delivery envelope.
  • event_type for routing and debugging.
  • event_version from the delivery envelope for payload migrations.
  • environment_id from the delivery envelope for environment-scoped processing.
  • processed_at timestamp after side effects finish.
  • payload raw JSON for audit and replay.

Event families

  • Cart (14 events)
  • Order (9 events)
  • Quote (9 events)
  • Payment (11 events)
  • Fulfillment (6 events)
  • Subscription (14 events)
  • Discount (2 events)
  • Customer (5 events)
  • AR / Net30 (1 events)
  • Shipping & Tax (1 events)
  • Workspace payment methods (5 events)
  • Platform billing charges (2 events)
  • Workspace lifecycle (1 events)

Abandoned carts & cart lifecycle

Carts are authoritative in Throttle. When your storefront creates a native cart (POST /api/v1/carts), Throttle tracks its whole lifecycle and detects abandonment server-side — you don't build your own timer.

  • cart.abandoned — a nightly sweep marks an open cart abandoned once it sits idle past the application's configured threshold (or you can react immediately on delivery). Throttle also sends the buyer a customer.cart_abandoned recovery email when it can resolve an email and the app has recovery enabled — subscribe to the event only if you want to run your own follow-up.
  • cart.converted — fires when a cart becomes an order (the successful-checkout signal, alongside order.created / payment.captured). A cart that lapses past its expires_at window is swept into cart.abandoned — there is no separate cart.expired webhook.
These are cart.* events — there is no checkout.* namespace
Use cart.abandoned and cart.converted — not checkout.abandoned / checkout.expired / checkout.completed (which don't exist). Subscribing to any cart event requires the carts:read scope on your API key — a key without it won't see cart events in the subscribable list at all.

The cart.abandoned delivery carries the full cart so your follow-up needs no extra fetch — buyer, line items, totals, and a recoveryUrl that reopens the checkout:

cart.abandoned delivery
{
  "id": "evt_2b8f...",
  "type": "cart.abandoned",
  "workspaceId": "9c1e...",
  "createdAt": "2026-07-04T18:22:05.000Z",
  "data": {
    "cartId": "cart_9f2a...",
    "sequence": 7,
    "currency": "USD",
    "total": 8900,
    "itemCount": 2,
    "customer": { "id": "cus_51...", "email": "[email protected]", "firstName": "Bea" },
    "lineItems": [
      { "id": "li_1", "name": "Premium Widget", "quantity": 2, "unitPrice": 2999, "total": 5998 }
    ],
    "recoveryUrl": "https://checkout.usethrottle.dev/c/cart_9f2a..."
  }
}

Payload envelope

Every delivery is a JSON object with stable top-level fields and event-specific fields under data. The signed body is capped at 256 KiB; larger deliveries are marked failed before Throttle POSTs them.

Delivery body
{
  "id": "evt_01HZX...",
  "type": "payment.captured",
  "version": "1",
  "workspaceId": "6659b411-9cd7-40ac-9a73-1e7801d89f55",
  "environmentId": "b7e5a40e-8c0f-4d85-a726-2ff2967f4b52",
  "createdAt": "2026-05-06T10:00:00.000Z",
  "data": {
    "paymentId": "pay_01HZX...",
    "orderId": "ord_01HZX...",
    "amount": 12900,
    "capturedAmount": 12900,
    "authorizedAmount": 12900,
    "currency": "USD",
    "customerId": "cus_01HZX...",
    "subscriptionId": "sub_01HZX...",
    "customer": {
      "id": "cus_01HZX...",
      "email": "[email protected]",
      "firstName": "Ada",
      "lastName": "Lovelace",
      "phone": null,
      "externalId": "your-user-42",
      "externalCustomerId": null
    }
  }
}
  • Headers: X-Throttle-Signature, X-Throttle-Event-Id, X-Throttle-Event-Type, Content-Type: application/json.
  • Envelope: id, type, version, workspaceId, environmentId, createdAt, and data.
  • Amounts: all money fields are integer minor units, for example cents for USD.
  • Customer identity: every subscription.* event, and every payment.* event that resolves an order, carries a nested customer object so you can map the delivery to your own user without a follow-up GET /customers/{id}. Read customer.externalId for the id you set yourself — externalCustomerId is a separate per-connection mapping and is null for most integrations. Payment events also carry customerId and, for subscription-driven charges, subscriptionId. Treat customer as optional: it is omitted when the record cannot be resolved.

Event payload reference

Each event below uses the common envelope above. Route by type and read the event fields from data.

How the data object is shaped

data follows two shapes — check the exact fields for each event in the table below rather than assuming one form:

  • Entity lifecycle events nest the full entity under its name: data.order, data.payment, data.subscription, data.customer, data.fulfillment, data.paymentMethod, and data.workspace (for platform-billing events). On order.created, the order includes clientContext when attribution was captured at checkout — UTMs, click ids (gclid / fbclid / msclkid / ttclid), gaClientId / gaSessionId, fbp / fbc, landingPage, referrer, consent, and the server-stamped ipAddress / userAgent. See Track conversions .
  • Cart activity events (cart.*) are intentionally flat and lightweight — data.cartId, data.sequence, plus the changed fields — because they fire at high frequency and do not ship the full cart.
  • Non-entity signals carry flat scalars, not a nested entity: order.sync_failed, order.sync_conflict, payment.disputed, payment.dispute_cleared, subscription.trial_blocked, and shipping_tax.provider_connection.unhealthy. For example payment.disputed sends data.paymentId (not data.payment).

Every order.* event embeds data.order.paymentStatus, one of pending, authorized, captured, partially_paid, partially_refunded, refunded, failed, or voided. partially_paid means money has been captured for this order, but less than the order total. Most commonly a deposit on a quote with deposit-and-balance terms, where the balance has not yet been collected.

Cart

  • cart.created — A native cart is created. { cartId, sequence, applicationId, customerId, currency }
  • cart.updated — Cart metadata, customer, notes, or addresses change. { cartId, sequence, changedFields }
  • cart.item_added — A line item is added to the cart. { cartId, sequence, itemId, name, quantity, unitPrice }
  • cart.item_updated — A line item quantity or price changes. { cartId, sequence, itemId, quantity, unitPrice }
  • cart.item_removed — A line item is removed from the cart. { cartId, sequence, itemId }
  • cart.shipping_selected — A buyer selects a shipping method. { cartId, sequence, methodId, displayName, rateAmount, currency }
  • cart.shipping_cleared — A selected shipping method is cleared. { cartId, sequence }
  • cart.discount_applied — A cart discount code is applied. { cartId, sequence, code, amount, type, discountTotal }
  • cart.discount_removed — A cart discount code is removed. { cartId, sequence }
  • cart.tax_recomputed — Cart tax lines are recomputed or cleared. { cartId, sequence, lineCount }
  • cart.checkout_started — A cart moves into checkout. { cartId, sequence }
  • cart.converted — A cart is converted into an order. { cartId, sequence }
  • cart.abandoned — A cart is marked abandoned. { cartId, sequence, currency, subtotal, taxTotal, shippingTotal, discountTotal, total, itemCount, customer: { id: string | null, email, firstName, lastName, phone, externalId, externalCustomerId } | null, lineItems: [{ id, type, referenceId, name, quantity, unitPrice, subtotal, total, imageUrl }], shippingAddress: CartAddress | null, billingAddress: CartAddress | null, recoveryUrl: string | null }
  • cart.merged — An anonymous cart is merged into a customer cart. { cartId, sequence, fromCartId, toCustomerId }

Order

  • order.created — An order is persisted. { order, fromCart?, sessionId?, customer: { id, email, firstName, lastName, phone, externalId, externalCustomerId } | null }
  • order.updated — An order status changes without reaching a terminal state. { order, status, previousStatus, action, customer: { id, email, firstName, lastName, phone, externalId, externalCustomerId } | null }
  • order.fulfilled — Every deliverable line item on the order has been fulfilled. Fires on entering `fulfilled`, whether the engine derived it from fulfillment rows or a merchant set it by hand. { order, status, previousStatus, action, manual?, actor?, customer: { id, email, firstName, lastName, phone, externalId, externalCustomerId } | null }
  • order.cancelled — An order is cancelled. { order, status, previousStatus, action, reason?, customer: { id, email, firstName, lastName, phone, externalId, externalCustomerId } | null }
  • order.closed — A fulfilled order is closed. { order, status, previousStatus, action, customer: { id, email, firstName, lastName, phone, externalId, externalCustomerId } | null }
  • order.sync_failed — Provider order writeback retries are exhausted. { mappingId, connectionId, externalId, attempts, error }
  • order.sync_conflict — An inbound provider update conflicts with Throttle payment state. { externalStatus, throttleStatus, dimension }
  • return.created — A return / RMA is opened against an order. { returnId, orderId, status, reason?, restock, refundAmount, items }
  • return.updated — A return transitions (approved / rejected / received / completed / cancelled). On completion refundPaymentId is set. { returnId, orderId, status, previousStatus, restock, refundAmount, refundPaymentId? }

Quote

  • quote.requested — A buyer or integration submits a quote request (RFQ). { quote, revision? }
  • quote.proposed — A quote revision is issued and proposed to the buyer. { quote, revision }
  • quote.viewed — The buyer opens the quote link. Throttled to one delivery per revision per 6 hours. { quote, revision }
  • quote.comment_added — A shared (non-internal) comment is added by either party. { quote, revision?, comment }
  • quote.revision_requested — The buyer asks for changes instead of accepting the current proposal. { quote, revision }
  • quote.declined — The buyer (or a rep, recording a decline) declines the quote. { quote, revision?, declinedReason? }
  • quote.accepted — The buyer accepts a quote (or a rep records a phone/email acceptance) and checkout begins. { quote, revision }
  • quote.converted — The accepted quote completes checkout and its order now exists. { quote, orderId }
  • quote.expired — The hourly expiry cron flips a proposed quote past its revision expiry; it can no longer be accepted. { quote, revision: null }

Payment

  • payment.pending — A payment is created and waiting for authorization. { payment }
  • payment.authorized — A payment authorization succeeds. { payment }
  • payment.captured — A payment capture succeeds (full or partial). { paymentId, orderId, amount, capturedAmount?, authorizedAmount?, currency, processor?, processorTransactionId?, metadata?, customerId, subscriptionId?, customer: { id, email, firstName, lastName, phone, externalId, externalCustomerId } | null }
  • payment.failed — A processor reports a terminal payment failure or decline. { paymentId, orderId, amount?, currency?, code?, message?, errorCode?, errorMessage?, customerId, subscriptionId?, customer: { id, email, firstName, lastName, phone, externalId, externalCustomerId } | null }
  • payment.voided — A payment authorization is voided. { payment }
  • payment.refunded — A full refund succeeds. { paymentId, orderId, refundedAmount, paymentAmount, currency, status?, processor?, gr4vyRefundId?, customerId, subscriptionId?, customer: { id, email, firstName, lastName, phone, externalId, externalCustomerId } | null }
  • payment.partially_refunded — A partial refund succeeds. { paymentId, orderId, refundedAmount, paymentAmount, currency, status?, processor?, gr4vyRefundId?, customerId, subscriptionId?, customer: { id, email, firstName, lastName, phone, externalId, externalCustomerId } | null }
  • payment.refund_failed — A refund attempt fails. { paymentId, orderId, amount, currency, gr4vyRefundId?, errorCode?, errorMessage?, customerId, subscriptionId?, customer: { id, email, firstName, lastName, phone, externalId, externalCustomerId } | null }
  • payment.vaulted — A checkout stores a reusable payment method. { customerId, paymentMethodId, gr4vyBuyerId, processor, checkoutSessionId, recurring? }
  • payment.disputed — A payment is marked disputed — a Net30 payment flagged in the dashboard, or an ingested Gr4vy card chargeback. { paymentId, reason, openedAt }
  • payment.dispute_cleared — A payment dispute is cleared — a Net30 dispute cleared in the dashboard, or a Gr4vy chargeback won/reversed. { paymentId }

Fulfillment

  • fulfillment.created — A fulfillment is created for an order. { fulfillment, order: { id } }
  • fulfillment.completed — A fulfillment is completed. { fulfillment, order: { id } }
  • fulfillment.cancelled — A fulfillment is cancelled. { fulfillment, order: { id } }
  • fulfillment.shipment.shipped — A shipment fulfillment receives its first tracking number. { fulfillment, shipment, order: { id } }
  • fulfillment.shipment.delivered — A shipment fulfillment is marked completed. { fulfillment, order: { id } }
  • fulfillment.digital.delivered — A digital fulfillment is marked completed (download/access ready). { fulfillment, digitalDelivery?, order: { id } }

Subscription

  • subscription.created — A subscription is created. { subscription, customer: { id, email, firstName, lastName, phone, externalId, externalCustomerId } | null }
  • subscription.activated — A trialing subscription becomes active. { subscription, customer: { id, email, firstName, lastName, phone, externalId, externalCustomerId } | null }
  • subscription.updated — Subscription plan, metadata, or cancel-at-period-end changes. { subscription, cancelAtPeriodEnd?, customer: { id, email, firstName, lastName, phone, externalId, externalCustomerId } | null }
  • subscription.plan_changed — A subscription plan change is applied immediately (upgrade). { subscription, previous, customer: { id, email, firstName, lastName, phone, externalId, externalCustomerId } | null }
  • subscription.plan_change_scheduled — A downgrade is scheduled for the next interval. { subscription, pending, effectiveAt, customer: { id, email, firstName, lastName, phone, externalId, externalCustomerId } | null }
  • subscription.paused — A subscription is paused. { subscription, customer: { id, email, firstName, lastName, phone, externalId, externalCustomerId } | null }
  • subscription.resumed — A paused subscription is resumed. { subscription, customer: { id, email, firstName, lastName, phone, externalId, externalCustomerId } | null }
  • subscription.cancelled — A subscription is cancelled. { subscription, reason?, atPeriodEnd?, lastError?, customer: { id, email, firstName, lastName, phone, externalId, externalCustomerId } | null }
  • subscription.renewed — A renewal succeeds. { subscription, payment?, order?, freeRenewal?, customer: { id, email, firstName, lastName, phone, externalId, externalCustomerId } | null }
  • subscription.past_due — The first renewal failure moves a subscription to past_due. { subscription, customer: { id, email, firstName, lastName, phone, externalId, externalCustomerId } | null }
  • subscription.payment_failed — A renewal payment attempt fails. { subscription, attempt, nextRetryAt, lastError, customer: { id, email, firstName, lastName, phone, externalId, externalCustomerId } | null }
  • subscription.backup_pm_used — A renewal's primary card declined and the buyer's backup card was charged instead. { subscription, payment?, backupPaymentMethod: { cardLastFour?, cardBrand? }, customer: { id, email, firstName, lastName, phone, externalId, externalCustomerId } | null }
  • subscription.create_failed — Auto-create from a recurring checkout intent fails after vaulting. { checkoutSessionId, customerId, paymentMethodId, gr4vyBuyerId, recurring, reason, customer: { id, email, firstName, lastName, phone, externalId, externalCustomerId } | null }
  • subscription.trial_blocked — Trial-fraud protection downgrades a requested trial. { subscriptionId, customerId, paymentMethodId, reason, requestedTrialDays, customer: { id, email, firstName, lastName, phone, externalId, externalCustomerId } | null }

Discount

  • discount.applied — A discount is applied to a cart. { cart, code, cartId }
  • discount.removed — A discount is removed from a cart. { cart, cartId }

Customer

  • customer.created — A customer is created. { customer }
  • customer.updated — A customer profile is updated. { customer }
  • customer.deleted — A customer is deleted. { customer }
  • customer.payment_method_added — A customer payment method is added. { paymentMethod }
  • customer.payment_method_removed — A customer payment method is removed. { paymentMethod }

AR / Net30

  • invoice.past_due — A Net30 invoice passes a dunning tick. { paymentId, orderId, invoiceNumber, daysPastDue, tickFired, dueDate, totalAmount, currency }

Shipping & Tax

  • shipping_tax.provider_connection.unhealthy — A shipping or tax provider connection auto-disables after repeated failures (consecutive_failures reaches threshold). { connection_id, axis, provider, environment, environment_id, application_id, workspace_id, consecutive_failures, last_error_reason, last_error_detail, last_error_at, fallback_policy }

Workspace payment methods

  • workspace.payment_method.added — Internal-only — fired when a platform-billing payment method is attached to a workspace. { workspace, paymentMethod }
  • workspace.payment_method.removed — Internal-only — fired when a platform-billing payment method is hard-deleted from the workspace. { workspace, paymentMethod }
  • workspace.payment_method.default_changed — Internal-only — fired when the workspace primary (default) payment method changes. { workspace, paymentMethod, previousPaymentMethod? }
  • workspace.payment_method.backup_set — Internal-only — fired when the workspace backup payment method is set or cleared. { workspace, paymentMethod }
  • workspace.payment_method.backup_used — Internal-only — fired when the renewal or dunning charge succeeded on the backup PM after the primary failed. { workspace, primaryPaymentMethod, backupPaymentMethod, charge }

Platform billing charges

  • workspace.platform_charge.succeeded — Internal-only — fired after a successful platform-billing charge (initial subscribe or renewal). { workspace, invoice, charge }
  • workspace.platform_charge.failed — Internal-only — fired after a platform-billing charge fails (initial or renewal). { workspace, charge, error }

Workspace lifecycle

  • workspace.trial_expired — Internal-only — fired by trial-expiry-runner when a workspace trial lapses without conversion. { workspace, expiredAt }

Event scope reference

Subscribing to an event requires holding the matching read scope. When you create or update a webhook endpoint (or register an extension), every event in the requested list is checked against the registering caller's scopes; any event you are not allowed to read returns 403 with error.code = "insufficient_scopes" naming the missing scope. See the API key scopes reference for the full scope catalog and grant rules.

Gated at registration, not per delivery
The scope check happens once, when the subscription is created or edited — webhook endpoints are not bound to a single API key, so deliveries are not re-checked per event. Grant the read scope for every family you intend to subscribe to before registering.

The full mapping is available as JSON at GET /api/v1/event-types — each entry is { type, readScope, trigger, dataShape, requiredDataKeys } , so a build step can check its handlers against the live catalogue without scraping this page. Payload shapes for every event are also typed in the @usethrottle/webhook-types package: import the ThrottleEvent discriminated union of ThrottleEventEnvelope variants and narrow on type to get the exact data shape per event.

billing:read

  • workspace.payment_method.added
  • workspace.payment_method.backup_set
  • workspace.payment_method.backup_used
  • workspace.payment_method.default_changed
  • workspace.payment_method.removed
  • workspace.platform_charge.failed
  • workspace.platform_charge.succeeded
  • workspace.trial_expired

carts:read

  • cart.abandoned
  • cart.checkout_started
  • cart.converted
  • cart.created
  • cart.discount_applied
  • cart.discount_removed
  • cart.item_added
  • cart.item_removed
  • cart.item_updated
  • cart.merged
  • cart.shipping_cleared
  • cart.shipping_selected
  • cart.tax_recomputed
  • cart.updated

customers:read

  • customer.created
  • customer.deleted
  • customer.payment_method_added
  • customer.payment_method_removed
  • customer.updated

discounts:read

  • discount.applied
  • discount.removed

fulfillment_digital:read

  • fulfillment.digital.delivered

fulfillments:read

  • fulfillment.cancelled
  • fulfillment.completed
  • fulfillment.created
  • fulfillment.shipment.delivered
  • fulfillment.shipment.shipped

invoices:read

  • invoice.past_due

order_returns:read

  • return.created
  • return.updated

orders:read

  • order.cancelled
  • order.closed
  • order.created
  • order.fulfilled
  • order.sync_conflict
  • order.sync_failed
  • order.updated

payment_disputes:read

  • payment.dispute_cleared
  • payment.disputed

payment_refunds:read

  • payment.partially_refunded
  • payment.refund_failed
  • payment.refunded

payments:read

  • payment.authorized
  • payment.captured
  • payment.failed
  • payment.pending
  • payment.vaulted
  • payment.voided

quotes:read

  • quote.accepted
  • quote.comment_added
  • quote.converted
  • quote.declined
  • quote.expired
  • quote.proposed
  • quote.requested
  • quote.revision_requested
  • quote.viewed

shipping_tax:read

  • shipping_tax.provider_connection.unhealthy

subscriptions:read

  • subscription.activated
  • subscription.backup_pm_used
  • subscription.cancelled
  • subscription.create_failed
  • subscription.created
  • subscription.past_due
  • subscription.paused
  • subscription.payment_failed
  • subscription.plan_change_scheduled
  • subscription.plan_changed
  • subscription.renewed
  • subscription.resumed
  • subscription.trial_blocked
  • subscription.updated

Retries

Non-2xx responses and network failures are retried with delayed backoff and are visible in webhook delivery logs. Design handlers to return 2xx only after the event is durably stored or safely ignored as a duplicate.

The retry curve is fixed at [5m, 15m, 1h, 6h, 24h] (max five attempts). After the final attempt, deliveries are marked dead_letter and stop retrying automatically; an operator can replay them via POST /api/v1/webhook-deliveries/{deliveryId}/replay .

sequenceDiagram
  participant T as Throttle
  participant Q as Delivery Queue
  participant E as Your Endpoint
  participant Op as Operator
  T->>Q: emit event (e.g. payment.captured)
  Q->>E: attempt 1
  E-->>Q: 5xx / timeout
  Note over Q: wait 5m
  Q->>E: attempt 2
  E-->>Q: 5xx / timeout
  Note over Q: wait 15m
  Q->>E: attempt 3
  E-->>Q: 5xx / timeout
  Note over Q: wait 1h
  Q->>E: attempt 4
  E-->>Q: 5xx / timeout
  Note over Q: wait 6h
  Q->>E: attempt 5
  E-->>Q: 5xx / timeout
  Note over Q: wait 24h, max attempts reached
  Q->>Q: mark delivery dead_letter
  Op->>T: POST /api/v1/webhook-deliveries/{deliveryId}/replay
  T->>Q: requeue
  Q->>E: manual retry attempt
  E-->>Q: 2xx
  Q->>Q: mark delivered
Webhook delivery retry timeline with manual replay path.

Manage endpoints

Endpoints have three states: active, paused, and deleted. Pause stops dispatch without losing the subscription; delete is a soft remove that excludes the endpoint from the list and from future dispatch.

  • PATCH /api/v1/webhook-endpoints/{id} updates url, enabledEvents, or isActive. Send { "isActive": false } to pause and { "isActive": true } to resume.
  • DELETE /api/v1/webhook-endpoints/{id} soft-deletes the endpoint. Deleted endpoints cannot be recovered through the API; create a new one if you need to resubscribe.
  • GET /api/v1/webhook-endpoints returns both active and paused endpoints; deleted are excluded.
  • GET /api/v1/webhook-endpoints/coverage reports subscribable event types your application emitted that no active endpoint is subscribed to. A handler written for an event you never subscribed to simply never runs, and the delivery log cannot show that — it only records what was sent, so the gap is indistinguishable from the event never happening. Defaults to a 30-day window; pass ?days= (1–90) to widen it. Paused endpoints do not count as coverage. The same list appears on the webhooks page in the dashboard.
    Coverage response
    {
      "data": {
        "windowDays": 30,
        "unsubscribed": [
          {
            "type": "subscription.paused",
            "count": 12,
            "lastEmittedAt": "2026-07-27T09:24:11.000Z"
          }
        ]
      }
    }
order.completed is now order.fulfilled
order.completed was renamed to order.fulfilled when an order's lifecycle status absorbed its fulfillment state — nothing emits the old type any more. Endpoints that were subscribed to it have been migrated to order.fulfilled in place, so no action is needed and no deliveries were lost. The old name is accepted on write and dropped from what gets saved, like any other retired type (below), so an integration that still sends it in an enabledEvents array will not 400 — but it will not receive anything either. Subscribe to order.fulfilled.
Read-modify-write is safe across event retirements
enabledEvents replaces the whole list, so the usual way to add one subscription is to read the endpoint and send its current array back with the new type appended. If we have retired an event type since your endpoint was created, that stored value is still in the array we hand you — and write requests accept it rather than rejecting the round-trip. Retired types are dropped before anything is saved, and the response lists them under retiredEventsIgnored so the removal is visible rather than silent:
PATCH response
{
  "data": {
    "id": "wep_01HZX...",
    "enabledEvents": ["payment.captured", "subscription.updated"],
    "retiredEventsIgnored": ["cart.expired"]
  }
}
A request whose event list is entirely retired types is rejected with 400 no_deliverable_events — saving it would leave the endpoint subscribed to nothing at all.

Inspect deliveries

GET /api/v1/webhook-deliveries returns recent delivery attempts. Paginate via limit (1–200, default 50) and offset (default 0). Response shape is { data: { items, total, limit, offset, hasMore } } ; keep paging until hasMore is false.

Narrow the list with endpointId, status (one of pending, delivered, failed, dead_letter) and eventType (an exact event name, e.g. payment.failed). Filtering happens server-side, so status=failed returns every failed delivery across the whole result set rather than only those on the page you happened to load.

GET /api/v1/webhook-deliveries/{id} returns one delivery in full: the exact payload envelope that was signed and POSTed, its status, lastStatus/lastError, and a signatureScheme descriptor (header name, the t=…,v1=… format, and the signed {t}.{rawBody} string). Use it to debug signature verification without reverse-engineering the headers from live traffic.

This is the first place to look when something is not happening and nothing is erroring: it separates “never sent” from “sent, and your handler swallowed it”, which have identical symptoms and completely different fixes. It is also the right source for test fixtures — a mock that returns a friendlier shape than production makes tests pass while the integration is broken, so replay a captured payload rather than writing what you think the shape is. See Failures with no error.

Send a test event

POST /api/v1/webhook-endpoints/{id}/test fires a synthetic, signed delivery so you can verify reachability and signature handling. Omit the body to send a default payment.captured sample, or pass { "eventType": "subscription.renewed" } for a realistic per-event stub. To exercise your own handler logic (org lookup, subscription status updates), pass an eventType plus a data object — it is delivered verbatim as the envelope data instead of the built-in stub.