All guidesStripe and webhooks
On this page

Polar question

Polar

How do I verify Polar webhooks in a Next.js App Router route and grant access after a subscription event?

By Ohad Krispin, founder of VibeRaven · LinkedIn

Export POST = Webhooks({ webhookSecret: process.env.POLAR_WEBHOOK_SECRET, ... }) from @polar-sh/nextjs in a route such as app/api/webhook/polar/route.ts. It reads the raw body and checks the webhook-id, webhook-timestamp and webhook-signature headers before any handler runs, and answers 403 when the signature is missing or wrong. Grant access in onSubscriptionActive, not onSubscriptionCreated: Polar says a new subscription may not be active yet while the first payment is processed. Remove access in onSubscriptionRevoked, which Polar sends when the customer loses access; subscription.canceled can leave access until the end of the period. If you would rather keep one record per user, handle onCustomerStateChanged, whose payload is the customer state with its active subscriptions and granted benefits.

Why verification fails

The adapter answers 403 with {"received":false} for every verification error, so the reason is only in the SDK. In a run of @polar-sh/sdk 1.0.2 on 2026-10-30, a payload signed with another secret and a payload parsed and stringified again both failed with "No matching signature found", an empty secret with "Secret can't be empty" and a request without the three headers with "Missing required headers". The SDK also refuses a timestamp more than five minutes from the server clock.

Each endpoint signs with the secret set when it was created, and polar webhook listen signs with the secret it prints, so the sandbox endpoint, the production endpoint and the CLI each need their own value of POLAR_WEBHOOK_SECRET. Polar's troubleshooting adds two causes outside the code: middleware that protects the route returns 403 before the handler runs, and a redirect such as www to the bare domain counts as a failed delivery.

PolarWebhookVerificationError: No matching signature found
PolarWebhookVerificationError: Secret can't be empty
PolarWebhookVerificationError: Missing required headers
PolarWebhookVerificationError: Message timestamp too old

Verifying without the adapter

webhooks.validateEvent from @polar-sh/sdk/2026-10 is the same check. Pass it the body from await request.text(), never a parsed object. Secrets generated on or after 8 September 2026, 00:00 UTC follow Standard Webhooks; older ones use Polar HMAC, keyed with the UTF-8 bytes of the whole whsec_ string. Polar SDKs 1.0.0-alpha.19 and later try both, so pass the secret as the dashboard shows it. A Standard Webhooks library needs an older secret base64-encoded first.

import { webhooks } from '@polar-sh/sdk/2026-10'

export async function POST(request: Request) {
  const body = await request.text()
  try {
    const event = await webhooks.validateEvent(
      body,
      {
        'webhook-id': request.headers.get('webhook-id') ?? '',
        'webhook-timestamp': request.headers.get('webhook-timestamp') ?? '',
        'webhook-signature': request.headers.get('webhook-signature') ?? '',
      },
      process.env.POLAR_WEBHOOK_SECRET ?? '',
    )
    // handle event.type
    return new Response(null, { status: 202 })
  } catch (error) {
    if (error instanceof webhooks.PolarWebhookVerificationError) return new Response(null, { status: 403 })
    throw error
  }
}

Which subscription events change access

subscription.active fires for a new paid subscription and when a failed payment is recovered. subscription.canceled marks a cancellation, and by default the subscription stays active until the period ends, when Polar sends subscription.updated and subscription.revoked. subscription.past_due fires when a payment fails and the customer can still recover; Polar revokes the subscription when the retries are exhausted. subscription.updated is a catch-all for all of these. Polar notes that delivery order is not guaranteed in at least one sequence, a migration, so decide access from the status in the payload, not from the order events arrive in.

Checklist

Set it up, then check a real delivery

  1. 01In Polar, open Settings > Webhooks, add an endpoint with your deployed URL (for example https://your-app.com/api/webhook/polar), leave the format on Raw, set a secret and select the subscription events you handle.
  2. 02
    Add the route. The handler verifies first and calls your functions only for a valid event; a handler that throws fails the request, so Polar retries the delivery:
    // app/api/webhook/polar/route.ts
    import { Webhooks } from '@polar-sh/nextjs'
    
    export const POST = Webhooks({
      webhookSecret: process.env.POLAR_WEBHOOK_SECRET!,
      onSubscriptionActive: async (payload) => {
        // grant access for payload.data.customer_id
      },
      onSubscriptionRevoked: async (payload) => {
        // remove access for payload.data.customer_id
      },
    })
  3. 03To match a Polar customer to your own user, pass your user ID as external_customer_id when you send them to checkout. The customer state, from onCustomerStateChanged or the Get Customer State by External ID endpoint, then carries it as external_id.
  4. 04Set POLAR_WEBHOOK_SECRET in Vercel for the environment the endpoint points at, with the secret of that endpoint, and redeploy. Vercel applies changed variables only to new deployments.
  5. 05If middleware protects your routes, exclude the webhook path from it, and use the final URL with no redirect.
  6. 06
    Test locally with the Polar CLI, which forwards your organization's events and sends sample ones. Put the secret it prints in .env.local, then run trigger in a second terminal and pick an event:
    polar auth login --sandbox
    polar webhook listen http://localhost:3000/api/webhook/polar
    polar webhook trigger
  7. 07After a sandbox purchase, open the endpoint's deliveries in Polar: each should show a 2xx response. Polar retries failed deliveries up to 10 times and disables an endpoint after 10 consecutive failures.

Related

Sources

Official docs behind this checklist

FAQ

FAQ for builders before launch

Should I grant access on checkout or order events instead?

For a subscription, Polar points you to the subscription events, and order.created for a renewal is created before the payment, with status pending. For a one-time purchase, order.paid says the payment went through.

What does the handler return for an event type my SDK does not know?

The Next.js adapter answers 200 and ignores a signed event of a type the installed SDK does not know yet, so new event types do not cause retries.

Do I have to answer quickly?

Polar times out a delivery after 10 seconds and recommends answering within 2 seconds, so move slow work, such as emails or provisioning, to a background job after you store the event.