Webhook Testing: The Complete Guide for Developers in 2026

Webhook Testing: The Complete Guide for Developers in 2026

Webhooks are notoriously hard to test because they're triggered by external events, delivered asynchronously, and come with failure modes like duplicate delivery, out-of-order events, and retry storms. This guide covers five testing strategies, plus how to test idempotency, retry logic, signature verification, and failure scenarios with Node.js code examples.

Key Takeaways

Webhooks fail in production in ways unit tests can't catch. Duplicate delivery, retry storms, and signature verification failures are integration-level problems that need integration-level tests. Test idempotency before you need it. Stripe, GitHub, and every major webhook sender retries on non-2xx responses — your handler must be idempotent or you'll process events multiple times. HMAC signature verification is a security requirement, not an optional check. Test it explicitly, including the failure case.

Webhooks look simple on paper: an external service sends an HTTP POST to your endpoint when something happens. In practice, webhook handling is one of the most failure-prone areas in backend development.

Consider what can go wrong:

  • Your endpoint is down when the event arrives — the sender retries, you process it twice
  • The event arrives out of order — you process a payment.updated before payment.created
  • The sender delivers the same event multiple times (idempotency failure)
  • An attacker sends fake webhook payloads (signature verification failure)
  • Your handler crashes partway through, leaving the system in an inconsistent state
  • The retry logic sends exponential traffic during an outage, overwhelming your recovery

Testing webhooks properly means testing all of these failure modes, not just the happy path.

The Five Webhook Testing Strategies

Strategy 1: Mock Webhook Sender

For unit and integration tests, you don't need a real external service to send you events. Build a mock sender that simulates the exact payload format and signature scheme of the real sender.

// tests/helpers/mockWebhookSender.js
const crypto = require("crypto");
const axios = require("axios");

class MockWebhookSender {
  constructor(signingSecret, endpointUrl) {
    this.signingSecret = signingSecret;
    this.endpointUrl = endpointUrl;
  }

  sign(payload, timestamp) {
    const signedPayload = `${timestamp}.${JSON.stringify(payload)}`;
    return crypto
      .createHmac("sha256", this.signingSecret)
      .update(signedPayload)
      .digest("hex");
  }

  async send(eventType, payload, options = {}) {
    const timestamp = options.timestamp || Math.floor(Date.now() / 1000);
    const body = {
      id: options.eventId || `evt_${crypto.randomBytes(8).toString("hex")}`,
      type: eventType,
      created: timestamp,
      data: { object: payload }
    };

    const signature = this.sign(body, timestamp);

    const response = await axios.post(this.endpointUrl, body, {
      headers: {
        "Content-Type": "application/json",
        "X-Webhook-Signature": `t=${timestamp},v1=${signature}`,
        "X-Webhook-ID": body.id
      }
    });

    return response;
  }

  async sendDuplicate(eventType, payload) {
    const eventId = `evt_${crypto.randomBytes(8).toString("hex")}`;
    const first = await this.send(eventType, payload, { eventId });
    const second = await this.send(eventType, payload, { eventId }); // same ID
    return { first, second };
  }
}

module.exports = MockWebhookSender;

Use it in tests:

const MockWebhookSender = require("./helpers/mockWebhookSender");

const sender = new MockWebhookSender(
  process.env.WEBHOOK_SIGNING_SECRET,
  "http://localhost:3000/webhooks/payment"
);

test("processes payment.succeeded event", async () => {
  const response = await sender.send("payment.succeeded", {
    amount: 5000,
    currency: "usd",
    customer: "cus_123"
  });
  expect(response.status).toBe(200);
  
  // Verify the effect in your system
  const order = await Order.findByCustomerId("cus_123");
  expect(order.status).toBe("paid");
});

Strategy 2: Request Inspection with smee.io

For development, smee.io provides a public URL that forwards requests to your localhost. This lets you trigger real webhook events from services like Stripe or GitHub and receive them on your local machine.

# Install smee client
npm install --global smee-client

<span class="hljs-comment"># Start forwarding to your local webhook handler
smee --url https://smee.io/your-unique-id --path /webhooks --port 3000

smee.io keeps a log of all requests so you can replay them — useful for debugging handling logic after the fact.

Strategy 3: Local Tunnel with ngrok

For testing with real external services, ngrok gives your local server a public HTTPS URL:

ngrok http 3000
# → Forwarding https://abc123.ngrok-free.app → localhost:3000

Configure this URL as your webhook endpoint in the external service's dashboard. Every real event will hit your local handler.

Strategy 4: Replay Tools

Hookdeck and similar tools record incoming webhooks and let you replay them. This is valuable for:

  • Testing handling logic changes against real historical payloads
  • Debugging failures without waiting for the external service to send again
  • Load testing by replaying a burst of events

Strategy 5: Contract Tests

Contract tests verify that your handler correctly processes the payload schema documented by the external service. Validate every incoming webhook against the provider's schema before processing:

const Ajv = require("ajv");
const stripePaymentSchema = require("./schemas/stripe-payment-succeeded.json");

function validateWebhookPayload(payload, schema) {
  const ajv = new Ajv();
  const validate = ajv.compile(schema);
  const valid = validate(payload);
  if (!valid) {
    throw new Error(`Invalid webhook payload: ${JSON.stringify(validate.errors)}`);
  }
}

// In your webhook handler:
app.post("/webhooks/stripe", (req, res) => {
  try {
    validateWebhookPayload(req.body, stripePaymentSchema);
    // ... process event
    res.sendStatus(200);
  } catch (err) {
    console.error("Webhook validation failed:", err.message);
    res.status(400).json({ error: err.message });
  }
});

Testing Idempotency

Every webhook handler must be idempotent. Stripe retries events on non-2xx responses. GitHub retries failed deliveries. Any sender worth using retries — and your handler will receive the same event multiple times in production.

// tests/webhook.idempotency.test.js
const { setupTestDb, clearTestDb } = require("./helpers/db");
const MockWebhookSender = require("./helpers/mockWebhookSender");
const app = require("../src/app");

describe("Webhook idempotency", () => {
  let server, sender;
  
  beforeAll(async () => {
    await setupTestDb();
    server = app.listen(3001);
    sender = new MockWebhookSender(
      process.env.WEBHOOK_SIGNING_SECRET,
      "http://localhost:3001/webhooks/payment"
    );
  });
  
  afterEach(clearTestDb);
  afterAll(() => server.close());

  test("duplicate event delivery creates only one record", async () => {
    const eventId = "evt_idempotency_test_001";
    const payload = { amount: 5000, currency: "usd", customer: "cus_idem_test" };

    // Send same event twice (simulates retry)
    const r1 = await sender.send("payment.succeeded", payload, { eventId });
    const r2 = await sender.send("payment.succeeded", payload, { eventId });

    expect(r1.status).toBe(200);
    expect(r2.status).toBe(200); // Must not error on duplicate

    // Only one payment record should exist
    const payments = await Payment.findAll({ where: { idempotency_key: eventId } });
    expect(payments).toHaveLength(1);
    expect(payments[0].amount).toBe(5000);
  });

  test("processing 10 duplicates is safe", async () => {
    const eventId = "evt_storm_test_001";
    const payload = { amount: 1000, customer: "cus_storm" };
    
    // Simulate retry storm
    const responses = await Promise.all(
      Array(10).fill(null).map(() =>
        sender.send("payment.succeeded", payload, { eventId })
      )
    );
    
    // All should succeed
    expect(responses.every(r => r.status === 200)).toBe(true);
    
    // Still only one payment
    const count = await Payment.count({ where: { idempotency_key: eventId } });
    expect(count).toBe(1);
  });
});

The typical implementation uses the webhook event ID as an idempotency key in the database:

async function handlePaymentSucceeded(event) {
  const { id: eventId, data: { object: payment } } = event;
  
  // Check for duplicate
  const existing = await ProcessedEvent.findOne({ where: { event_id: eventId } });
  if (existing) {
    console.log(`Duplicate event ${eventId} — skipping`);
    return;
  }
  
  // Process in transaction
  await db.transaction(async (t) => {
    await Payment.create({ 
      amount: payment.amount,
      customer_id: payment.customer,
      idempotency_key: eventId
    }, { transaction: t });
    
    await ProcessedEvent.create({ event_id: eventId }, { transaction: t });
  });
}

Testing Retry Logic

Test that your system correctly handles the retry behavior of each webhook sender. The key scenarios:

  • Handler returns 5xx → sender retries → handler processes successfully on retry
  • Handler times out → sender retries → handler is idempotent so no double-processing
  • Sender gives up after N retries → your system is in a known partial state
test("recovers correctly when first delivery times out", async () => {
  let callCount = 0;
  
  // Mock handler that fails the first time
  app.post("/webhooks/test-retry", async (req, res) => {
    callCount++;
    if (callCount === 1) {
      // Simulate timeout by not responding for 30s
      await new Promise(resolve => setTimeout(resolve, 100));
      res.status(503).send("Temporarily unavailable");
    } else {
      // Process normally on retry
      await processEvent(req.body);
      res.sendStatus(200);
    }
  });
  
  // First delivery fails, second succeeds
  await sender.send("order.created", { id: "order_retry_001" });
  // Wait for retry (in test, use a faster retry interval)
  await sleep(200);
  await sender.send("order.created", { id: "order_retry_001" }); // retry
  
  const order = await Order.findById("order_retry_001");
  expect(order).not.toBeNull();
  expect(callCount).toBe(2);
});

Testing HMAC Signature Verification

Signature verification is how you confirm that a webhook came from the legitimate sender and wasn't tampered with. Every webhook endpoint must verify signatures — and you must test both the success and failure paths.

// src/middleware/verifyWebhookSignature.js
const crypto = require("crypto");

function verifyWebhookSignature(req, res, next) {
  const signature = req.headers["x-webhook-signature"];
  const rawBody = req.rawBody; // must use express.raw() to capture this
  
  if (!signature) {
    return res.status(401).json({ error: "Missing signature header" });
  }
  
  const [timestampPart, sigPart] = signature.split(",");
  const timestamp = timestampPart.replace("t=", "");
  const receivedSig = sigPart.replace("v1=", "");
  
  // Replay attack protection: reject events older than 5 minutes
  const eventAge = Math.abs(Date.now() / 1000 - parseInt(timestamp));
  if (eventAge > 300) {
    return res.status(401).json({ error: "Webhook timestamp too old" });
  }
  
  const signedPayload = `${timestamp}.${rawBody}`;
  const expectedSig = crypto
    .createHmac("sha256", process.env.WEBHOOK_SIGNING_SECRET)
    .update(signedPayload)
    .digest("hex");
  
  if (!crypto.timingSafeEqual(Buffer.from(receivedSig), Buffer.from(expectedSig))) {
    return res.status(401).json({ error: "Invalid signature" });
  }
  
  next();
}

module.exports = verifyWebhookSignature;
// tests/webhook.signature.test.js
describe("Webhook signature verification", () => {
  test("accepts valid signature", async () => {
    const response = await sender.send("test.event", { data: "valid" });
    expect(response.status).toBe(200);
  });

  test("rejects missing signature header", async () => {
    const response = await axios.post(
      "http://localhost:3000/webhooks/payment",
      { type: "test.event", data: {} },
      { headers: { "Content-Type": "application/json" } }
    );
    expect(response.status).toBe(401);
    expect(response.data.error).toMatch(/Missing signature/);
  });

  test("rejects tampered payload", async () => {
    const timestamp = Math.floor(Date.now() / 1000);
    const payload = { type: "payment.succeeded", data: { amount: 5000 } };
    const signature = `t=${timestamp},v1=invalidsignature`;
    
    const response = await axios.post(
      "http://localhost:3000/webhooks/payment",
      payload,
      { headers: { "X-Webhook-Signature": signature } }
    );
    expect(response.status).toBe(401);
  });

  test("rejects replayed events older than 5 minutes", async () => {
    const oldTimestamp = Math.floor(Date.now() / 1000) - 400; // 6.7 minutes ago
    const response = await sender.send(
      "payment.succeeded",
      { amount: 5000 },
      { timestamp: oldTimestamp }
    );
    expect(response.status).toBe(401);
    expect(response.data.error).toMatch(/timestamp too old/);
  });
});

Testing Failure Scenarios (4xx/5xx Responses)

Your handler should return the correct status codes to control retry behavior:

  • 200 OK — event processed successfully, don't retry
  • 400 Bad Request — invalid payload, don't retry (it will fail again)
  • 409 Conflict — duplicate event, don't retry
  • 500 Internal Server Error — transient failure, please retry
test("returns 400 for unknown event types (no retry needed)", async () => {
  const response = await sender.send("unknown.event.type", { data: "test" });
  expect(response.status).toBe(400);
});

test("returns 409 for duplicate events (no retry needed)", async () => {
  const eventId = "evt_duplicate_check";
  await sender.send("payment.succeeded", { amount: 1000 }, { eventId });
  const response = await sender.send("payment.succeeded", { amount: 1000 }, { eventId });
  expect(response.status).toBe(409);
});

Production Webhook Health Monitoring

Once your webhooks are in production, you need to know when the endpoint goes down. A webhook endpoint that returns 5xx silently for an hour will trigger a retry storm when it recovers.

Try HelpMeTest

HelpMeTest can continuously ping your webhook endpoint with synthetic events to verify it's accepting and processing correctly. Set up a health check that sends a test webhook payload every 5 minutes and verifies the expected side effect — no more silent outages. Visit https://helpmetest.com to get started — $100/month flat, no per-test fees.

Read more