Webhook events
The normalized webhook event union, the exact provider events each one maps from, the signature schemes, and how provider detection works.
A complete reference of what parseWebhookEvent produces for every provider event it recognizes. For
verification, the raw-body rule, and idempotency, see Webhooks.
The normalized union
type WebhookEventType =
| 'subscription.created'
| 'subscription.updated'
| 'subscription.canceled'
| 'order.paid'
| 'checkout.completed'
| 'license.issued'
| 'unknown';
Every parsed event also carries providerType — the provider’s original event string — raw, the
untouched envelope, idempotencyKey (always set), and, where the provider supplies them,
subscriptionChange and createdAt. Anything not listed below comes back as unknown;
parseWebhookEvent never throws on an unrecognized type.
The finer-grained transition a provider named lives in the separate
subscriptionChange field, not in type.
The matrix
subscription.created
| Provider | Provider event |
|---|---|
| Polar | subscription.created |
| Lemon Squeezy | subscription_created |
| Stripe | customer.subscription.created |
| Paddle | subscription.created |
| Dodo Payments | — (none; see below) |
subscription.active, which also fires after recovery from on_hold. Always upsert on subscription
events rather than insert — see the webhook handler guide.
subscription.updated
| Provider | Provider events |
|---|---|
| Polar | subscription.active, subscription.canceled, subscription.cycled, subscription.past_due, subscription.paused, subscription.resumed, subscription.uncanceled, subscription.updated |
| Lemon Squeezy | subscription_cancelled, subscription_paused, subscription_resumed, subscription_unpaused, subscription_updated |
| Stripe | customer.subscription.paused, customer.subscription.pending_update_applied, customer.subscription.pending_update_expired, customer.subscription.resumed, customer.subscription.updated |
| Paddle | subscription.activated, subscription.imported, subscription.past_due, subscription.paused, subscription.resumed, subscription.trialing, subscription.updated |
| Dodo Payments | subscription.active, subscription.on_hold, subscription.plan_changed, subscription.renewed, subscription.update_payment_method, subscription.updated |
subscription.paused is deliberately left unknown. Dodo has no paused status,
no pause or resume endpoint, and reports pause: false in the
capability matrix — there is no state the event could map onto.
subscription.canceled (terminal)
| Provider | Provider events |
|---|---|
| Polar | subscription.revoked |
| Lemon Squeezy | subscription_expired |
| Stripe | customer.subscription.deleted |
| Paddle | subscription.canceled |
| Dodo Payments | subscription.cancelled, subscription.expired, subscription.failed |
subscription.updated, not this event. Polar emits
subscription.canceled when a cancellation is merely scheduled and subscription.revoked when it
takes effect; Paddle’s identically named subscription.canceled fires only on the effective date; Lemon
Squeezy uses subscription_cancelled for the grace period and subscription_expired at the end. All
three schedules normalize to subscription.updated with cancelAtPeriodEnd: true — read
event.subscription.cancelAtPeriodEnd, never providerType.
order.paid
| Provider | Provider events |
|---|---|
| Polar | order.paid |
| Lemon Squeezy | order_created, subscription_payment_success* |
| Stripe | invoice.paid |
| Paddle | transaction.completed |
| Dodo Payments | payment.succeeded |
billing_reason is initial. That invoice is the first subscription
payment, which Lemon Squeezy also raises as order_created: the same money under two IDs. The initial
invoice comes back as unknown, so order.paid fires exactly once per payment and the surviving ID is
the order ID orders.list returns.
On Stripe, order.paid maps from invoice.paid — and a one-off payment is only invoiced on
request. checkouts.create sets invoice_creation[enabled] on every payment-mode session it
creates, so those purchases do emit the event. A session created outside this SDK, or before this
version, produces no invoice at all: its payment arrives as charge.succeeded /
payment_intent.succeeded, both unknown here. Subscription payments always invoice.
On Paddle, transaction.paid is deliberately left unmapped so transaction.completed is the
single “money received” signal and a payment never fires twice.
The attached Order is mapped exactly as the orders namespace maps it — status, createdAt, and
refundStatus included — and order.id is the ID client.orders.get and client.orders.getInvoiceUrl
take. A delivery you missed can be recovered with orders.list; see
Orders.
checkout.completed
| Provider | Provider events |
|---|---|
| Polar | checkout.updated — only when the mapped Checkout.status is complete |
| Lemon Squeezy | — (checkouts carry no lifecycle status) |
| Stripe | checkout.session.completed, checkout.session.async_payment_succeeded — only when the mapped Checkout.status is complete |
| Paddle | — (use transaction.completed → order.paid) |
| Dodo Payments | — (use payment.succeeded → order.paid) |
payment_status: 'unpaid', which maps to Checkout.status: 'open' — so the event comes back as
unknown (with checkout attached) and the settlement arrives later as
checkout.session.async_payment_succeeded. Polar behaves the same way for a checkout.updated that
isn’t yet succeeded.
license.issued
| Provider | Provider event |
|---|---|
| Polar | benefit_grant.created — only when data.benefit.type is license_keys |
| Lemon Squeezy | license_key_created |
| Stripe | — (no license-key API) |
| Paddle | — (no license-key API) |
| Dodo Payments | license_key.created |
unknown. The grant carries only display_key, a masked
form, so event.licenseKey is undefined on Polar. Use event.licenseKeyId — always set — with
client.licenseKeys.get.
Issuance is the only normalized license event. Revocation and updates are not mapped — the three providers disagree too much to normalize; License keys explains why and what to do instead.
Subscription changes
type is the coarse classification that stays honest on all five providers. subscriptionChange is the
finer one: which lifecycle transition the provider’s event string named, or undefined where it named
none. It is only ever set on subscription.updated.
type SubscriptionChange = 'cancel_scheduled' | 'past_due' | 'paused' | 'resumed' | 'uncanceled';
| Change | Polar | Lemon Squeezy | Stripe | Paddle | Dodo Payments |
|---|---|---|---|---|---|
cancel_scheduled |
subscription.canceled |
subscription_cancelled |
— | — | — |
past_due |
subscription.past_due |
— | — | subscription.past_due |
subscription.on_hold |
paused |
subscription.paused |
subscription_paused |
customer.subscription.paused¹ |
subscription.paused |
— |
resumed |
subscription.resumed |
subscription_unpaused |
customer.subscription.resumed¹ |
subscription.resumed |
— |
uncanceled |
subscription.uncanceled |
subscription_resumed² |
— | — | — |
customer.subscription.paused / .resumed fire only for Stripe’s raw status=paused — a trial that
ended without a payment method — never for pause_collection, which is what
client.subscriptions.pause() uses. An SDK-initiated Stripe pause arrives as a bare
subscription.updated with subscriptionChange left undefined; read
event.subscription.status === 'paused' instead.
² Lemon Squeezy’s subscription_resumed means un-cancel, not un-pause. The reversal of
subscription_paused is subscription_unpaused (→ resumed). Getting these backwards fires a “welcome
back from your pause” flow at customers who un-cancelled.
The dashes are the point: coverage is genuinely uneven, and subscriptionChange reports that honestly
instead of inventing a transition. It is derived only from the provider’s event string, never from
payload state — cancelAtPeriodEnd stays true for the rest of the period, so a payload-derived
transition would re-announce the same “cancellation scheduled” on every later event. Every event still
carries the full Subscription, so the current state is always available regardless of whether a
transition was named.
Which model each event carries
WebhookEvent is a discriminated union on type, so this table is enforced by the type system:
narrow on event.type and the payload below is a required field — no optional-field guessing, no !.
| Normalized event | Populated field |
|---|---|
subscription.created |
subscription |
subscription.updated |
subscription, plus subscriptionChange where the provider named a transition |
subscription.canceled |
subscription |
order.paid |
order — with status, createdAt, and refundStatus |
checkout.completed |
checkout |
license.issued |
licenseKeyId always; licenseKey on Lemon Squeezy and Dodo Payments, never on Polar |
unknown |
checkout for a not-yet-complete Polar/Stripe checkout event; otherwise nothing but raw |
Independent of the type, every event carries type, providerType, raw and idempotencyKey, plus
createdAt on every provider except Lemon Squeezy — see
Idempotency for where the key comes from and what it does and
does not protect against.
| Field | Set on |
|---|---|
idempotencyKey |
every event, always — <provider>:<id>, or <provider>:sha256:<hex> of the raw body when the provider publishes no id (always Lemon Squeezy) |
createdAt |
every event except Lemon Squeezy — from the envelope’s timestamp (Polar, Dodo Payments), created (Stripe) or occurred_at (Paddle), never the webhook-timestamp header |
subscriptionChange |
subscription.updated only, and only where the provider named a transition |
Signature schemes
| Provider | Header(s) | Signed payload | Key derivation | Digest | Tolerance |
|---|---|---|---|---|---|
| Polar | webhook-id, webhook-timestamp, webhook-signature |
{id}.{ts}.{body} |
secret verbatim, including whsec_ |
base64, any v1, part |
300 s |
| Dodo Payments | same (Standard Webhooks) | {id}.{ts}.{body} |
strip whsec_, then base64-decode into key bytes |
base64, any v1, part |
300 s |
| Stripe | stripe-signature |
{t}.{body} |
secret verbatim, including whsec_ |
lowercase hex, any v1= (v0= ignored) |
300 s |
| Paddle | paddle-signature (ts=…;h1=…) |
{ts}:{body} |
secret verbatim | lowercase hex, any h1= |
300 s |
| Lemon Squeezy | x-signature |
body only (no timestamp) | secret verbatim | hex, compared case-insensitively | — |
verifyWebhook returns false (never throws) for a missing header, a stale timestamp, a malformed
secret, or a mismatched signature, and all comparisons are constant-time.
Provider detection
detectWebhookProvider inspects headers, then the body when it has to:
| Signal | Result |
|---|---|
stripe-signature present |
stripe |
paddle-signature present |
paddle |
x-signature present |
lemon-squeezy |
webhook-id + webhook-signature, body has business_id |
dodo-payments |
webhook-id + webhook-signature, body has no business_id |
polar |
| anything else (or unparseable body) | undefined |