Webhooks
Verifying signatures
Confirm that every webhook request really came from Deckle by validating its signature before you trust the payload.
Your webhook endpoint is a public URL, so anyone can POST to it. Deckle signs each delivery so you can prove the request came from us and the body wasn't tampered with. Verify the signature on every request and reject anything that doesn't match.
Why verify#
Because your endpoint is reachable by anyone, a signature is the only thing that distinguishes a genuine Deckle delivery from a forged one. Verifying it lets you trust that the event is real before you act on it — updating a record, suppressing a contact, or firing a downstream job. Skip verification and an attacker can replay or fake events against your system.
Each webhook endpoint you register in the dashboard has its own signing secret. Deckle shows it once, prefixed with whsec_. Store it as an environment variable and never commit it. See the webhooks overview for how to register an endpoint and retrieve the secret.
The signature#
Every delivery includes an X-Deckle-Signature header. Its value is the HMAC-SHA256 of the raw request body, keyed with your endpoint's whsec_ secret, encoded as lowercase hex. Deckle also sends an X-Deckle-Event header naming the event type.
To verify, you recompute the HMAC over the exact bytes you received and compare it to the header. If they match, the request is authentic and unmodified.
Verify against the raw body
Always compute the HMAC over the raw request body, exactly as received, before callingJSON.parse. Parsing and re-serializing can reorder keys or change whitespace, which produces a different signature and makes every verification fail.Verify with the SDK#
The Node SDK ships a verifyWebhookSignature helper. Read the raw body as text, pass it with the signature header and your secret, and it returns whether the request is valid. In a Next.js Route Handler, calling req.text() gives you the raw body directly — do that before you parse.
import { verifyWebhookSignature } from "@deckle/sdk";
export async function POST(req: Request) {
// Read the RAW body first — do not parse before verifying.
const body = await req.text();
const signature = req.headers.get("x-deckle-signature");
const secret = process.env.DECKLE_WEBHOOK_SECRET!;
if (!verifyWebhookSignature(body, signature, secret)) {
return new Response("Invalid signature", { status: 401 });
}
// Signature is valid — now it's safe to parse.
const event = JSON.parse(body);
switch (event.type) {
case "email.delivered":
// handle delivery
break;
case "email.bounced":
// handle bounce
break;
}
return new Response("ok", { status: 200 });
}Return a 2xx as soon as you've verified and accepted the event. Deckle treats any non-2xx response as a failed delivery.
Verify manually#
You don't need the SDK — the check is a few lines with Node's built-in crypto module. Recompute the HMAC-SHA256 hex digest of the raw body with your secret, then compare it to the header using a timing-safe comparison so you don't leak information through response timing.
import { createHmac, timingSafeEqual } from "node:crypto";
export function verify(rawBody: string, signature: string | null, secret: string) {
if (!signature) return false;
const expected = createHmac("sha256", secret)
.update(rawBody, "utf8")
.digest("hex");
const a = Buffer.from(expected, "hex");
const b = Buffer.from(signature, "hex");
// Bail early if lengths differ — timingSafeEqual throws otherwise.
if (a.length !== b.length) return false;
return timingSafeEqual(a, b);
}Getting the raw body#
The signature only matches if you hash the untouched bytes. In frameworks that eagerly parse JSON (like Express with express.json()), disable body parsing for the webhook route or capture the raw buffer first — otherwise the parsed-and-reserialized body won't match the signature.