PayHook

Polar subscription webhooks: which event grants access

Polar subscription events in order, from checkout to revocation: which one grants access, why subscription.canceled is not the end, and customer.state_changed.

Grant access on subscription.active, which Polar also sends for a trial, with the status trialing, and take it away on subscription.revoked, not on subscription.canceled. Or listen to a single event, customer.state_changed, and give access while the subscription is in its list of active subscriptions. Below, each sequence in the order it arrives, from Polar's documentation and our own captures in Polar's sandbox (API version 2026-10).

A paid checkout

In our captures, the whole sequence took less than half a minute after the customer paid:

  1. checkout.updated, with the checkout confirmed
  2. customer.created and customer.state_changed, with no active subscription yet (a customer who bought before gets customer.updated instead)
  3. subscription.created, already active
  4. subscription.active
  5. subscription.updated
  6. order.created, order.updated and order.paid, with the billing_reason subscription_create
  7. checkout.updated, with the checkout succeeded, and customer.state_changed, now listing the subscription

subscription.active is the event to grant access on. subscription.created was already active in our captures, so a handler that reads the status can grant on either; one that waits for order.paid grants last.

A trial, and a declined card

With a trial, subscription.created and subscription.active both arrive with the status trialing, and the first order is paid at zero. A handler that grants only when the status is active locks every trial out: treat trialing as access.

When the card is declined, the checkout is confirmed and then reopened. The customer still gets customer.created and a customer.state_changed with no active subscription, but no subscription or order event arrives at all.

Cancellation, and changing one's mind

What happensEventsStatusAccess
The customer cancels, by default at the period's endsubscription.updated, subscription.canceledactive, with cancel_at_period_endKeep it
The customer changes their mindsubscription.updated, subscription.uncanceledactiveKeep it
The period endssubscription.updated, subscription.revokedcanceledTake it away
The merchant cancels immediatelysubscription.updated, subscription.canceled, subscription.revoked, at oncecanceledTake it away

So subscription.canceled means the subscription will end, not that it has: revoking on it takes away access the customer has paid for until the period's end.

Renewals, failures, pauses and migrations

  • Renewal. subscription.cycled, subscription.updated and a pending order.created arrive first, then order.updated and order.paid once the payment succeeds. Polar sends subscription.cycled whether or not the payment succeeds, so extend access on order.paid, not on subscription.cycled alone.
  • Failed payment. subscription.past_due arrives; the customer recovers by updating the payment method.
  • Pause. Like a cancellation, it applies at the period's end: subscription.updated first, then subscription.updated and subscription.paused when it takes effect, with the status paused and the benefits revoked. A resume sends subscription.updated, subscription.resumed and an order.created: the customer is charged at once.
  • Migration. A subscription that Polar takes over from another provider sends subscription.updated and subscription.migrated, but no subscription.created and no subscription.active: a handler that waits for subscription.active never unlocks it.

A refund is not a cancellation

In our capture, a full refund of the first order sent refund.created, order.updated, order.refunded and two refund.updated, and the subscription stayed active until we cancelled it separately. If a refund should end access in your app, cancel the subscription along with it, or handle order.refunded yourself.

One event for all of it: customer.state_changed

Polar's customer state holds the customer, their active subscriptions, their granted benefits and their active meters. Polar sends customer.state_changed when the customer is created, updated or deleted, when a subscription is created or updated, and when a benefit is granted or revoked. In our captures, its list of active subscriptions held the trial with the status trialing, kept the subscription through a cancellation at the period's end and emptied when the subscription was revoked.

To know which of your users it is, pass your own user id as external_customer_id when you create the checkout session; it comes back as the customer's external_id. With Polar's Next.js adapter:

// app/api/webhook/polar/route.ts
import { Webhooks } from "@polar-sh/nextjs";

export const POST = Webhooks({
  webhookSecret: process.env.POLAR_WEBHOOK_SECRET!,
  onCustomerStateChanged: async (payload) => {
    const state = payload.data;
    // external_id: your own user id, passed as external_customer_id at checkout
    await setAccess(state.external_id, state.active_subscriptions.length > 0);
  },
});

The adapter checks the signature before it calls a handler and answers 403 when it does not verify. In @polar-sh/nextjs 1.1.0 the fields keep the JSON's snake case, as above. Polar now marks its older adapters, among them Express, Hono and Supabase, as deprecated, and recommends its SDK for those frameworks.

Let Polar grant the access itself

Some access Polar can give without any code of yours: its benefits. License keys, access to a private GitHub repository, Discord invites and roles, file downloads, feature flags, credits and a shared Slack channel are granted to active subscribers, kept for good after a one-time purchase, and revoked when a subscription ends.

How PayHook reports it

PayHook, the webhook inspector we are building, verifies Polar events with both of Polar's signing keys and follows each subscription's events as one chain. It knows which events Polar sends for a checkout, a trial and a cancellation, says whether access should be on, and names what is missing, such as a subscription.active that never arrived. PayHook is in closed beta; the home page has the details.

Sources

Back to the blog