Webhook Security

Verify webhook signatures to ensure events are from do.dev

Every webhook delivery from do.dev is signed using HMAC-SHA256 with your endpoint's signing secret. Always verify signatures before processing events.

Signature Format

Each delivery includes a signature header:

X-DoDevWebhook-Signature: t=1700000000,v1=5a4b3c2d1e0f...

The header contains:

  • t — Unix timestamp when the delivery was sent
  • v1 — HMAC-SHA256 hex digest of the signed payload

Verification Steps

  1. Extract the timestamp (t) and signature (v1) from the header
  2. Construct the signed payload: {timestamp}.{raw_body}
  3. Compute HMAC-SHA256 using your signing secret
  4. Compare the computed signature with v1
  5. Optionally, reject events older than 5 minutes (replay protection)

Code Examples

Node.js / TypeScript

import crypto from "crypto";

function verifyWebhookSignature(
  rawBody: string,
  signatureHeader: string,
  signingSecret: string,
  toleranceSeconds = 300
): boolean {
  // Parse the signature header
  const parts = Object.fromEntries(
    signatureHeader.split(",").map((part) => {
      const [key, value] = part.split("=");
      return [key, value];
    })
  );

  const timestamp = parts.t;
  const signature = parts.v1;

  if (!timestamp || !signature) {
    throw new Error("Invalid signature header format");
  }

  // Check timestamp tolerance (replay protection)
  const now = Math.floor(Date.now() / 1000);
  if (Math.abs(now - parseInt(timestamp)) > toleranceSeconds) {
    throw new Error("Webhook timestamp too old");
  }

  // Compute expected signature
  const signedPayload = `${timestamp}.${rawBody}`;
  const expected = crypto
    .createHmac("sha256", signingSecret)
    .update(signedPayload)
    .digest("hex");

  // Constant-time comparison
  return crypto.timingSafeEqual(
    Buffer.from(signature),
    Buffer.from(expected)
  );
}

Express.js

import express from "express";

const app = express();

// Important: use raw body for signature verification
app.post("/webhooks/dodev", express.raw({ type: "application/json" }), (req, res) => {
  const rawBody = req.body.toString();
  const signature = req.headers["x-dodevwebhook-signature"] as string;

  try {
    const valid = verifyWebhookSignature(rawBody, signature, process.env.WEBHOOK_SECRET!);
    if (!valid) {
      return res.status(401).send("Invalid signature");
    }
  } catch (err) {
    return res.status(401).send("Signature verification failed");
  }

  const event = JSON.parse(rawBody);
  console.log(`Received: ${event.type}`);

  // Process the event...

  res.status(200).send("OK");
});

Next.js (App Router)

import crypto from "crypto";
import { NextResponse } from "next/server";

export async function POST(request: Request) {
  const rawBody = await request.text();
  const signature = request.headers.get("x-dodevwebhook-signature");

  if (!signature) {
    return NextResponse.json({ error: "Missing signature" }, { status: 401 });
  }

  const valid = verifyWebhookSignature(
    rawBody,
    signature,
    process.env.WEBHOOK_SECRET!
  );

  if (!valid) {
    return NextResponse.json({ error: "Invalid signature" }, { status: 401 });
  }

  const event = JSON.parse(rawBody);

  switch (event.type) {
    case "send.email.bounced":
      await handleBounce(event.data);
      break;
    case "send.email.complained":
      await handleComplaint(event.data);
      break;
  }

  return NextResponse.json({ received: true });
}

Python

import hmac
import hashlib
import time

def verify_webhook(raw_body: str, signature_header: str, signing_secret: str, tolerance: int = 300) -> bool:
    # Parse signature header
    parts = dict(part.split("=", 1) for part in signature_header.split(","))
    timestamp = parts.get("t")
    signature = parts.get("v1")

    if not timestamp or not signature:
        raise ValueError("Invalid signature header")

    # Check timestamp tolerance
    if abs(time.time() - int(timestamp)) > tolerance:
        raise ValueError("Webhook timestamp too old")

    # Compute expected signature
    signed_payload = f"{timestamp}.{raw_body}"
    expected = hmac.new(
        signing_secret.encode(),
        signed_payload.encode(),
        hashlib.sha256,
    ).hexdigest()

    return hmac.compare_digest(signature, expected)

Flask

from flask import Flask, request, jsonify

app = Flask(__name__)

@app.route("/webhooks/dodev", methods=["POST"])
def handle_webhook():
    raw_body = request.get_data(as_text=True)
    signature = request.headers.get("X-DoDevWebhook-Signature")

    if not verify_webhook(raw_body, signature, WEBHOOK_SECRET):
        return jsonify({"error": "Invalid signature"}), 401

    event = request.get_json()
    print(f"Received: {event['type']}")

    return jsonify({"received": True}), 200

Go

package main

import (
    "crypto/hmac"
    "crypto/sha256"
    "encoding/hex"
    "fmt"
    "math"
    "strconv"
    "strings"
    "time"
)

func verifyWebhook(rawBody, signatureHeader, signingSecret string) (bool, error) {
    // Parse signature header
    parts := make(map[string]string)
    for _, part := range strings.Split(signatureHeader, ",") {
        kv := strings.SplitN(part, "=", 2)
        if len(kv) == 2 {
            parts[kv[0]] = kv[1]
        }
    }

    timestamp := parts["t"]
    signature := parts["v1"]

    // Check timestamp tolerance (5 minutes)
    ts, _ := strconv.ParseInt(timestamp, 10, 64)
    if math.Abs(float64(time.Now().Unix()-ts)) > 300 {
        return false, fmt.Errorf("webhook timestamp too old")
    }

    // Compute expected signature
    signedPayload := fmt.Sprintf("%s.%s", timestamp, rawBody)
    mac := hmac.New(sha256.New, []byte(signingSecret))
    mac.Write([]byte(signedPayload))
    expected := hex.EncodeToString(mac.Sum(nil))

    return hmac.Equal([]byte(signature), []byte(expected)), nil
}

Security Best Practices

  1. Always verify signatures — Never process a webhook without verifying the HMAC signature
  2. Use timestamp validation — Reject events older than 5 minutes to prevent replay attacks
  3. Use constant-time comparison — Prevent timing attacks when comparing signatures (crypto.timingSafeEqual in Node.js, hmac.compare_digest in Python)
  4. Use HTTPS only — do.dev rejects webhook endpoints that don't use HTTPS
  5. Store secrets securely — Keep your signing secret in environment variables, not in code
  6. Respond quickly — Return a 2xx response within 15 seconds. Process events asynchronously if needed
  7. Handle events idempotently — You may receive the same event more than once (retries). Use eventId to deduplicate