soul v4.2.0

@jun/soul/webhooks

Webhooks

Verificar firmas Standard Webhooks: Polar, Resend, Clerk y compañia.

Que resuelve#

Un webhook entrante es una URL publica que provoca efectos en tu base: emitir una licencia, subir un plan, marcar un pago. Si no verificas la firma, cualquiera con la URL puede dispararlos.

Este modulo implementa el esquema Standard Webhooks, que usan Polar, Resend, Clerk y varios mas: HMAC-SHA256 sobre id.timestamp.body, con los headers webhook-id, webhook-timestamp y webhook-signature.

La API#

verifyStandardWebhook(secret, headers, rawBody, { toleranceSeconds? })
Devuelve true/false. Nunca lanza. La tolerancia por defecto es 300 segundos.
signStandardWebhook(secret, id, timestamp, rawBody)
Firma. Es un helper para tests: te deja fabricar peticiones validas.

Uso#

TypeScript
import { verifyStandardWebhook } from "@jun/soul/webhooks";

pagos.post("/", async (c) => {
  if (!c.env.WEBHOOK_SECRET) return c.json({ error: "webhook no configurado" }, 503);

  // El cuerpo CRUDO, antes de parsear.
  const raw = await c.req.text();

  const ok = await verifyStandardWebhook(c.env.WEBHOOK_SECRET, c.req.raw.headers, raw);
  if (!ok) return c.body(null, 401);

  const evento = JSON.parse(raw);
  // ...actuar
  return c.json({ received: true });
});

Por que no lanza nunca#

Devuelve false ante cualquier problema —header ausente, secreto mal formado, timestamp fuera de ventana, firma que no calza— para que respondas un 401 seco. Distinguir el motivo en la respuesta solo le sirve a quien esta intentando falsificarla.

La idempotencia es tuya#

TypeScript
const webhookId = c.req.header("webhook-id") ?? "";

const ins = await c.env.DB.prepare(
  "INSERT INTO webhook_events (webhook_id) VALUES (?) ON CONFLICT DO NOTHING",
).bind(webhookId).run();

// Ya lo habiamos procesado: 200 para que el proveedor deje de reintentar.
if (!ins.meta.changes) return c.json({ received: true, duplicate: true });

Que responder#

SituacionCodigoPor que
Firma invalida401Sin detalle.
Body no parseable400No va a mejorar reintentando.
Secreto sin configurar503Es un fallo tuyo, no del proveedor.
Evento que no te interesa200Si respondes error, reintentara para siempre.
Procesado (o duplicado)200Cierra el ciclo.