PayHook

Payment succeeded, subscription not active: which event to trust

Which webhook event grants access, keeps it and takes it away in Stripe, Paddle, Lemon Squeezy, Polar and Dodo, and the traps that lock a paying user out.

When a customer pays and your app stays locked, the webhook often arrived and was even verified. The handler waited for the wrong event: one this provider never sends for this flow, one that comes minutes later, or one whose name says something its status does not. Each provider has one event or status to grant access on, one to keep it, and one to take it away, and each has a trap.

The short answer

ProviderGrant access onKeep access whileTake it away onThe trap
Stripecheckout.session.completed with payment_status other than unpaid, or checkout.session.async_payment_succeeded for delayed payment methodsinvoice.paid arrives and the subscription is active or trialingcustomer.subscription.deleted, or a status of canceled or unpaidA subscription set to cancel at the period's end stays active until then
Paddlesubscription.createdsubscription.updated shows active, trialing or past_dueA status of canceled, or the date of a scheduled cancellationA trial sends subscription.trialing instead of subscription.activated
Lemon Squeezysubscription_created, which arrives activeThe status is on_trial, active or cancelled before ends_atsubscription_expiredsubscription_cancelled does not end access: the subscription stays valid until ends_at
Polarsubscription.active, which a trial sends tooThe status is active or trialingsubscription.revokedsubscription.canceled arrives at once, with the status still active
Dodo Paymentssubscription.activesubscription.renewed arrives at each chargesubscription.expired or subscription.cancelledpayment.succeeded comes 2 to 10 minutes after subscription.active, and there is no subscription.created

Stripe

For Checkout, Stripe's fulfillment guide grants on checkout.session.completed, after checking that the session's payment_status is not unpaid. Some payment methods, like bank debits, are not instant: their money arrives later with checkout.session.async_payment_succeeded, so listen to both. For the life of the subscription, Stripe's subscription guide extends access on invoice.paid, once the subscription is active; a trialing subscription can have access too. It revokes access when the status becomes canceled or unpaid, and customer.subscription.deleted comes when the subscription ends.

A customer who cancels at the end of the period sends you customer.subscription.updated with cancel_at_period_end set, while the status stays active: keep access until customer.subscription.deleted. Do not rely on the success page either: Stripe's guide warns that a customer can pay and never reach it.

Paddle

Paddle's provisioning guide grants access on subscription.created, saving the customer and subscription ids against your user, and reacts to everything after it through subscription.updated: renewals, plan changes and status changes all come as updates. By its table, trialing, active and past_due all mean full access, with a banner asking a past_due customer to update the payment method; paused means none or read only, and canceled none. When a cancellation is scheduled, keep access until it takes effect.

A handler that waits for subscription.activated never unlocks a trial: for a trialing price, Paddle sends subscription.trialing in its place.

Lemon Squeezy

A new subscription sends order_created and subscription_created, which arrives already active, then subscription_payment_success for its first invoice. Grant on subscription_created. The order carries no subscription id, so link the two through the subscription's order_id.

The trap is the cancellation: a cancelled subscription stays valid until ends_at, and access ends with subscription_expired. A failed renewal makes it past_due, Lemon Squeezy retries the payment for two weeks, and a retry that succeeds makes it active again. More on Lemon Squeezy's webhooks.

Polar

Grant on subscription.active. In our sandbox capture, a trial sent it too, with the status trialing. Polar's own trap is its cancellation sequence: by default a cancellation applies at the end of the period, so subscription.updated and subscription.canceled arrive at once while the status stays active, with cancel_at_period_end set. Access ends when subscription.revoked arrives at the end of the period, with the status canceled. A pause works the same way: the subscription stays active until subscription.paused.

At each new period, Polar sends subscription.cycled whether or not the renewal payment succeeds, so do not extend access on it alone: order.paid confirms the payment, and subscription.past_due reports a failure. More on Polar's subscription events.

Dodo Payments

There is no subscription.created: grant on subscription.active. The first payment.succeeded follows 2 to 10 minutes later, so a handler that waits for it keeps a new customer waiting. Extend access on subscription.renewed, which comes with every charge together with payment.succeeded.

The other statuses, from Dodo's subscription guide: past_due means a renewal failed and a grace period runs, during which the customer keeps access; on_hold means a renewal failed and nothing renews until the payment method is updated; expired and cancelled end the subscription; subscription.failed means the first mandate failed, so never grant access. One more trap: a subscription whose period equals its payment frequency, one month and one month, lasts a single cycle and then expires. More on Dodo's webhooks.

The traps every provider shares

  • The success page instead of the webhook. The redirect after checkout is not proof of payment, and it may never happen.
  • Matching the user by email. The customer may pay with another address than the one they sign in with. Pass your own user id through the checkout instead, and read it back from the webhook:
ProviderYour id goes intoAnd comes back in
Stripeclient_reference_id of the Checkout SessionThe session in checkout.session.completed
Paddlecustom_data of the checkout or transactionThe transaction, and the subscription it creates
Lemon Squeezycheckout[custom][user_id] in the checkout linkmeta.custom_data of order and subscription events
Polarexternal_customer_id of the checkout sessioncustomer.external_id in its events
Dodo Paymentsmetadata of the checkout sessionThe metadata of its events
  • Trusting the order of arrival. Events come out of order and more than once. Keep the id of each event you have handled, and compare the provider's own timestamps before you apply a change: the five providers compared.
  • Trusting an event you did not verify. Your webhook URL is public: anyone can post a perfect subscription.active to it. Grant access only on a signature that verifies.

How PayHook reports it

This is the question PayHook is built to answer. For Stripe, Lemon Squeezy, Polar and Dodo, PayHook, the webhook inspector we are building, follows each subscription's events as one chain, applies the provider's rules above and says whether access should be on and what is missing; when it relays the events to your app, it also shows which of your app's answers broke the chain. Each finding comes with a fix prompt to paste into Cursor, Claude Code or the chat of Lovable and Bolt. PayHook is in closed beta; the home page has the details.

Sources

Back to the blog