@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#
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#
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#
| Situacion | Codigo | Por que |
|---|---|---|
| Firma invalida | 401 | Sin detalle. |
| Body no parseable | 400 | No va a mejorar reintentando. |
| Secreto sin configurar | 503 | Es un fallo tuyo, no del proveedor. |
| Evento que no te interesa | 200 | Si respondes error, reintentara para siempre. |
| Procesado (o duplicado) | 200 | Cierra el ciclo. |
Webhook de pagos completo#
Verificacion, idempotencia y escritura atomica, en el orden correcto:
import { Hono } from "hono";
import { verifyStandardWebhook } from "@jun/soul/webhooks";
import { sendEmail } from "@jun/soul/lib";
const pagos = new Hono<{ Bindings: Env }>();
pagos.post("/", async (c) => {
if (!c.env.POLAR_WEBHOOK_SECRET) return c.json({ error: "webhook no configurado" }, 503);
const raw = await c.req.text();
if (!(await verifyStandardWebhook(c.env.POLAR_WEBHOOK_SECRET, c.req.raw.headers, raw))) {
return c.body(null, 401);
}
let evento: { type?: string; data?: Record<string, unknown> };
try { evento = JSON.parse(raw); } catch { return c.body(null, 400); }
// Idempotencia antes de tocar nada.
const webhookId = c.req.header("webhook-id") ?? "";
const ins = await c.env.DB.prepare(
"INSERT INTO webhook_events (webhook_id, type) VALUES (?, ?) ON CONFLICT DO NOTHING",
).bind(webhookId, String(evento.type ?? "")).run();
if (!ins.meta.changes) return c.json({ received: true, duplicate: true });
if (evento.type === "order.paid") {
const email = (evento.data as any)?.customer?.email;
const orgId = Number((evento.data as any)?.metadata?.org_id);
// Varias escrituras => batch: atomico y un solo viaje.
await c.env.DB.batch([
c.env.DB.prepare("UPDATE orgs SET plan = 'pro' WHERE id = ?").bind(orgId),
c.env.DB.prepare(
"INSERT INTO subscriptions (org_id, provider, external_id, plan, status) VALUES (?, 'polar', ?, 'pro', 'active')",
).bind(orgId, String((evento.data as any)?.id ?? "")),
]);
c.executionCtx.waitUntil(sendEmail(c.env, {
to: email, subject: "Tu plan Pro esta activo", text: "Gracias por la compra.",
}));
}
// Cualquier otro evento: 200 y a otra cosa.
return c.json({ received: true });
});
export default pagos;
La tabla de idempotencia#
CREATE TABLE IF NOT EXISTS webhook_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
webhook_id TEXT NOT NULL,
type TEXT,
org_id INTEGER REFERENCES orgs(id),
created_at INTEGER NOT NULL DEFAULT (unixepoch())
);
-- El UNIQUE es lo que hace funcionar el ON CONFLICT DO NOTHING.
CREATE UNIQUE INDEX IF NOT EXISTS idx_webhook_events_id ON webhook_events(webhook_id);
Probarlo sin el proveedor#
signStandardWebhook existe justo para esto: fabricar una peticion valida en un test.
import { signStandardWebhook } from "@jun/soul/webhooks";
const SECRET = "whsec_" + btoa("secreto-de-prueba");
async function enviarWebhook(payload: unknown, opts?: { id?: string; ts?: number }) {
const raw = JSON.stringify(payload);
const id = opts?.id ?? crypto.randomUUID();
const ts = opts?.ts ?? Math.floor(Date.now() / 1000);
const firma = await signStandardWebhook(SECRET, id, ts, raw);
return SELF.fetch("http://localhost/webhooks/pagos", {
method: "POST",
headers: {
"Content-Type": "application/json",
"webhook-id": id,
"webhook-timestamp": String(ts),
"webhook-signature": firma,
},
body: raw,
});
}
it("rechaza una firma invalida", async () => {
const res = await SELF.fetch("http://localhost/webhooks/pagos", {
method: "POST",
headers: { "webhook-id": "x", "webhook-timestamp": "1", "webhook-signature": "v1,noesvalida" },
body: "{}",
});
expect(res.status).toBe(401);
});
it("no procesa dos veces el mismo evento", async () => {
const id = crypto.randomUUID();
const payload = { type: "order.paid", data: { metadata: { org_id: 1 } } };
expect((await enviarWebhook(payload, { id })).status).toBe(200);
const segunda = await enviarWebhook(payload, { id });
expect(await segunda.json()).toMatchObject({ duplicate: true });
const { n } = (await env.DB.prepare(
"SELECT COUNT(*) AS n FROM subscriptions WHERE org_id = 1",
).first<{ n: number }>())!;
expect(n).toBe(1);
});
it("rechaza un timestamp viejo (anti-replay)", async () => {
const viejo = Math.floor(Date.now() / 1000) - 3600;
const res = await enviarWebhook({ type: "order.paid" }, { ts: viejo });
expect(res.status).toBe(401);
});
Configurar el secreto#
npx wrangler secret put POLAR_WEBHOOK_SECRET --env production
# El valor tiene la forma whsec_<base64>, tal cual lo entrega el proveedor.