Skip to content
revenue-sdk
Esc
navigateopen⌘Jpreview
On this page

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)
Dodo Payments has no creation event. Its first signal for a new subscription is 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
Dodo Payments’ 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
A scheduled cancellation is 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
* Lemon Squeezy renewals raise no order, which is why the subscription-invoice event is mapped at all — except when its 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.updatedonly when the mapped Checkout.status is complete
Lemon Squeezy — (checkouts carry no lifecycle status)
Stripe checkout.session.completed, checkout.session.async_payment_succeededonly when the mapped Checkout.status is complete
Paddle — (use transaction.completedorder.paid)
Dodo Payments — (use payment.succeededorder.paid)
A completed session can be unpaid. For delayed payment methods Stripe finishes the session with 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.createdonly 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
Polar has no license webhook — a key arrives as a benefit grant, which is why this is the one normalized type derived from the payload rather than the event string; the same event for a Discord, downloadable or meter-credit benefit stays 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²
¹ Stripe’s paused/resumed events are not the pause you issued. 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 statecancelAtPeriodEnd 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

Last updated on August 8, 2026

Was this page helpful?