Ir al contenido

Verificar firmas

Cualquiera que conozca la URL de tu endpoint puede enviarle peticiones. Verifica siempre la firma antes de confiar en un payload.

Cada entrega incluye:

webhook-id: 0192e1c4-0000-7000-8000-0000000000cc
webhook-timestamp: 1761228131
webhook-signature: v1,K5oZfzN0mYI+Hx3Yb0v8j3H5kq2tqZ2dA8s0r6sF9Vw=

La firma es el HMAC-SHA256 en base64 de "{webhook-id}.{webhook-timestamp}.{raw body}", con los bytes del secreto como clave: toma tu signing_secret, quita el prefijo whsec_ y decodifica el resto desde base64. Rechaza las peticiones cuya marca de tiempo difiera más de 5 minutos de tu reloj para evitar reenvíos maliciosos.

El SDK de SpacePost verifica con WebCrypto y devuelve el evento ya analizado. Lanza una excepción si la firma o la marca de tiempo no son válidas.

webhook-handler.ts
import { SpacePost } from "@spacepost/node";
const spacepost = new SpacePost(process.env.SPACEPOST_API_KEY);
/** Works as a Bun, Deno, Cloudflare Workers or Next.js route handler. */
export async function POST(request: Request): Promise<Response> {
const payload = await request.text(); // the RAW body: do not parse it first
try {
const event = await spacepost.webhooks.verify({
payload,
headers: request.headers,
secret: process.env.SPACEPOST_WEBHOOK_SECRET!,
});
switch (event.type) {
case "email.bounced":
case "email.complained":
console.warn("Stop mailing", event.data.to);
break;
case "email.delivered":
console.log("Delivered", event.data.email_id);
break;
}
return new Response("ok");
} catch {
return new Response("invalid signature", { status: 400 });
}
}

Los encabezados pueden ser un objeto Headers o un objeto simple (por ejemplo, req.headers de Express). Con Express, monta express.raw({ type: "application/json" }) en la ruta del webhook para que req.body sea un Buffer.

Con el paquete standardwebhooks:

import { Webhook } from "standardwebhooks";
const wh = new Webhook(process.env.SPACEPOST_WEBHOOK_SECRET!); // "whsec_..." is accepted
const event = wh.verify(rawBody, {
"webhook-id": req.headers["webhook-id"] as string,
"webhook-timestamp": req.headers["webhook-timestamp"] as string,
"webhook-signature": req.headers["webhook-signature"] as string,
});

Con svix (o el verificador del paquete resend): usa los encabezados svix-*, que SpacePost también envía.

import { Webhook } from "svix";
const wh = new Webhook(process.env.SPACEPOST_WEBHOOK_SECRET!);
const event = wh.verify(rawBody, {
"svix-id": req.headers["svix-id"] as string,
"svix-timestamp": req.headers["svix-timestamp"] as string,
"svix-signature": req.headers["svix-signature"] as string,
});

La verificación de webhooks del paquete oficial resend toma los mismos encabezados de Standard Webhooks, por lo que funciona con tu signing_secret de SpacePost.

El signing_secret es fijo para cada webhook. Para rotarlo, crea un nuevo webhook con el mismo endpoint, haz que tu manejador acepte el nuevo secreto (prueba ambos durante la transición) y luego elimina el webhook antiguo.

Envía un correo a delivered@test.spacepost.dev con la URL de un túnel registrada como tu endpoint. Consulta Pruebas.