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.
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.
/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.
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;
}
} 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.
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 acustomer.cart_abandonedrecovery 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, alongsideorder.created/payment.captured). A cart that lapses past itsexpires_atwindow is swept intocart.abandoned— there is no separate cart.expired webhook.
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:
{
"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.
{
"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, anddata. - Amounts: all money fields are integer minor units, for example cents for USD.
- Customer identity: every
subscription.*event, and everypayment.*event that resolves an order, carries a nestedcustomerobject so you can map the delivery to your own user without a follow-upGET /customers/{id}. Readcustomer.externalIdfor the id you set yourself —externalCustomerIdis a separate per-connection mapping and isnullfor most integrations. Payment events also carrycustomerIdand, for subscription-driven charges,subscriptionId. Treatcustomeras 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.
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, anddata.workspace(for platform-billing events). Onorder.created, the order includesclientContextwhen attribution was captured at checkout — UTMs, click ids (gclid/fbclid/msclkid/ttclid),gaClientId/gaSessionId,fbp/fbc,landingPage,referrer,consent, and the server-stampedipAddress/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, andshipping_tax.provider_connection.unhealthy. For examplepayment.disputedsendsdata.paymentId(notdata.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.
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 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}updatesurl,enabledEvents, orisActive. 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-endpointsreturns both active and paused endpoints; deleted are excluded. -
GET /api/v1/webhook-endpoints/coveragereports 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 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.
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:
{
"data": {
"id": "wep_01HZX...",
"enabledEvents": ["payment.captured", "subscription.updated"],
"retiredEventsIgnored": ["cart.expired"]
}
} 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.