コンテンツにスキップ

署名の検証

エンドポイントの URL を知った人は誰でも、そこに POST できます。ペイロードを信頼する前に、必ず署名を検証してください。

各配信には、次の内容が付きます。

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

署名は、"{webhook-id}.{webhook-timestamp}.{raw body}" の base64 形式の HMAC-SHA256 で、シークレットのバイト列を鍵にします。signing_secret から whsec_ プレフィックスを取り除き、残りを base64 デコードしてください。リプレイ攻撃を防ぐため、タイムスタンプが手元の時計から 5 分以上ずれているリクエストは拒否します。

SpacePost の SDK は WebCrypto で検証し、パース済みのイベントを返します。署名またはタイムスタンプが不正な場合は例外をスローします。

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 });
}
}

ヘッダーには、Headers オブジェクトまたはプレーンなオブジェクト(たとえば Express の req.headers)を渡せます。Express では、req.body が Buffer になるように、Webhook のルートに express.raw({ type: "application/json" }) をマウントしてください。

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,
});

svix を使う場合(または resend パッケージの検証機能): SpacePost が併せて送信している svix-* ヘッダーを使います。

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,
});

公式の resend パッケージの Webhook 検証は、同じ Standard Webhooks ヘッダーを受け取るため、SpacePost の signing_secret でそのまま動作します。

シークレットをローテーションする

Section titled “シークレットをローテーションする”

signing_secret は Webhook ごとに固定です。ローテーションするには、同じエンドポイントで新しい Webhook を作成し、ハンドラーが新しいシークレットを受け付けるように切り替え(切り替え期間中は両方を試します)、古い Webhook を削除します。

トンネルの URL をエンドポイントとして登録し、delivered@test.spacepost.dev にメールを送信します。テストをご覧ください。