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.
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 secret | Use |
|---|---|
| Created before 2026-09-08 | SDK 0.47.x or 1.x |
| Created on or after 2026-09-08 | SDK 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 202The 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
403usually means your auth middleware is blocking the route. Exclude the webhook path from it. - No redirects. Polar treats a
3xxas 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 triggerUse 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.canceledin Polar means cancel at period end, so the customer keeps access. Onlyrevokedends it, and the adapter treats them that way.order.paidonly records a purchase when the billing reason ispurchase, so renewals do not add a row every cycle.- Events we do not track map to
null: the route answers200and 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.