Polar webhooks in Next.js: verify the signature (Standard Webhooks)

Verify Polar webhooks in a Next.js route handler: raw body, Standard Webhooks headers, the 2026-09-08 secret change, retries and idempotency.

MC
Martin Coll

To verify a Polar webhook in Next.js, read the request body as raw text with await request.text(), pass it with the webhook-id, webhook-timestamp and webhook-signature headers and your endpoint secret to the Polar SDK's validateEvent, answer 403 if verification fails, and answer a 2xx fast for everything else. Polar signs deliveries with the Standard Webhooks scheme. The part that changed recently is the secret format, and it decides which SDK version you can use. This guide covers both, using the code from useDeploy's Polar adapter, which uses @polar-sh/sdk 1.x and verifies both secret formats. Everything follows Polar's webhook delivery docs.

How Polar signs a delivery

Each request carries three headers: webhook-id, webhook-timestamp and webhook-signature. The signature is computed over the exact bytes of the body, so you must verify before parsing. If your framework parses the JSON and you re-serialize it, the whitespace changes and every signature fails.

The secret format changed on 2026-09-08

Per Polar's docs, secrets generated on or after 2026-09-08 (00:00 UTC) follow Standard Webhooks, and you pass the whsec_... value to a Standard Webhooks library as is. Older secrets use Polar's original HMAC scheme, where the key is the UTF-8 bytes of the full secret string. Polar SDKs from 1.0.0-alpha.19 try both key types.

That has a practical consequence. We tested @polar-sh/[email protected], the version useDeploy pinned before moving to 1.x: its validateEvent base64-encodes the secret before verifying (see dist/commonjs/webhooks.js). That matches the old scheme. Given a Standard Webhooks secret it fails with No matching signature found, even though the signature is valid. With @polar-sh/[email protected] both secret formats verified in our test, which is why useDeploy now runs on 1.x.

So:

Your endpoint secretUse
Created before 2026-09-08SDK 0.47.x or 1.x
Created on or after 2026-09-08SDK 1.x, or the standardwebhooks package directly

If you don't know which you have, install the 1.x SDK.

The route handler

This is for the App Router, with SDK 1.x (@polar-sh/sdk 1.0.2, imported from its 2026-10 entry point as the docs show):

// app/api/webhooks/polar/route.ts
import { webhooks } from '@polar-sh/sdk/2026-10';

export async function POST(request: Request): Promise<Response> {
  const secret = process.env.POLAR_WEBHOOK_SECRET;
  if (!secret) return new Response('Webhook secret not configured', { status: 500 });

  // The signature covers the exact bytes Polar sent: read raw text, never request.json().
  const body = await request.text();

  try {
    const event = await webhooks.validateEvent(body, Object.fromEntries(request.headers), secret);

    switch (event.type) {
      case 'order.paid':
        // persist or enqueue, idempotently, keyed on event.data.id
        break;
      case 'subscription.revoked':
        // end access
        break;
    }
    return new Response(null, { status: 202 });
  } catch (error) {
    if (error instanceof webhooks.PolarWebhookVerificationError) {
      return new Response('Invalid signature', { status: 403 });
    }
    if (error instanceof webhooks.PolarWebhookUnknownTypeError) {
      return new Response(null, { status: 202 }); // newer event type: acknowledge, don't retry
    }
    throw error;
  }
}

We ran this handler with a Standard Webhooks secret. Results:

valid signature                    202
tampered body                      403
missing headers                    403
timestamp 10 minutes old           403
unknown event type                 202

The old timestamp is rejected because the Standard Webhooks library checks the timestamp against a tolerance window, which stops someone replaying a captured delivery later.

Notes for Next.js:

  • Raw body. In App Router route handlers request.text() returns the raw body. In the Pages Router, disable the body parser for the route (export const config = { api: { bodyParser: false } }) and read the stream.
  • No auth in front of it. Polar's docs say a 403 usually means your auth middleware is blocking the route. Exclude the webhook path from it.
  • No redirects. Polar treats a 3xx as a failure and does not follow it. Register the final URL, including the trailing slash if your host adds one.

Respond fast, retry-safe

From the docs:

  • Any non-2xx counts as a failure.
  • The request times out after 10 seconds. Polar recommends answering within 2 seconds, so enqueue the work and return.
  • Failed deliveries are retried up to 10 times with exponential backoff. After 10 consecutive failures the endpoint is disabled and members get an email.
  • You can inspect deliveries and trigger a redelivery in the dashboard.

The docs do not cover duplicates, so plan for them: deliveries are at-least-once, and a retry arrives after you already did the work. Make the write safe to repeat, keyed on the Polar object id (the order or subscription id), not on arrival:

await prisma.purchase.upsert({
  where: { externalOrderId: order.id }, // unique in the schema
  create: { /* the receipt */ },
  update: {}, // empty on purpose: a duplicate changes nothing
});

Test it locally

Polar's CLI forwards deliveries to your machine:

polar webhook listen http://localhost:3000/api/webhooks/polar
polar webhook trigger

Use the sandbox environment while you build, as the docs recommend.

How useDeploy does it

useDeploy runs Express, not a Next.js route, but the steps are the same. The route is POST /api/v1/billing/webhook, mounted with express.raw({ type: '*/*' }) so the handler gets the unparsed bytes. The Polar adapter, apps/server/src/modules/billing/infrastructure/providers/polar-payment-provider.ts, runs on @polar-sh/sdk 1.x and verifies and parses in one call:

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

async parseWebhook(req: WebhookRequest) {
  let payload: webhooks.WebhookPayload;
  try {
    payload = await webhooks.validateEvent(req.rawBody, { ...req.headers }, this.cfg.webhookSecret);
  } catch (e) {
    if (e instanceof webhooks.PolarWebhookUnknownTypeError) return ok(null);
    if (e instanceof webhooks.PolarWebhookVerificationError) {
      return err(new ValidationError(`Polar webhook signature invalid: ${e.message}`));
    }
    return err(new ValidationError(e instanceof Error ? e.message : 'Polar webhook validation failed'));
  }
  return ok(this.mapWebhook(payload));
}

A bad signature becomes a 422. Then mapWebhook turns Polar's event union into a provider-agnostic BillingEvent: subscription.created, subscription.updated (covering active, updated, canceled and uncanceled), a canceled event for subscription.revoked, and invoice.paid for order.paid. Details worth copying:

  • subscription.canceled in Polar means cancel at period end, so the customer keeps access. Only revoked ends it, and the adapter treats them that way.
  • order.paid only records a purchase when the billing reason is purchase, so renewals do not add a row every cycle.
  • Events we do not track map to null: the route answers 200 and nothing is published. A signed event type the SDK doesn't know yet (PolarWebhookUnknownTypeError) is treated the same way, so Polar doesn't retry it.
  • SDK 1.x hands back the payload in the API's snake_case (customer.external_id, net_amount), not the camelCase the 0.x SDK produced, so the mapper reads the snake_case fields.
  • A tracked event is published on the event bus, so the billing listeners (subscription state, purchase rows) and the outgoing-webhook dispatcher react without knowing about Polar. Purchases are idempotent on the order id, and subscription updates ignore events older than what is stored.

useDeploy answers 200 and 422 where the docs show 202 and 403. Any 2xx acknowledges, and any 4xx is a failed delivery, so both are fine.

useDeploy uses @polar-sh/sdk 1.x, which verifies both formats: a whsec_... secret from a webhook endpoint you create today and a pre-2026-09-08 secret work with the same adapter and the same POLAR_WEBHOOK_SECRET variable. If your own code still pins a 0.x SDK, moving to 1.x is a major version bump, not just a new webhook import: 1.x keeps request and response fields in snake_case and replaces the page iterators, so checkout, portal and list calls need migrating too.

Frequently asked questions

Why does my Polar webhook return "No matching signature found"? The three common causes are: you parsed the body before verifying, a middleware changed the bytes, or the SDK version does not match the secret format. Check the secret creation date against 2026-09-08.

Should I answer 200, 202 or 204? Any 2xx works. Polar's examples use 202.

Can I use request.json() in Next.js? Not before verifying. Read request.text(), verify, then JSON.parse the same string if you need it yourself. validateEvent already returns the typed event.

Do I need a queue? Not at the start. If the work can exceed about 2 seconds, enqueue it and return. useDeploy uses a durable outbox for this.


useDeploy ships the Polar adapter, the verified webhook route and the event mapping behind a provider-agnostic billing port. See the Polar integration.