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
- When you create a webhook, Sendr generates a unique signing secret (
whsec_...) - For each delivery, Sendr computes
HMAC-SHA256(rawBody, secret)and includes it in the header assha256=<hex> - 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:
- Checking the
created_atfield in the payload and rejecting events older than 5 minutes - 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.