Skip to content

Verifying signatures

Anyone who learns your endpoint URL can post to it. Always verify the signature before trusting a payload.

Each delivery carries:

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

The signature is the base64 HMAC-SHA256 of "{webhook-id}.{webhook-timestamp}.{raw body}", keyed with the secret’s bytes: take your signing_secret, drop the whsec_ prefix and base64-decode the rest. Reject requests whose timestamp is more than 5 minutes from your clock to stop replays.

The SpacePost SDK verifies with WebCrypto and returns the parsed event. It throws if the signature or timestamp is invalid.

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 can be a Headers object or a plain object (for example Express’ req.headers). With Express, mount express.raw({ type: "application/json" }) on the webhook route so req.body is a Buffer.

With the standardwebhooks package:

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

With svix (or the resend package’s verifier): use the svix-* headers, which SpacePost also sends.

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

The official resend package’s webhook verification takes the same Standard Webhooks headers, so it works with your SpacePost signing_secret.

The signing_secret is fixed per webhook. To rotate, create a new webhook with the same endpoint, switch your handler to accept the new secret (try both during the changeover), then delete the old webhook.

Send an email to delivered@test.spacepost.dev with a tunnel URL registered as your endpoint. See Testing.