Webhook Verification
Verify HMAC signatures to confirm webhook payloads came from Sendry.
Every webhook request from Sendry carries an HMAC-SHA256 signature. Verifying it confirms the payload came from Sendry and was not tampered with in transit.
What Sendry sends
| Header | Value |
|---|---|
X-Sendry-Signature | Lowercase hex digest. No sha256= prefix, no other encoding. |
X-Sendry-Timestamp | Unix time in seconds when the delivery was signed. |
X-Sendry-Event | The event type, the same value as type in the body. |
Content-Type | application/json |
User-Agent | Sendry-Webhook/1.0 |
The signature is computed over the timestamp and the raw body joined by a period, not over the body alone:
signature = HMAC_SHA256(secret, "<X-Sendry-Timestamp>.<raw request body>")
Signing the timestamp along with the body is what makes the timestamp trustworthy: a replayed request cannot have its timestamp moved forward without invalidating the signature.
The body looks like this. The event type is type, and the recipient is to:
{
"type": "email.delivered",
"created_at": "2026-03-12T09:00:05Z",
"data": {
"email_id": "email_abc123",
"to": "user@example.com",
"timestamp": "2026-03-12T09:00:05Z"
}
}
How it works
- When you create a webhook, Sendry generates a signing secret (
whsec_...). - For each delivery it sends the headers above, signing
"<timestamp>.<body>"with that secret. - You recompute the HMAC over the timestamp header and the raw body, and compare using a constant-time comparison.
- You reject timestamps outside a tolerance window, typically five minutes.
Always use a constant-time comparison (timingSafeEqual, hmac.compare_digest,
hmac.Equal, hash_equals) rather than ==.
Verification examples
import { createHmac, timingSafeEqual } from "node:crypto";
const TOLERANCE_SECONDS = 5 * 60;
function verifyWebhookSignature(
rawBody: string,
signature: string,
timestamp: string,
secret: string
): boolean {
// Reject anything too old to be a live delivery, and anything from the
// future, before spending time on the HMAC.
const sent = Number(timestamp);
if (!Number.isFinite(sent)) return false;
if (Math.abs(Date.now() / 1000 - sent) > TOLERANCE_SECONDS) return false;
const hmac = createHmac("sha256", secret);
hmac.update(`${timestamp}.${rawBody}`);
const expected = hmac.digest("hex");
const a = Buffer.from(expected);
const b = Buffer.from(signature);
if (a.length !== b.length) return false;
return timingSafeEqual(a, b);
}Express handler:
import express from "express";
const app = express();
// Raw body parser: the signature covers the exact bytes Sendry sent.
app.post(
"/webhooks/sendry",
express.raw({ type: "application/json" }),
(req, res) => {
const rawBody = req.body.toString("utf-8");
const signature = req.headers["x-sendry-signature"] as string;
const timestamp = req.headers["x-sendry-timestamp"] as string;
const secret = process.env.SENDRY_WEBHOOK_SECRET!;
if (!verifyWebhookSignature(rawBody, signature, timestamp, secret)) {
return res.status(403).json({ error: "Invalid signature" });
}
const event = JSON.parse(rawBody);
console.log("Event type:", event.type);
console.log("Email ID:", event.data.email_id);
switch (event.type) {
case "email.delivered":
// Mark email as delivered in your DB
break;
case "email.bounced":
// Handle bounce; suppress the address if data.bounce_type is "permanent"
break;
case "email.opened":
// Record open event
break;
}
res.status(200).json({ ok: true });
}
);Next.js App Router handler:
// app/api/webhooks/sendry/route.ts
import { NextRequest, NextResponse } from "next/server";
export async function POST(request: NextRequest) {
const rawBody = await request.text();
const signature = request.headers.get("x-sendry-signature") ?? "";
const timestamp = request.headers.get("x-sendry-timestamp") ?? "";
const secret = process.env.SENDRY_WEBHOOK_SECRET!;
if (!verifyWebhookSignature(rawBody, signature, timestamp, secret)) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const event = JSON.parse(rawBody);
// Handle event...
return NextResponse.json({ ok: true });
}Important: use the raw body
Verify before JSON-decoding. The signature covers the exact bytes Sendry sent. Re-serializing a parsed object changes key order and whitespace, and the HMAC will not match. Buffer the raw body, verify, and only then parse.
The same applies to any proxy in front of your handler: if it rewrites the body,
verification fails. If it filters request headers, make sure
X-Sendry-Signature and X-Sendry-Timestamp are in the allowlist, and that the
URL you registered in Sendry is the tier that actually reaches your handler.
Replay attacks
X-Sendry-Timestamp is covered by the signature, so it can be trusted once the
signature checks out. Reject deliveries whose timestamp is more than a few
minutes from your clock (five is a common tolerance), as every example above
does.
Sendry retries a failed delivery, so your endpoint must be idempotent
regardless: deduplicate on data.email_id together with type, or record the
events you have already applied.
Rotating your webhook secret
If your secret is compromised, delete the webhook and create a new one. The new webhook gets a new signing secret.
// Delete old webhook
await sendry.webhooks.remove("wh_old");
// Create new webhook, new secret generated
const webhook = await sendry.webhooks.create({
url: "https://yourapp.com/webhooks/sendry",
events: ["email.delivered", "email.bounced"],
});
// Update SENDRY_WEBHOOK_SECRET in your environment
console.log("New secret:", webhook.secret);
Inbound email webhooks are signed differently
Forwarded inbound email (see Inbound) is signed over the raw body alone, with no timestamp:
signature = HMAC_SHA256(secret, "<raw request body>")
It uses the same X-Sendry-Signature header and the same bare hex digest, but
sends no X-Sendry-Timestamp. Verify inbound payloads with a separate function
rather than reusing the delivery-event one.
Testing webhooks locally
Use a tunneling tool like ngrok or Cloudflare Tunnel to expose your local server:
ngrok http 3000
Then create a webhook pointing to your tunnel URL and trigger test events from the dashboard.