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
| Provider | Grant access on | Keep access while | Take it away on | The trap |
|---|---|---|---|---|
| Stripe | checkout.session.completed with payment_status other than unpaid, or checkout.session.async_payment_succeeded for delayed payment methods | invoice.paid arrives and the subscription is active or trialing | customer.subscription.deleted, or a status of canceled or unpaid | A subscription set to cancel at the period's end stays active until then |
| Paddle | subscription.created | subscription.updated shows active, trialing or past_due | A status of canceled, or the date of a scheduled cancellation | A trial sends subscription.trialing instead of subscription.activated |
| Lemon Squeezy | subscription_created, which arrives active | The status is on_trial, active or cancelled before ends_at | subscription_expired | subscription_cancelled does not end access: the subscription stays valid until ends_at |
| Polar | subscription.active, which a trial sends too | The status is active or trialing | subscription.revoked | subscription.canceled arrives at once, with the status still active |
| Dodo Payments | subscription.active | subscription.renewed arrives at each charge | subscription.expired or subscription.cancelled | payment.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:
| Provider | Your id goes into | And comes back in |
|---|---|---|
| Stripe | client_reference_id of the Checkout Session | The session in checkout.session.completed |
| Paddle | custom_data of the checkout or transaction | The transaction, and the subscription it creates |
| Lemon Squeezy | checkout[custom][user_id] in the checkout link | meta.custom_data of order and subscription events |
| Polar | external_customer_id of the checkout session | customer.external_id in its events |
| Dodo Payments | metadata of the checkout session | The 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.activeto 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
- Stripe documentation, Using webhooks with subscriptions and Fulfill orders, checked on 9 October 2026; the cancellation at the period's end: PayHook's own sandbox capture, 8 October 2026.
- Paddle documentation, Provision access and handle subscription state, Simulate webhooks and Custom data, checked on 9 October 2026.
- Lemon Squeezy documentation, Event types, the subscription object and Passing custom data, checked on 9 October 2026.
- Polar documentation, Webhook events and the API reference for creating a checkout session, checked on 9 October 2026; the trial: PayHook's own sandbox capture, 2 October 2026.
- Dodo Payments documentation, Subscription integration guide and Checkout sessions, checked on 9 October 2026.