验证签名
任何知道你端点 URL 的人都可以向它发送请求。在信任载荷之前,请务必验证签名。
签名的工作原理
Section titled “签名的工作原理”每次投递都带有:
webhook-id: 0192e1c4-0000-7000-8000-0000000000ccwebhook-timestamp: 1761228131webhook-signature: v1,K5oZfzN0mYI+Hx3Yb0v8j3H5kq2tqZ2dA8s0r6sF9Vw=签名是对 "{webhook-id}.{webhook-timestamp}.{raw body}" 计算的 base64 HMAC-SHA256,密钥为密钥字符串解码后的字节:取你的 signing_secret,去掉 whsec_ 前缀,再对其余部分做 base64 解码。请拒绝时间戳与你本机时钟相差超过 5 分钟的请求,以防止重放。
SpacePost SDK 使用 WebCrypto 验证,并返回解析后的事件。如果签名或时间戳无效,它会抛出异常。
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 }); }}标头可以是 Headers 对象或普通对象(例如 Express 的 req.headers)。使用 Express 时,请在 Webhook 路由上挂载 express.raw({ type: "application/json" }),使 req.body 为 Buffer。
使用 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,});使用 svix(或 resend 包的验证器):请使用 svix-* 标头,SpacePost 同样会发送它们。
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 "", 200不使用库:
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)}或者使用官方库:go get github.com/standard-webhooks/standard-webhooks/libraries/go,然后调用 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);使用 Resend SDK 的验证器
Section titled “使用 Resend SDK 的验证器”官方 resend 包的 Webhook 验证使用相同的 Standard Webhooks 标头,因此可以配合你的 SpacePost signing_secret 使用。
每个 Webhook 的 signing_secret 是固定的。如需轮换,请用相同的端点创建一个新的 Webhook,让你的处理程序改为接受新密钥(切换期间两个都尝试),然后删除旧的 Webhook。
把隧道 URL 注册为端点,然后向 delivered@test.spacepost.dev 发送一封邮件。参见测试。