Signaturen prüfen
Jeder, der die URL deines Endpoints kennt, kann an ihn senden. Prüfe immer die Signatur, bevor du einer Payload vertraust.
So funktioniert die Signierung
Abschnitt betitelt „So funktioniert die Signierung“Jede Zustellung enthält:
webhook-id: 0192e1c4-0000-7000-8000-0000000000ccwebhook-timestamp: 1761228131webhook-signature: v1,K5oZfzN0mYI+Hx3Yb0v8j3H5kq2tqZ2dA8s0r6sF9Vw=Die Signatur ist der Base64-kodierte HMAC-SHA256 von "{webhook-id}.{webhook-timestamp}.{raw body}", mit den Bytes des Secrets als Schlüssel: Nimm dein signing_secret, entferne das Präfix whsec_ und dekodiere den Rest per Base64. Lehne Anfragen ab, deren Zeitstempel mehr als 5 Minuten von deiner Uhr abweicht, um Replays zu verhindern.
Das SpacePost SDK prüft mit WebCrypto und gibt das geparste Event zurück. Es wirft eine Exception, wenn Signatur oder Zeitstempel ungültig sind.
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 }); }}Die Header können ein Headers-Objekt oder ein einfaches Objekt sein (zum Beispiel req.headers von Express). Binde bei Express express.raw({ type: "application/json" }) an die Webhook-Route, damit req.body ein Buffer ist.
Mit dem Paket 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,});Mit svix (oder dem Verifier des Pakets resend): Verwende die svix-*-Header, die SpacePost ebenfalls sendet.
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 "", 200Ohne Bibliothek:
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)}Oder nutze die offizielle Bibliothek: go get github.com/standard-webhooks/standard-webhooks/libraries/go und rufe wh, _ := standardwebhooks.NewWebhook(secret); err := wh.Verify(body, r.Header) auf.
<?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);Den Verifier des Resend SDK verwenden
Abschnitt betitelt „Den Verifier des Resend SDK verwenden“Die Webhook-Verifizierung des offiziellen Pakets resend nimmt dieselben Standard-Webhooks-Header entgegen und funktioniert daher mit deinem SpacePost-signing_secret.
Secrets rotieren
Abschnitt betitelt „Secrets rotieren“Das signing_secret ist pro Webhook fest. Zum Rotieren erstellst du einen neuen Webhook mit demselben Endpoint, stellst deinen Handler so um, dass er das neue Secret akzeptiert (probiere beide während der Umstellung), und löschst dann den alten Webhook.
Lokal testen
Abschnitt betitelt „Lokal testen“Sende eine E-Mail an delivered@test.spacepost.dev, wobei eine Tunnel-URL als dein Endpoint registriert ist. Siehe Testen.