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.updatedbeforepayment.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 3000smee.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:3000Configure 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 retry400 Bad Request— invalid payload, don't retry (it will fail again)409 Conflict— duplicate event, don't retry500 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.