Sendr Docs
Guides

Webhook Verification

Verify HMAC signatures to confirm webhook payloads came from Sendr.

Every webhook request from Sendr includes a Sendr-Signature header containing an HMAC-SHA256 signature of the raw request body. Verifying this signature confirms the payload is authentic and was not tampered with.

How it works

  1. When you create a webhook, Sendr generates a unique signing secret (whsec_...)
  2. For each delivery, Sendr computes HMAC-SHA256(rawBody, secret) and includes it in the header as sha256=<hex>
  3. You recompute the HMAC on your side and compare using a constant-time comparison

Always use a constant-time comparison (like timingSafeEqual or hmac.Equal) to prevent timing attacks.

Verification examples

import { createHmac, timingSafeEqual } from "node:crypto";

function verifyWebhookSignature(
  rawBody: string,
  signature: string,
  secret: string
): boolean {
  const hmac = createHmac("sha256", secret);
  hmac.update(rawBody);
  const expected = `sha256=${hmac.digest("hex")}`;

  // Constant-time comparison to prevent timing attacks
  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();

// Use raw body parser for the webhook route
app.post(
  "/webhooks/sendr",
  express.raw({ type: "application/json" }),
  (req, res) => {
    const rawBody = req.body.toString("utf-8");
    const signature = req.headers["sendr-signature"] as string;
    const secret = process.env.SENDR_WEBHOOK_SECRET!;

    if (!verifyWebhookSignature(rawBody, signature, secret)) {
      return res.status(403).json({ error: "Invalid signature" });
    }

    const event = JSON.parse(rawBody);
    console.log("Event type:", event.event);
    console.log("Email ID:", event.data.email_id);

    switch (event.event) {
      case "email.delivered":
        // Mark email as delivered in your DB
        break;
      case "email.bounced":
        // Handle bounce — suppress address if permanent
        break;
      case "email.opened":
        // Record open event
        break;
    }

    res.status(200).json({ ok: true });
  }
);

Next.js App Router handler:

// app/api/webhooks/sendr/route.ts
import { createHmac, timingSafeEqual } from "node:crypto";
import { NextRequest, NextResponse } from "next/server";

export async function POST(request: NextRequest) {
  const rawBody = await request.text();
  const signature = request.headers.get("sendr-signature") ?? "";
  const secret = process.env.SENDR_WEBHOOK_SECRET!;

  if (!verifyWebhookSignature(rawBody, signature, secret)) {
    return NextResponse.json({ error: "Forbidden" }, { status: 403 });
  }

  const event = JSON.parse(rawBody);
  // Handle event...

  return NextResponse.json({ ok: true });
}
import hashlib
import hmac
import os

def verify_webhook_signature(
    raw_body: bytes | str,
    signature: str,
    secret: str,
) -> bool:
    if isinstance(raw_body, str):
        raw_body = raw_body.encode("utf-8")

    expected = "sha256=" + hmac.new(
        key=secret.encode("utf-8"),
        msg=raw_body,
        digestmod=hashlib.sha256,
    ).hexdigest()

    # Constant-time comparison
    return hmac.compare_digest(expected, signature)

FastAPI handler:

from fastapi import FastAPI, Request, HTTPException
import json

app = FastAPI()

@app.post("/webhooks/sendr")
async def handle_webhook(request: Request):
    raw_body = await request.body()
    signature = request.headers.get("sendr-signature", "")
    secret = os.environ["SENDR_WEBHOOK_SECRET"]

    if not verify_webhook_signature(raw_body, signature, secret):
        raise HTTPException(status_code=403, detail="Invalid signature")

    event = json.loads(raw_body)
    event_type = event["event"]
    email_id = event["data"]["email_id"]

    if event_type == "email.delivered":
        # Mark as delivered
        pass
    elif event_type == "email.bounced":
        bounce_type = event["data"].get("bounce_type")
        if bounce_type == "permanent":
            # Add to suppression list
            pass

    return {"ok": True}

Django handler:

# views.py
from django.http import JsonResponse
from django.views.decorators.csrf import csrf_exempt
import json

@csrf_exempt
def sendr_webhook(request):
    if request.method != "POST":
        return JsonResponse({"error": "Method not allowed"}, status=405)

    raw_body = request.body
    signature = request.headers.get("Sendr-Signature", "")
    secret = os.environ["SENDR_WEBHOOK_SECRET"]

    if not verify_webhook_signature(raw_body, signature, secret):
        return JsonResponse({"error": "Forbidden"}, status=403)

    event = json.loads(raw_body)
    # Handle event...

    return JsonResponse({"ok": True})
package webhook

import (
    "crypto/hmac"
    "crypto/sha256"
    "encoding/hex"
    "io"
    "net/http"
    "os"
)

func verifyWebhookSignature(rawBody []byte, signature, secret string) bool {
    mac := hmac.New(sha256.New, []byte(secret))
    mac.Write(rawBody)
    expected := "sha256=" + hex.EncodeToString(mac.Sum(nil))
    // hmac.Equal does constant-time comparison
    return hmac.Equal([]byte(expected), []byte(signature))
}

func WebhookHandler(w http.ResponseWriter, r *http.Request) {
    body, err := io.ReadAll(r.Body)
    if err != nil {
        http.Error(w, "Bad request", http.StatusBadRequest)
        return
    }
    defer r.Body.Close()

    signature := r.Header.Get("Sendr-Signature")
    secret := os.Getenv("SENDR_WEBHOOK_SECRET")

    if !verifyWebhookSignature(body, signature, secret) {
        http.Error(w, "Forbidden", http.StatusForbidden)
        return
    }

    // Parse the event
    var event struct {
        Event     string         `json:"event"`
        CreatedAt string         `json:"created_at"`
        Data      map[string]any `json:"data"`
    }
    if err := json.Unmarshal(body, &event); err != nil {
        http.Error(w, "Bad request", http.StatusBadRequest)
        return
    }

    switch event.Event {
    case "email.delivered":
        // Handle delivery
    case "email.bounced":
        // Handle bounce
    case "email.opened":
        // Handle open
    }

    w.WriteHeader(http.StatusOK)
}
<?php

function verifyWebhookSignature(
    string $rawBody,
    string $signature,
    string $secret
): bool {
    $expected = 'sha256=' . hash_hmac('sha256', $rawBody, $secret);
    return hash_equals($expected, $signature);
}

// In your controller/route:
$rawBody = file_get_contents('php://input');
$signature = $_SERVER['HTTP_SENDR_SIGNATURE'] ?? '';
$secret = getenv('SENDR_WEBHOOK_SECRET');

if (!verifyWebhookSignature($rawBody, $signature, $secret)) {
    http_response_code(403);
    echo json_encode(['error' => 'Forbidden']);
    exit;
}

$event = json_decode($rawBody, true);
$eventType = $event['event'];
$emailId = $event['data']['email_id'];

switch ($eventType) {
    case 'email.delivered':
        // Handle delivery
        break;
    case 'email.bounced':
        // Handle bounce
        break;
}

http_response_code(200);
echo json_encode(['ok' => true]);

Important: use raw body

Parse the signature before JSON-decoding the body. The signature is computed on the raw bytes of the request body. If you parse JSON first, whitespace differences can cause the verification to fail.

Always buffer the raw body and compute the HMAC before calling JSON.parse().

Rotating your webhook secret

If your secret is compromised, delete the webhook and create a new one. The new webhook will have a new signing secret.

// Delete old webhook
await sendr.webhooks.remove("wh_old");

// Create new webhook — new secret generated
const webhook = await sendr.webhooks.create({
  url: "https://yourapp.com/webhooks/sendr",
  events: ["email.delivered", "email.bounced"],
});

// Update SENDR_WEBHOOK_SECRET in your environment
console.log("New secret:", webhook.secret);

Replay attacks

Sendr does not currently include a timestamp in the signature. To protect against replay attacks, consider:

  1. Checking the created_at field in the payload and rejecting events older than 5 minutes
  2. Storing processed event IDs and deduplicating

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