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.

MC
Martin Coll

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 endpointUsá
Creado antes del 2026-09-08SDK 0.47.x o 1.x
Creado a partir del 2026-09-08SDK 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                 202

El 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 403 suele significar que tu middleware de auth está bloqueando la ruta. Excluí el path del webhook.
  • Sin redirects. Polar trata un 3xx como 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 trigger

Usá 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.canceled en Polar significa cancelar al final del período, así que el cliente conserva el acceso. Solo revoked lo termina, y el adapter los trata así.
  • order.paid solo registra una compra cuando el billing reason es purchase, así que las renovaciones no agregan una fila en cada ciclo.
  • Los eventos que no seguimos se mapean a null: la ruta responde 200 y 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.