Verificar assinaturas
Qualquer pessoa que descubra a URL do seu endpoint pode enviar requisições a ele. Sempre verifique a assinatura antes de confiar em um payload.
Como a assinatura funciona
Seção intitulada “Como a assinatura funciona”Cada entrega traz:
webhook-id: 0192e1c4-0000-7000-8000-0000000000ccwebhook-timestamp: 1761228131webhook-signature: v1,K5oZfzN0mYI+Hx3Yb0v8j3H5kq2tqZ2dA8s0r6sF9Vw=A assinatura é o HMAC-SHA256 em base64 de "{webhook-id}.{webhook-timestamp}.{corpo bruto}", usando como chave os bytes do segredo: pegue o seu signing_secret, remova o prefixo whsec_ e decodifique o restante em base64. Rejeite requisições cujo horário difira em mais de 5 minutos do seu relógio, para impedir replays.
O SDK do SpacePost verifica com WebCrypto e retorna o evento já interpretado. Ele lança uma exceção se a assinatura ou o horário forem inválidos.
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 }); }}Os cabeçalhos podem ser um objeto Headers ou um objeto simples (por exemplo, o req.headers do Express). Com o Express, monte express.raw({ type: "application/json" }) na rota do webhook para que req.body seja um Buffer.
Com o pacote 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,});Com o svix (ou o verificador do pacote resend): use os cabeçalhos svix-*, que o SpacePost também envia.
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 "", 200Sem uma 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)}Ou use a biblioteca oficial: go get github.com/standard-webhooks/standard-webhooks/libraries/go e chame 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);Usando o verificador do SDK do Resend
Seção intitulada “Usando o verificador do SDK do Resend”A verificação de webhooks do pacote oficial resend usa os mesmos cabeçalhos do Standard Webhooks, então funciona com o signing_secret do seu SpacePost.
Rotação de segredos
Seção intitulada “Rotação de segredos”O signing_secret é fixo por webhook. Para rotacioná-lo, crie um novo webhook com o mesmo endpoint, faça seu handler aceitar o novo segredo (tente os dois durante a transição) e depois exclua o webhook antigo.
Testando localmente
Seção intitulada “Testando localmente”Envie um e-mail para delivered@test.spacepost.dev com a URL de um túnel registrada como seu endpoint. Veja Testes.