Verificar firmas
Cualquiera que conozca la URL de tu endpoint puede enviarle peticiones. Verifica siempre la firma antes de confiar en un payload.
Cómo funciona la firma
Sección titulada «Cómo funciona la firma»Cada entrega incluye:
webhook-id: 0192e1c4-0000-7000-8000-0000000000ccwebhook-timestamp: 1761228131webhook-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.
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 acceptedconst 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,});pip install standardwebhooksimport osfrom flask import Flask, requestfrom standardwebhooks.webhooks import Webhook, WebhookVerificationError
app = Flask(__name__)wh = Webhook(os.environ["SPACEPOST_WEBHOOK_SECRET"]) # "whsec_..." is accepted
@app.post("/webhooks/spacepost")def spacepost_webhook(): try: event = wh.verify(request.get_data(as_text=True), dict(request.headers)) except WebhookVerificationError: return "invalid signature", 400 print(event["type"], event["data"]["email_id"]) return "", 200Sin biblioteca:
import base64, hashlib, hmac, time
def verify(secret: str, headers: dict, body: bytes, tolerance: int = 300) -> bool: msg_id = headers["webhook-id"] ts = headers["webhook-timestamp"] if abs(time.time() - int(ts)) > tolerance: return False key = base64.b64decode(secret.removeprefix("whsec_")) signed = f"{msg_id}.{ts}.".encode() + body expected = base64.b64encode(hmac.new(key, signed, hashlib.sha256).digest()).decode() return any(hmac.compare_digest(s.split(",", 1)[1], expected) for s in headers["webhook-signature"].split() if s.startswith("v1,"))package main
import ( "crypto/hmac" "crypto/sha256" "encoding/base64" "fmt" "io" "math" "net/http" "os" "strconv" "strings" "time")
func verify(secret string, h http.Header, body []byte) bool { id := h.Get("webhook-id") ts := h.Get("webhook-timestamp") n, err := strconv.ParseInt(ts, 10, 64) if err != nil || math.Abs(float64(time.Now().Unix()-n)) > 300 { return false } key, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(secret, "whsec_")) if err != nil { return false } mac := hmac.New(sha256.New, key) mac.Write([]byte(id + "." + ts + ".")) mac.Write(body) expected := base64.StdEncoding.EncodeToString(mac.Sum(nil)) for _, s := range strings.Fields(h.Get("webhook-signature")) { if v, ok := strings.CutPrefix(s, "v1,"); ok && hmac.Equal([]byte(v), []byte(expected)) { return true } } return false}
func main() { http.HandleFunc("/webhooks/spacepost", func(w http.ResponseWriter, r *http.Request) { body, _ := io.ReadAll(r.Body) if !verify(os.Getenv("SPACEPOST_WEBHOOK_SECRET"), r.Header, body) { http.Error(w, "invalid signature", http.StatusBadRequest) return } fmt.Println("event:", string(body)) w.WriteHeader(http.StatusOK) }) http.ListenAndServe(":8080", nil)}O usa la biblioteca oficial: go get github.com/standard-webhooks/standard-webhooks/libraries/go y llama a wh, _ := standardwebhooks.NewWebhook(secret); err := wh.Verify(body, r.Header).
<?phpfunction verify_spacepost_webhook(string $secret, array $headers, string $body, int $tolerance = 300): bool { $headers = array_change_key_case($headers, CASE_LOWER); $id = $headers['webhook-id'] ?? ''; $ts = $headers['webhook-timestamp'] ?? ''; if (!ctype_digit($ts) || abs(time() - (int) $ts) > $tolerance) { return false; } $key = base64_decode(preg_replace('/^whsec_/', '', $secret)); $expected = base64_encode(hash_hmac('sha256', "$id.$ts.$body", $key, true)); foreach (explode(' ', $headers['webhook-signature'] ?? '') as $sig) { if (str_starts_with($sig, 'v1,') && hash_equals($expected, substr($sig, 3))) { return true; } } return false;}
$body = file_get_contents('php://input');if (!verify_spacepost_webhook(getenv('SPACEPOST_WEBHOOK_SECRET'), getallheaders(), $body)) { http_response_code(400); exit('invalid signature');}$event = json_decode($body, true);error_log($event['type'] . ' ' . $event['data']['email_id']);http_response_code(200);Usar el verificador del SDK de Resend
Sección titulada «Usar el verificador del SDK de Resend»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.
Rotar secretos
Sección titulada «Rotar secretos»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.
Probar en local
Sección titulada «Probar en local»Envía un correo a delivered@test.spacepost.dev con la URL de un túnel registrada como tu endpoint. Consulta Pruebas.