Webhooks de Polar en Next.js: verificá la firma (Standard Webhooks)
Verificá webhooks de Polar en Next.js: body crudo, headers Standard Webhooks, el cambio de secret del 2026-09-08, reintentos e idempotencia.
Para verificar un webhook de Polar en Next.js, leé el body como texto crudo con
await request.text(), pasalo junto con los headers webhook-id,
webhook-timestamp y webhook-signature y el secret de tu endpoint al
validateEvent del SDK de Polar, respondé 403 si la verificación falla y
respondé un 2xx rápido para todo lo demás. Polar firma las entregas con el
esquema Standard Webhooks. Lo que cambió hace
poco es el formato del secret, y eso decide qué versión del SDK podés usar. Esta
guía cubre las dos cosas, con el código del adapter de Polar de useDeploy,
que usa @polar-sh/sdk 1.x y verifica los dos formatos de secret. Todo sigue la
documentación de delivery de webhooks de
Polar.
Cómo firma Polar una entrega
Cada request trae tres headers: webhook-id, webhook-timestamp y
webhook-signature. La firma se calcula sobre los bytes exactos del body, así que
tenés que verificar antes de parsear. Si tu framework parsea el JSON y lo volvés a
serializar, cambian los espacios y todas las firmas fallan.
El formato del secret cambió el 2026-09-08
Según la doc de Polar, los secrets generados a partir del 2026-09-08 (00:00 UTC)
siguen Standard Webhooks, y le pasás el valor whsec_... tal cual a una librería
de Standard Webhooks. Los secrets anteriores usan el HMAC original de Polar, donde
la clave son los bytes UTF-8 del string completo del secret. Los SDKs de Polar
desde 1.0.0-alpha.19 prueban los dos tipos de clave.
Eso tiene una consecuencia práctica. Probamos @polar-sh/[email protected], la versión
que fijaba useDeploy antes de pasar a 1.x: su validateEvent codifica el secret
en base64 antes de verificar (mirá dist/commonjs/webhooks.js). Eso corresponde
al esquema viejo. Con un secret de Standard Webhooks falla con No matching signature found, aunque la firma sea válida. Con @polar-sh/[email protected] los dos
formatos de secret verificaron en nuestra prueba, y por eso useDeploy ahora corre
sobre 1.x.
Entonces:
| El secret de tu endpoint | Usá |
|---|---|
| Creado antes del 2026-09-08 | SDK 0.47.x o 1.x |
| Creado a partir del 2026-09-08 | SDK 1.x, o el paquete standardwebhooks directo |
Si no sabés cuál tenés, instalá el SDK 1.x.
El route handler
Esto es para el App Router, con el SDK 1.x (@polar-sh/sdk 1.0.2, importado desde
su entry point 2026-10 como muestra la doc):
// 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 });
// La firma cubre los bytes exactos que mandó Polar: leé texto crudo, nunca 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':
// guardá o encolá, de forma idempotente, con event.data.id como clave
break;
case 'subscription.revoked':
// cortá el acceso
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 }); // tipo de evento más nuevo: confirmalo, no lo reintentes
}
throw error;
}
}Corrimos este handler con un secret de Standard Webhooks. Resultados:
valid signature 202
tampered body 403
missing headers 403
timestamp 10 minutes old 403
unknown event type 202El timestamp viejo se rechaza porque la librería de Standard Webhooks compara el timestamp contra una ventana de tolerancia, lo que evita que alguien reenvíe más tarde una entrega capturada.
Notas para Next.js:
- Body crudo. En los route handlers del App Router
request.text()devuelve el body crudo. En el Pages Router, desactivá el body parser de la ruta (export const config = { api: { bodyParser: false } }) y leé el stream. - Sin auth adelante. La doc de Polar dice que un
403suele significar que tu middleware de auth está bloqueando la ruta. Excluí el path del webhook. - Sin redirects. Polar trata un
3xxcomo falla y no lo sigue. Registrá la URL final, con la barra final si tu hosting la agrega.
Respondé rápido y que el retry no moleste
Según la doc:
- Cualquier respuesta que no sea 2xx cuenta como falla.
- El request tiene un timeout de 10 segundos. Polar recomienda responder dentro de 2 segundos, así que encolá el trabajo y devolvé.
- Las entregas fallidas se reintentan hasta 10 veces con backoff exponencial. Después de 10 fallas consecutivas el endpoint se deshabilita y los miembros reciben un mail.
- Podés revisar las entregas y disparar una reentrega desde el dashboard.
La doc no cubre los duplicados, así que planificalos: las entregas son at-least-once, y un reintento puede llegar cuando ya hiciste el trabajo. Hacé que la escritura sea segura de repetir, con la clave en el id del objeto de Polar (el id de la orden o de la suscripción) y no en el orden de llegada:
await prisma.purchase.upsert({
where: { externalOrderId: order.id }, // único en el schema
create: { /* el comprobante */ },
update: {}, // vacío a propósito: un duplicado no cambia nada
});Probalo en local
El CLI de Polar reenvía las entregas a tu máquina:
polar webhook listen http://localhost:3000/api/webhooks/polar
polar webhook triggerUsá el entorno sandbox mientras desarrollás, como recomienda la doc.
Cómo lo hace useDeploy
useDeploy corre Express, no una ruta de Next.js, pero los pasos son los mismos. La
ruta es POST /api/v1/billing/webhook, montada con express.raw({ type: '*/*' })
para que el handler reciba los bytes sin parsear. El adapter de Polar,
apps/server/src/modules/billing/infrastructure/providers/polar-payment-provider.ts,
corre sobre @polar-sh/sdk 1.x y verifica y parsea en una sola llamada:
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));
}Una firma inválida termina en un 422. Después mapWebhook convierte la unión de
eventos de Polar en un BillingEvent agnóstico del proveedor:
subscription.created, subscription.updated (que cubre active, updated,
canceled y uncanceled), un evento canceled para subscription.revoked e
invoice.paid para order.paid. Detalles que vale la pena copiar:
subscription.canceleden Polar significa cancelar al final del período, así que el cliente conserva el acceso. Solorevokedlo termina, y el adapter los trata así.order.paidsolo registra una compra cuando el billing reason espurchase, así que las renovaciones no agregan una fila en cada ciclo.- Los eventos que no seguimos se mapean a
null: la ruta responde200y no se publica nada. Un tipo de evento firmado que el SDK todavía no conoce (PolarWebhookUnknownTypeError) se trata igual, así Polar no lo reintenta. - El SDK 1.x devuelve el payload en el snake_case de la API
(
customer.external_id,net_amount), no en el camelCase que generaba el SDK 0.x, así que el mapper lee los campos en snake_case. - Un evento seguido se publica en el event bus, así que los listeners de billing (estado de suscripción, filas de compras) y el dispatcher de webhooks salientes reaccionan sin saber nada de Polar. Las compras son idempotentes por el id de la orden, y las actualizaciones de suscripción ignoran los eventos más viejos que lo guardado.
useDeploy responde 200 y 422 donde la doc muestra 202 y 403. Cualquier 2xx
confirma y cualquier 4xx es una entrega fallida, así que ambos sirven.
useDeploy usa @polar-sh/sdk 1.x, que verifica los dos formatos: un secret
whsec_... de un endpoint de webhook que crees hoy y un secret anterior al
2026-09-08 funcionan con el mismo adapter y la misma variable
POLAR_WEBHOOK_SECRET. Si tu propio código todavía fija un SDK 0.x, pasar a 1.x es
un salto de versión mayor, no solo un import nuevo de webhooks: 1.x deja los campos
de requests y responses en snake_case y reemplaza los iteradores de páginas, así
que también hay que migrar las llamadas de checkout, portal y listados.
Preguntas frecuentes
¿Por qué mi webhook de Polar devuelve "No matching signature found"? Las tres causas comunes: parseaste el body antes de verificar, un middleware cambió los bytes, o la versión del SDK no coincide con el formato del secret. Compará la fecha de creación del secret con el 2026-09-08.
¿Respondo 200, 202 o 204? Cualquier 2xx sirve. Los ejemplos de Polar usan 202.
¿Puedo usar request.json() en Next.js?
No antes de verificar. Leé request.text(), verificá y después hacé JSON.parse
del mismo string si lo necesitás vos. validateEvent ya devuelve el evento
tipado.
¿Necesito una cola? Al principio no. Si el trabajo puede pasar de unos 2 segundos, encolalo y devolvé. useDeploy usa un outbox durable para eso.
useDeploy trae el adapter de Polar, la ruta de webhook verificada y el mapeo de eventos detrás de un port de billing agnóstico del proveedor. Mirá la integración con Polar.