Sendry Docs
Guides

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

HeaderValue
X-Sendry-SignatureLowercase hex digest. No sha256= prefix, no other encoding.
X-Sendry-TimestampUnix time in seconds when the delivery was signed.
X-Sendry-EventThe event type, the same value as type in the body.
Content-Typeapplication/json
User-AgentSendry-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

  1. When you create a webhook, Sendry generates a signing secret (whsec_...).
  2. For each delivery it sends the headers above, signing "<timestamp>.<body>" with that secret.
  3. You recompute the HMAC over the timestamp header and the raw body, and compare using a constant-time comparison.
  4. 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.

On this page