Mailerflow Start free
Blog › Transactional Email & Developer Tools › Email Webhooks

How to Test Webhooks Locally: Tunnels, Replays and Fixtures

Published October 18, 2026 · 9 min read

Illustration for How to Test Webhooks Locally: Tunnels, Replays and Fixtures

To test webhooks locally you need two things: a way for the provider's servers to reach your machine (a tunnel, a capture tool, or replayed fixtures) and a local receiver that verifies the signature before it trusts the payload. This guide walks through all three approaches with runnable code, then covers the parts most tutorials skip — retries, duplicate delivery, idempotency and the error handling that keeps a webhook consumer alive under real traffic.

What You Need Before You Start

  • Node.js 20+ (or Python 3.11+ if you prefer the Flask variant below)
  • A local HTTP server bound to a fixed port, e.g. 3000
  • A tunnel tool: ngrok, Cloudflare Tunnel, or any equivalent
  • curl for manual replays
  • A Mailerflow account and an API key

One constraint shapes everything else: webhook endpoints have to be publicly reachable over HTTPS, because the provider makes an outbound request from its own infrastructure. 127.0.0.1 is not routable from the internet, so you either expose your port or you stop trying to receive live events and replay them instead.

Mailerflow's webhooks are signed and include retries and delivery logs. That combination is useful during development: you can compare what your local server actually received against the delivery log to see whether a missing event was never sent, failed signature verification, or was retried after your handler returned a non-2xx.

Why Localhost Can't Receive Webhooks

The provider's servers open an HTTP connection to a URL you registered. If that URL points at localhost, the connection is attempted from outside your network and fails. There is no configuration flag that fixes this — the request has to land on something with a public address.

Three approaches cover most local testing needs:

A tunnel forwards a public HTTPS URL to a port on your machine. The provider delivers to the tunnel, the tunnel forwards to localhost:3000, and your handler sees a real request with real headers. This is the only option that tests the complete delivery path end to end, including TLS, headers and timing.

A hosted capture tool gives you a public URL that stores incoming events instead of forwarding them immediately. You then trigger a forward to localhost on demand. Useful when you want to inspect a payload before your code touches it, or when your local server isn't running yet.

Fixtures are JSON payloads — hand-written or copied from a real delivery — that you POST to your local server yourself. No tunnel, no network, fully deterministic. This is what you want for unit tests and CI.

Exposing Localhost with a Tunnel

Install ngrok and start it against your server's port:

ngrok http 3000

ngrok prints a forwarding URL such as https://a1b2c3.ngrok.app. Register that plus your path as the webhook endpoint in Mailerflow:

https://a1b2c3.ngrok.app/webhooks/mailerflow

Now trigger an event — send a transactional email to an address you control — and watch the request arrive in your terminal.

Two things bite people here. First, free-tier tunnel URLs are typically ephemeral and may change on restart, so the endpoint you registered goes stale and events silently fail. Either re-register the endpoint after each restart or use a reserved domain so the URL is stable. Second, some tunnels and reverse proxies buffer or rewrite request bodies. Signature verification is computed over the exact raw bytes, so if a proxy re-encodes the JSON, verification fails even though the secret is correct. Verify the raw body your handler sees matches what the provider sent before you debug anything else.

Cloudflare Tunnel is an equivalent alternative:

cloudflared tunnel --url http://localhost:3000

It prints a trycloudflare.com URL you can register the same way. The same caveats apply: quick tunnels are ephemeral, and you should confirm the body arrives byte-for-byte.

A Minimal Local Receiver That Verifies Signatures

One of the most frequent causes of signature failures is parsing the body to JSON before verifying. Once parsed, the original bytes are gone and the HMAC can't be reproduced. In Express, that means express.raw(), not express.json(), on the webhook route.

The header names and signing scheme below are placeholders — check your provider's docs for the exact header names, timestamp format and HMAC construction before you implement against them. The structure of the check (raw bytes, timestamp window, constant-time comparison) is what matters.

// server.js — Node 20+, Express 4
import express from 'express';
import crypto from 'node:crypto';

const app = express();
const SECRET = process.env.MAILERFLOW_WEBHOOK_SECRET;
const MAX_SKEW_SECONDS = 300;

// Raw body is required: the signature is computed over the exact bytes sent.
app.post(
  '/webhooks/mailerflow',
  express.raw({ type: 'application/json' }),
  (req, res) => {
    // Placeholder header names — confirm against your provider's docs.
    const signature = req.get('x-mailerflow-signature');
    const timestamp = req.get('x-mailerflow-timestamp');

    if (!signature || !timestamp) {
      return res.status(400).json({ error: 'missing signature headers' });
    }

    // Reject stale deliveries to limit replay attacks.
    const age = Math.abs(Date.now() / 1000 - Number(timestamp));
    if (!Number.isFinite(age) || age > MAX_SKEW_SECONDS) {
      return res.status(401).json({ error: 'timestamp out of range' });
    }

    const expected = crypto
      .createHmac('sha256', SECRET)
      .update(`${timestamp}.`)
      .update(req.body)
      .digest('hex');

    const a = Buffer.from(expected, 'utf8');
    const b = Buffer.from(signature, 'utf8');
    if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
      return res.status(401).json({ error: 'invalid signature' });
    }

    let event;
    try {
      event = JSON.parse(req.body.toString('utf8'));
    } catch {
      // Malformed payload: log it, but don't ask for a retry.
      console.error('unparseable payload');
      return res.status(200).json({ received: true });
    }

    console.log('event', event.id, event.type);
    // Hand off to your queue here, then respond fast.
    res.status(200).json({ received: true });
  }
);

app.listen(3000, () => console.log('listening on :3000'));

Three details matter. express.raw() preserves the bytes. crypto.timingSafeEqual avoids leaking information through comparison timing. And the timestamp check bounds how long a captured request stays replayable.

If you're on Python, the equivalent with Flask:

# app.py — Python 3.11+, Flask 3
import hashlib, hmac, json, os, time
from flask import Flask, request, jsonify

app = Flask(__name__)
SECRET = os.environ["MAILERFLOW_WEBHOOK_SECRET"].encode()

@app.post("/webhooks/mailerflow")
def webhook():
    raw = request.get_data()  # raw bytes, not request.json
    # Placeholder header names — confirm against your provider's docs.
    signature = request.headers.get("X-Mailerflow-Signature", "")
    timestamp = request.headers.get("X-Mailerflow-Timestamp", "")

    if not signature or not timestamp:
        return jsonify(error="missing signature headers"), 400

    if abs(time.time() - float(timestamp)) > 300:
        return jsonify(error="timestamp out of range"), 401

    expected = hmac.new(
        SECRET, f"{timestamp}.".encode() + raw, hashlib.sha256
    ).hexdigest()

    if not hmac.compare_digest(expected, signature):
        return jsonify(error="invalid signature"), 401

    event = json.loads(raw)
    print("event", event.get("id"), event.get("type"))
    return jsonify(received=True), 200

Replaying Real Events and Building Fixtures

Once the handler works against live traffic, stop depending on live traffic. Two techniques get you there.

Replay from the delivery log. Mailerflow's delivery log shows each webhook attempt and the response it received. Use it to confirm what was sent and when, then reconstruct a fixture from the payload your handler logged on the successful attempt. You sign the fixture yourself, since you're now the sender:

// sign-and-post.mjs
import crypto from 'node:crypto';
import { readFile } from 'node:fs/promises';

const secret = process.env.MAILERFLOW_WEBHOOK_SECRET;
const body = await readFile(process.argv[2]);
const timestamp = Math.floor(Date.now() / 1000).toString();
const signature = crypto
  .createHmac('sha256', secret)
  .update(`${timestamp}.`)
  .update(body)
  .digest('hex');

const res = await fetch('http://localhost:3000/webhooks/mailerflow', {
  method: 'POST',
  headers: {
    'content-type': 'application/json',
    'x-mailerflow-signature': signature,
    'x-mailerflow-timestamp': timestamp,
  },
  body,
});

console.log(res.status, await res.text());

Run it with node sign-and-post.mjs test/fixtures/delivered.json.

Fixtures. Save captured payloads under test/fixtures/ and test the handler directly — no HTTP, no tunnel, no network. Keep one fixture per event type you actually branch on: delivered, bounced, complained, opened, clicked, unsubscribed. Those are the events where a wrong branch causes real damage, like suppressing an address on a soft bounce or failing to suppress on a complaint.

// handler.test.js — Vitest
import { describe, it, expect, vi } from 'vitest';
import { handleEvent } from '../src/handle-event.js';
import delivered from './fixtures/delivered.json' assert { type: 'json' };
import complained from './fixtures/complained.json' assert { type: 'json' };

describe('handleEvent', () => {
  it('records a delivery', async () => {
    const store = { record: vi.fn() };
    await handleEvent(delivered, store);
    expect(store.record).toHaveBeenCalledWith(
      expect.objectContaining({ type: 'delivered' })
    );
  });

  it('suppresses on complaint', async () => {
    const store = { suppress: vi.fn() };
    await handleEvent(complained, store);
    expect(store.suppress).toHaveBeenCalled();
  });
});

Fixtures are deterministic, which is what makes them the right tool for CI.

Testing Retries, Duplicates and Idempotency

Webhook providers retry on non-2xx responses and on timeouts. That means your handler will see the same event more than once, and it has to be safe when it does.

Test it deliberately. Add a counter or an env flag that makes the handler fail the first N attempts:

let failuresLeft = Number(process.env.FAIL_FIRST ?? 0);

app.post('/webhooks/mailerflow', express.raw({ type: 'application/json' }), (req, res) => {
  if (failuresLeft > 0) {
    failuresLeft -= 1;
    return res.status(500).json({ error: 'simulated failure' });
  }
  // ...verify and process
});

Start with FAIL_FIRST=2, trigger one event, and confirm you see three requests: two failures and one success. Then confirm your dedupe logic means the successful one doesn't double-process.

The standard idempotency pattern is a unique constraint on the event ID:

async function alreadyProcessed(eventId, store) {
  // Postgres: INSERT ... ON CONFLICT DO NOTHING and check rowCount.
  // Redis: SET key NX EX 86400 — returns null if the key existed.
  const inserted = await store.insertEventOnce(eventId);
  return !inserted;
}

async function onEvent(event, store) {
  if (await alreadyProcessed(event.id, store)) {
    return; // duplicate delivery, nothing to do
  }
  await doTheWork(event);
}

Also test the timeout path. If your handler does slow work synchronously — a database write, an API call, a PDF render — the provider may time out and retry while your first attempt is still running. Respond 200 immediately and push the work onto a queue. The retry then finds the event already queued and skips it.

Error Handling That Survives Real Traffic

These rules prevent the most common production failures.

Return 2xx fast. Anything else signals failure and triggers a retry. If your handler is slow, the provider may time out and retry while your first attempt is still running.

Separate permanent from transient failures. A malformed payload or an event type you don't handle will never succeed no matter how many times it's retried. Log it and return 200. A database that's briefly down will succeed on retry, so return 500 and let the retry happen.

Wrap everything in try/catch. An unhandled exception turns into a 500, which turns into an infinite retry loop for a payload that can never be processed.

Log consistently. Record the event ID, event type and a correlation ID on every request. When something goes missing, you can match your local logs against the Mailerflow delivery log and see exactly which attempt failed and why. For a structured walkthrough of the usual causes, see Debugging Webhook Deliveries: Why Email Events Go Missing.

From Localhost to Production

Before you switch off the tunnel:

  • Replace the tunnel URL with your real HTTPS endpoint and re-register it.
  • Move the signing secret out of .env and into your secret manager; rotate it if it was ever committed.
  • Keep signature verification enabled in production — it's the only thing distinguishing a real delivery from anyone who finds your endpoint.
  • Keep idempotency enabled. Retries happen in production too.
  • Confirm your endpoint returns 200 quickly under load, not just on your laptop.

After deploy, use Mailerflow's delivery logs to confirm events are arriving and to inspect failed attempts. The same discipline applies to the calls you make back to the API: Mailerflow's REST API uses scoped keys and idempotency keys, so a retried request doesn't create a duplicate. The developer docs cover the OpenAPI spec, code samples and the JS SDK if you want to generate a typed client instead of hand-rolling requests.

Troubleshooting Common Problems

  • Signature mismatch. Almost always the body was parsed before verification, or a proxy altered it. Check the raw bytes first, the secret second.
  • No events arriving. The tunnel is down, the registered URL is stale after a restart, or you're not subscribed to that event type.
  • Duplicate processing. No idempotency key on the event ID. Add a unique constraint or a Redis SET NX.
  • Timeouts and retries. The handler is doing slow work synchronously. Queue it and return 200.
  • 401 loops on valid events. Clock skew on the timestamp check. Widen the window slightly or sync your server's clock.

FAQ

Do I need a tunnel, or can I use a public test endpoint? A public test endpoint works if you only need to see payloads, but it won't exercise your actual handler. Use a tunnel when you need end-to-end testing of the real delivery path, and fixtures when you need speed and determinism.

How do I test webhooks in CI without a tunnel? Call your handler function directly with fixture payloads, or start your server on a local port and POST signed fixtures to it. Both avoid the network entirely, which is what makes them reliable in CI.

Why does my signature verification fail even though the secret is right? The body your handler hashed isn't the body that was signed. That happens when a framework parses JSON before verification, or when a proxy re-encodes the request. Verify against the raw bytes.

How many times will a failed webhook be retried? Mailerflow retries failed webhook deliveries, and the delivery log shows each attempt. Check the delivery log for the specific event rather than assuming a fixed count, and make your handler idempotent so the answer doesn't matter.

Can I trigger a test event without sending a real email? You can POST a signed fixture to your local endpoint, or reconstruct one from a payload your handler logged during a real delivery. Both let you exercise the handler without generating new email activity.


Send your first transactional email with the Mailerflow REST API — scoped keys, idempotency and an OpenAPI spec. Start free.