Resource library

QA How-To

Webhook API Testing Complete Guide (2026)

Webhook API testing complete guide 2026 with runnable TypeScript tests for signatures, retries, duplicates, ordering, replay, security, and observability.

20 min read | 2,778 words

TL;DR

Test webhooks as untrusted, at-least-once HTTP messages. Cover raw-body signature verification, durable acceptance, idempotency, retries, ordering, fixture replay, failure injection, and operational evidence.

Key Takeaways

  • Verify signatures against the exact raw request bytes before parsing JSON.
  • Acknowledge only after the event is durably accepted for processing.
  • Use a durable unique event ID to make duplicate delivery harmless.
  • Test transient retries, permanent failures, delay caps, and exhaustion deterministically.
  • Protect object state from stale and out-of-order events with versions or sequences.
  • Replay sanitized production-shaped fixtures and assert both responses and side effects.
  • Instrument receipt, queueing, processing, retries, duplicates, and final outcomes.

Webhook API testing verifies that an event producer sends the right HTTP request and that a consumer authenticates, processes, and acknowledges it safely. This webhook api testing complete guide 2026 gives you a runnable local lab for signatures, retries, duplicates, ordering, replay, observability, and security. You will test behavior at the HTTP boundary instead of trusting a successful status code.

A webhook is an asynchronous API contract. The provider decides when to call you, networks can fail, and the same event may arrive more than once. Reliable tests therefore cover the sender, receiver, transport assumptions, persistence, and operational evidence.

TL;DR

Treat every webhook as an untrusted, at-least-once message delivered over HTTP.

Risk Test Passing evidence
Forged request Reject a bad HMAC signature 401 response and no side effect
Slow handler Acknowledge before long work Fast 202 response
Lost delivery Simulate retryable failures Same event arrives again
Duplicate delivery Send one event twice One business side effect
Wrong order Deliver newer event first Final state stays correct
Regression Replay a saved payload Stable contract assertions

Build a local receiver, capture the raw request body, verify its signature with a constant-time comparison, reserve the event ID atomically, and move business work behind an acknowledgement. Then test both happy paths and deliberate failures.

What You Will Build

By the end of this guide, you will have:

  • A TypeScript webhook receiver using Node.js and Express.
  • HMAC SHA-256 signature verification over the exact raw bytes.
  • A small idempotency store that rejects duplicate event IDs.
  • Vitest integration tests for valid, invalid, duplicate, and malformed deliveries.
  • A sender harness that retries transient failures with bounded backoff.
  • Fixtures for replaying real payload shapes without exposing secrets.
  • A practical checklist for ordering, observability, performance, and security.

The lab is intentionally small. Its interfaces map directly to production components such as a database uniqueness constraint, queue, dead-letter workflow, and metrics platform.

Prerequisites

Use Node.js 22 LTS or a newer supported LTS release. Check your installation:

node --version
npm --version

Create an empty directory and install the dependencies:

mkdir webhook-testing-lab
cd webhook-testing-lab
npm init -y
npm install express
npm install -D typescript tsx vitest supertest @types/node @types/express @types/supertest

Update package.json with module mode and test scripts:

{
  "name": "webhook-testing-lab",
  "type": "module",
  "scripts": {
    "dev": "tsx watch src/server.ts",
    "test": "vitest run",
    "test:watch": "vitest"
  }
}

Create tsconfig.json:

{
  "compilerOptions": {
    "target": "ES2023",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "outDir": "dist"
  },
  "include": ["src", "test"]
}

The examples use an illustrative x-webhook-signature header. Real providers use different header names, timestamp formats, canonical strings, and version prefixes. Follow the provider's published signing contract exactly.

Step 1: Webhook API Testing Complete Guide 2026 Contract and Test Matrix

Start with observable rules, not implementation details. Our example accepts payment.succeeded events with a stable ID and ISO timestamp.

{
  "id": "evt_1001",
  "type": "payment.succeeded",
  "createdAt": "2026-07-15T10:15:00.000Z",
  "data": {
    "paymentId": "pay_901",
    "amount": 4999,
    "currency": "USD"
  }
}

Write down the contract before automating:

Case Input Expected HTTP result Expected side effect
Valid delivery Correct body and signature 202 Event queued once
Invalid signature Changed signature 401 None
Malformed JSON Signed invalid JSON 400 None
Unsupported type Valid unknown event 422 None
Duplicate Same ID delivered twice 202 both times One queue item
Temporary outage Receiver returns 503 Sender retries Event eventually accepted
Permanent rejection Receiver returns 401 Sender stops None

Separate transport acknowledgement from business completion. A 202 Accepted means the receiver durably accepted responsibility, not that every downstream action finished.

Verify the step: review the matrix with the provider and consumer owners. Every row must name an HTTP result and a measurable side effect. If a requirement says only "works," it is not testable yet.

Step 2: Build a Raw-Body Webhook Receiver

Create src/app.ts. Express must preserve the raw bytes because parsing and reserializing JSON can change whitespace or key order and invalidate a correct signature.

import express, { type Request, type Response } from "express";
import { verifySignature } from "./signature.js";

export type WebhookEvent = {
  id: string;
  type: string;
  createdAt: string;
  data: Record<string, unknown>;
};

export const processedIds = new Set<string>();
export const acceptedEvents: WebhookEvent[] = [];

export function createApp(secret: string) {
  const app = express();

  app.post(
    "/webhooks/payments",
    express.raw({ type: "application/json", limit: "256kb" }),
    (req: Request, res: Response) => {
      const signature = req.header("x-webhook-signature");
      const rawBody = Buffer.isBuffer(req.body)
        ? req.body
        : Buffer.from("");

      if (!signature || !verifySignature(rawBody, signature, secret)) {
        return res.status(401).json({ error: "invalid signature" });
      }

      let event: WebhookEvent;
      try {
        event = JSON.parse(rawBody.toString("utf8")) as WebhookEvent;
      } catch {
        return res.status(400).json({ error: "invalid JSON" });
      }

      if (!event.id || !event.createdAt || event.type !== "payment.succeeded") {
        return res.status(422).json({ error: "unsupported event" });
      }

      if (!processedIds.has(event.id)) {
        processedIds.add(event.id);
        acceptedEvents.push(event);
      }

      return res.status(202).json({ received: true });
    }
  );

  return app;
}

Create src/server.ts:

import { createApp } from "./app.js";

const port = Number(process.env.PORT ?? 3000);
const secret = process.env.WEBHOOK_SECRET;

if (!secret) {
  throw new Error("WEBHOOK_SECRET is required");
}

createApp(secret).listen(port, () => {
  console.log(`Webhook receiver listening on http://localhost:${port}`);
});

This receiver limits request size, authenticates before parsing, validates required fields, and acknowledges duplicates without repeating the side effect.

Verify the step: run WEBHOOK_SECRET=test_secret npm run dev. Expect the listening message. A plain POST without a signature should return 401, which confirms that the route is reachable and closed by default.

Step 3: Implement Signature Verification Correctly

Create src/signature.ts:

import { createHmac, timingSafeEqual } from "node:crypto";

export function signPayload(rawBody: Buffer, secret: string): string {
  return createHmac("sha256", secret).update(rawBody).digest("hex");
}

export function verifySignature(
  rawBody: Buffer,
  suppliedHex: string,
  secret: string
): boolean {
  if (!/^[a-f0-9]{64}$/i.test(suppliedHex)) {
    return false;
  }

  const expected = Buffer.from(signPayload(rawBody, secret), "hex");
  const supplied = Buffer.from(suppliedHex, "hex");

  return expected.length === supplied.length &&
    timingSafeEqual(expected, supplied);
}

Compute the HMAC over the exact received buffer. Do not sign JSON.stringify(req.body). Use timingSafeEqual only after confirming equal lengths because it throws for different-sized buffers.

Many production schemes sign a string such as timestamp + "." + rawBody. They also include a version prefix such as v1=. Adapt both signing and verification to that specification. Never invent a generic verifier and assume it matches Stripe, GitHub, Shopify, or another provider.

Add test/signature.test.ts:

import { describe, expect, it } from "vitest";
import { signPayload, verifySignature } from "../src/signature.js";

describe("webhook signatures", () => {
  const secret = "test_secret";
  const raw = Buffer.from('{"id":"evt_1001"}');

  it("accepts an authentic payload", () => {
    expect(verifySignature(raw, signPayload(raw, secret), secret)).toBe(true);
  });

  it("rejects a modified payload", () => {
    const signature = signPayload(raw, secret);
    expect(
      verifySignature(Buffer.from('{"id":"evt_9999"}'), signature, secret)
    ).toBe(false);
  });

  it("rejects malformed signature text", () => {
    expect(verifySignature(raw, "not-hex", secret)).toBe(false);
  });
});

Verify the step: run npm test -- test/signature.test.ts. Expect three passing tests. Change one byte in raw and confirm authentication fails.

For a deeper provider-style implementation, follow webhook signature verification step by step.

Step 4: Test the HTTP Boundary End to End

Create test/webhook.test.ts:

import request from "supertest";
import { beforeEach, describe, expect, it } from "vitest";
import {
  acceptedEvents,
  createApp,
  processedIds
} from "../src/app.js";
import { signPayload } from "../src/signature.js";

const secret = "test_secret";
const app = createApp(secret);

const event = {
  id: "evt_1001",
  type: "payment.succeeded",
  createdAt: "2026-07-15T10:15:00.000Z",
  data: { paymentId: "pay_901", amount: 4999, currency: "USD" }
};

function delivery(payload: unknown, signature?: string) {
  const raw = JSON.stringify(payload);
  return request(app)
    .post("/webhooks/payments")
    .set("content-type", "application/json")
    .set("x-webhook-signature", signature ?? signPayload(Buffer.from(raw), secret))
    .send(raw);
}

beforeEach(() => {
  processedIds.clear();
  acceptedEvents.length = 0;
});

describe("POST /webhooks/payments", () => {
  it("accepts a valid signed event", async () => {
    const response = await delivery(event);
    expect(response.status).toBe(202);
    expect(response.body).toEqual({ received: true });
    expect(acceptedEvents).toHaveLength(1);
  });

  it("rejects an invalid signature without a side effect", async () => {
    const response = await delivery(event, "0".repeat(64));
    expect(response.status).toBe(401);
    expect(acceptedEvents).toHaveLength(0);
  });

  it("accepts a duplicate but processes it once", async () => {
    expect((await delivery(event)).status).toBe(202);
    expect((await delivery(event)).status).toBe(202);
    expect(acceptedEvents).toHaveLength(1);
  });

  it("rejects an unsupported event type", async () => {
    const response = await delivery({ ...event, type: "unknown.event" });
    expect(response.status).toBe(422);
    expect(acceptedEvents).toHaveLength(0);
  });
});

Supertest sends requests through the actual Express middleware stack without opening a network port. Assertions cover both the response and the side effect. This matters because a handler can return 202 while silently dropping work, or return an error after committing work.

Verify the step: run npm test -- test/webhook.test.ts. Expect four passing cases. Temporarily remove processedIds.add(event.id); the duplicate test should fail.

Step 5: Make Idempotency Durable and Test Concurrency

The in-memory Set teaches the rule but is not production durability. It disappears on restart and is not shared across instances. In production, reserve the provider event ID in the same durable system that records the work.

A relational pattern uses a unique constraint:

CREATE TABLE webhook_events (
  provider TEXT NOT NULL,
  event_id TEXT NOT NULL,
  event_type TEXT NOT NULL,
  received_at TIMESTAMPTZ NOT NULL DEFAULT now(),
  payload JSONB NOT NULL,
  status TEXT NOT NULL DEFAULT 'accepted',
  PRIMARY KEY (provider, event_id)
);

Attempt the insert before publishing downstream work. If the key already exists, acknowledge the delivery and skip the repeated side effect. When a database and message broker cannot share a transaction, use an outbox table so accepted work is eventually published.

Concurrency is the important test. Two identical requests can reach different application instances at nearly the same time:

it("processes concurrent duplicates once", async () => {
  const responses = await Promise.all([
    delivery(event),
    delivery(event),
    delivery(event),
    delivery(event)
  ]);

  expect(responses.map((r) => r.status)).toEqual([202, 202, 202, 202]);
  expect(acceptedEvents).toHaveLength(1);
});

The example passes in one Node.js process because the check and add happen synchronously. A production test should target multiple instances and assert one database row plus one business action. Do not rely on a read-then-write sequence without a unique constraint because it races.

Verify the step: add the concurrency test and run it repeatedly. In staging, query by provider, event_id and verify exactly one record. The focused guide on validating webhook event ordering and duplicates expands this into durable scenarios.

Step 6: Test Retry and Backoff Behavior

Providers retry because connections reset, acknowledgements time out, and receivers return retryable errors. Your sender-side contract should distinguish transient results from permanent rejection.

Create src/deliver.ts:

export type Attempt = (attemptNumber: number) => Promise<Response>;

export async function deliverWithRetry(
  attempt: Attempt,
  sleep: (milliseconds: number) => Promise<void>,
  maxAttempts = 4
): Promise<Response> {
  let lastResponse: Response | undefined;

  for (let number = 1; number <= maxAttempts; number += 1) {
    lastResponse = await attempt(number);

    if (lastResponse.ok) return lastResponse;
    if (lastResponse.status >= 400 && lastResponse.status < 500) {
      return lastResponse;
    }

    if (number < maxAttempts) {
      const delay = Math.min(250 * 2 ** (number - 1), 2000);
      await sleep(delay);
    }
  }

  return lastResponse!;
}

Create test/deliver.test.ts:

import { describe, expect, it, vi } from "vitest";
import { deliverWithRetry } from "../src/deliver.js";

describe("delivery retries", () => {
  it("retries transient failures and then succeeds", async () => {
    const attempt = vi.fn()
      .mockResolvedValueOnce(new Response(null, { status: 503 }))
      .mockResolvedValueOnce(new Response(null, { status: 202 }));
    const sleep = vi.fn().mockResolvedValue(undefined);

    const response = await deliverWithRetry(attempt, sleep);

    expect(response.status).toBe(202);
    expect(attempt).toHaveBeenCalledTimes(2);
    expect(sleep).toHaveBeenCalledWith(250);
  });

  it("does not retry authentication failure", async () => {
    const attempt = vi.fn()
      .mockResolvedValue(new Response(null, { status: 401 }));
    const sleep = vi.fn().mockResolvedValue(undefined);

    await deliverWithRetry(attempt, sleep);

    expect(attempt).toHaveBeenCalledTimes(1);
    expect(sleep).not.toHaveBeenCalled();
  });
});

Injecting sleep makes the test deterministic and fast. Production senders normally add random jitter, persist attempt state, cap retries, and expose exhausted deliveries for replay. Test documented provider behavior rather than assuming every provider treats status codes identically.

Verify the step: run npm test -- test/deliver.test.ts. Expect two passing tests with no real delay. The detailed webhook retry and backoff testing tutorial covers timeout, jitter, caps, and exhaustion.

Step 7: Validate Ordering, Freshness, and Replay Protection

Delivery order is not guaranteed. A retry of an old event can arrive after a newer event. Design state transitions around provider sequence numbers, object versions, or event timestamps when the provider guarantees their meaning.

For example, store the last applied version with each payment:

type PaymentState = { version: number; status: string };
const payments = new Map<string, PaymentState>();

export function applyPaymentEvent(input: {
  paymentId: string;
  version: number;
  status: string;
}) {
  const current = payments.get(input.paymentId);
  if (current && input.version <= current.version) return false;

  payments.set(input.paymentId, {
    version: input.version,
    status: input.status
  });
  return true;
}

Test the hostile order:

it("does not let an older event overwrite new state", () => {
  applyPaymentEvent({ paymentId: "pay_901", version: 2, status: "refunded" });
  applyPaymentEvent({ paymentId: "pay_901", version: 1, status: "paid" });

  expect(payments.get("pay_901")).toEqual({
    version: 2,
    status: "refunded"
  });
});

Signature timestamps serve a different purpose. If the provider signs a timestamp, enforce a documented tolerance to reduce replay risk. Use the signed timestamp, compare it against a controlled clock, and test boundaries just inside and outside the tolerance. Do not reject delayed business events merely because their event creation time is old unless the contract requires it.

Verify the step: deliver version 2, then version 1, then version 2 again. Final state must remain version 2 and the duplicate must not create another side effect.

Step 8: Capture and Replay Payload Fixtures Safely

Production-shaped fixtures reveal optional fields, nested arrays, Unicode, decimals, null values, and schema evolution that hand-written samples miss. Capture only after removing personal data, access tokens, cookies, and real signatures.

Save test/fixtures/payment-succeeded.json:

{
  "id": "evt_fixture_001",
  "type": "payment.succeeded",
  "createdAt": "2026-07-15T10:15:00.000Z",
  "data": {
    "paymentId": "pay_fixture_001",
    "amount": 4999,
    "currency": "USD",
    "customerReference": "synthetic-customer"
  }
}

Replay the exact file bytes:

import { readFile } from "node:fs/promises";
import request from "supertest";
import { signPayload } from "../src/signature.js";

it("replays a sanitized fixture", async () => {
  const raw = await readFile(
    new URL("./fixtures/payment-succeeded.json", import.meta.url)
  );

  const response = await request(app)
    .post("/webhooks/payments")
    .set("content-type", "application/json")
    .set("x-webhook-signature", signPayload(raw, secret))
    .send(raw);

  expect(response.status).toBe(202);
});

Keep immutable original fixtures for contract regression. Create separate transformed fixtures for edge cases so one test does not silently alter another test's evidence. Review fixtures like source code and run secret scanning in CI.

Verify the step: run the replay test, then change only whitespace in the fixture without recomputing its signature in the test. A stored old signature should fail, proving that authentication uses exact bytes. See replay webhook payloads for local testing for capture, redaction, and CLI workflows.

Step 9: Add Performance, Observability, and Failure Injection

Correctness tests are necessary but incomplete. A receiver can pass functional checks and still time out under load. Keep acknowledgement work small: authenticate, validate, reserve idempotency, durably enqueue, respond.

Measure at least:

  • Request count grouped by event type and response class.
  • Acknowledgement latency percentiles.
  • Signature, schema, and processing failure counts.
  • Queue age and depth.
  • Retry count and exhausted delivery count.
  • Duplicate count.
  • Time from receipt to completed business effect.

Use a correlation ID that is safe to log, usually the provider event ID after validation. Never log shared secrets, full signature headers, or unredacted payloads. Restrict access to stored payloads and define retention.

Run controlled failure scenarios in a nonproduction environment: delay the response beyond the sender timeout, return 503 for the first two attempts, reset the connection, pause the worker, and make a downstream dependency unavailable. Verify that the request is either not accepted or is durably recoverable. A response timeout can produce a retry even when your first attempt succeeded, so idempotency must protect the business effect.

Load test with representative payload sizes and event mixes. Avoid using a public webhook inspector for confidential data unless its security and retention terms are approved.

Verify the step: for each injected failure, trace one event from initial receipt through retry to one final side effect. Dashboards should explain what happened without requiring payload contents.

Webhook API Testing Complete Guide 2026 Tool Choices

Choose tools by boundary and evidence, not popularity.

Tool or approach Best use Limitation
Supertest Express route integration tests Does not reproduce a real network
Vitest Fast TypeScript assertions and mocks Mock-heavy tests can hide integration defects
curl Manual delivery and header experiments Weak repeatability without scripts
Provider CLI Signed forwarding and official event fixtures Provider-specific
Local tunnel Receiving provider sandbox events Exposes a local endpoint
WireMock or MockServer Stateful sender and receiver simulations Requires scenario maintenance
k6 Load, latency, and failure thresholds Not a business assertion framework
Sanitized fixture replay Contract regression Fixtures can become stale

Use layers. Keep many fast deterministic tests in CI, add a smaller set against real persistence and queues, and run provider sandbox tests before release. If you expose localhost through a tunnel, use a temporary URL, authenticate where possible, and stop it after testing.

The Complete Series

Together, these tutorials turn each major reliability risk into a focused exercise. Use this pillar as the system map, then use the sub-articles when you implement each control.

Troubleshooting

Every valid signature returns 401 -> Confirm that signature middleware receives the raw buffer before any JSON parser. Check whether the provider signs a timestamp prefix, uses Base64 instead of hex, or includes a version label.

The raw body is empty -> Verify Content-Type matches application/json and no earlier middleware consumed the stream. Mount express.raw on the webhook route before global JSON parsing.

Duplicate effects still occur -> Replace process memory and read-then-write logic with a durable unique key. Test simultaneous delivery across multiple instances, not only sequential requests.

Tests wait for real backoff delays -> Inject the clock or sleep function. Assert requested delays while the unit test resolves immediately.

The provider keeps retrying after 202 -> Confirm that the response actually reaches the provider before its timeout. Inspect proxy and load balancer logs, and make the acknowledgement path faster.

Replay fixtures fail unexpectedly -> Sign the exact bytes sent. Editors may change line endings or add a final newline, both of which change the HMAC.

Best Practices and Common Mistakes

Do authenticate before parsing or performing work. Keep secrets in a secret manager, support controlled rotation, and compare signatures in constant time. Validate content type and request size before allocating large buffers.

Do make event IDs unique at the persistence boundary. Return success for already accepted duplicates when the provider contract permits it. Track processing status separately so operators can distinguish accepted, processing, completed, and failed events.

Do acknowledge only after durable acceptance. Returning 200 and then relying on in-process background work can lose events during a crash. A durable queue or transactional outbox closes that gap.

Do test negative space: missing headers, multiple signature values, malformed encodings, oversized bodies, unknown event types, old signed timestamps, concurrent duplicates, connection resets, and dependency failures.

Do not assume event order, treat a webhook as a command without validation, or trust an IP allowlist as the only authentication control. Do not use production secrets in local tests. Do not retry every 4xx forever, because permanent authentication or schema errors require investigation rather than traffic amplification.

For broader API test design, pair these checks with API testing scenario based interview questions and your service's normal contract, authorization, and resilience suites.

Interview Questions and Answers

Q: Why must webhook signature verification use the raw body?

The provider calculates the signature from a precise byte sequence. Parsing and reserializing JSON can change whitespace, escaping, or key order while preserving the same data, which produces a different HMAC. Capture raw bytes first and parse only after authentication.

Q: What does idempotency mean for a webhook consumer?

Processing the same event more than once produces the same final business result as processing it once. Store a stable provider event ID under a unique constraint and guard the side effect with that reservation. Returning a successful acknowledgement for a duplicate often prevents unnecessary retries.

Q: When should a receiver return 202?

Return it after authentication, validation, and durable acceptance into a database or queue. It should not claim final business success. If acceptance did not become durable, return a failure so the provider can retry according to its contract.

Q: How do you test retry logic without slow tests?

Inject the sleep or clock dependency. Script the attempt function to return transient errors followed by success, then assert attempt count and requested delays. Keep a smaller integration test for real scheduling and persistence.

Q: How do you test out-of-order delivery?

Deliver a newer object version first and an older version second. Assert that the stored version and business state remain at the newer value. Repeat the same version to combine ordering and duplicate protection.

Q: What should webhook logs contain?

Record a safe event identifier, event type, receipt time, response class, processing state, attempt number, and correlation identifiers. Redact payload secrets and personal data. Metrics and traces should connect receipt to the final side effect.

Q: What is the most dangerous false positive in webhook testing?

Asserting only the HTTP status. A handler may return success without durable work, or it may repeat a side effect before returning an error. Always assert persistence, queue publication, or the final business effect.

Where To Go Next

Run the lab first, then replace one simplified component at a time. Move the in-memory idempotency store to your database, replace the array with a durable queue, and exercise the deployment through the same proxy and load balancer used in production.

Next, use the four tutorials in The Complete Series to deepen signature, retry, ordering, and replay coverage. Add those scenarios to CI, keep provider sandbox checks in a release suite, and make exhausted deliveries visible to an operator.

Conclusion

A reliable webhook suite tests much more than a POST request. It proves authenticity over exact bytes, durable acceptance, bounded retries, one side effect for duplicates, safe state under reordered events, and enough evidence to recover failures.

Use this webhook api testing complete guide 2026 as your baseline. Run the included lab, adapt the provider-specific signing contract, and promote each verification from memory-backed examples to the real database, queue, worker, and observability stack.

Interview Questions and Answers

Why should a webhook handler preserve the raw request body?

Signature schemes normally authenticate the exact received bytes. JSON parsing and serialization can alter whitespace, escaping, or property order. Verify the signature over the raw buffer first, then parse and validate the event.

How would you design idempotent webhook processing?

I would reserve the provider and event ID under a durable unique constraint, then associate processing state with that record. A duplicate receives a successful acknowledgement but cannot repeat the business effect. For database-to-broker delivery, I would use a transactional outbox.

What should be tested besides the webhook HTTP status?

I would assert durable persistence or queue publication and the eventual business outcome. I would also verify that invalid requests create no side effects, duplicates create one effect, and failures produce usable logs, metrics, and retry state.

How would you test exponential backoff?

I would inject a clock or sleep dependency and script a sequence of transient responses. The test would assert attempt count, calculated delays, maximum delay, attempt cap, and success or exhaustion. Separate integration coverage would verify persisted scheduling.

How do you handle out-of-order webhook events?

I use a provider sequence, object version, or documented timestamp semantics to compare updates. The consumer ignores stale updates or fetches current state from the provider when ordering is ambiguous. Tests deliver events in reverse order and assert the newest state wins.

What security tests belong in a webhook test plan?

I cover missing, malformed, and incorrect signatures, signed timestamp boundaries, replay, oversized bodies, unsupported content types, schema abuse, secret rotation, and log redaction. I authenticate before parsing or performing side effects and do not treat IP allowlisting as the only control.

When is a webhook safely acknowledged?

It is safe after authentication and validation have passed and responsibility is durably recorded, commonly in a database or queue. The final business operation may still be asynchronous. Acknowledging before durable acceptance risks silent event loss during a crash.

Frequently Asked Questions

How do you test a webhook API?

Send signed HTTP requests to the real webhook route and assert both the response and durable side effect. Include invalid signatures, malformed bodies, duplicate IDs, retries, out-of-order events, timeouts, and replayed fixtures.

Can Postman test webhooks?

Postman can send example webhook requests and inspect responses, but signatures must be generated from the exact body bytes. Automated integration tests are better for concurrency, retry, persistence, and repeatable side-effect assertions.

Should a webhook return 200 or 202?

Follow the provider contract. A 202 is clear when work has been durably accepted but will finish asynchronously, while some providers expect any 2xx response. Never acknowledge before the event is safely recorded or queued.

How do you prevent duplicate webhook processing?

Persist the provider event ID with a unique constraint before initiating the business effect. Acknowledge known duplicates without repeating work, and test simultaneous deliveries across application instances.

Why does webhook signature verification fail with valid JSON?

Signatures authenticate bytes, not parsed JSON meaning. A parser can change whitespace, escaping, line endings, or key order, so verification must use the exact raw request body and the provider's documented canonical string.

How do you test webhook retries?

Return controlled transient errors or timeouts, then assert attempt count, delay schedule, cap, jitter boundaries, and eventual outcome. Inject the clock or sleep function so most tests remain fast and deterministic.

Are webhook events guaranteed to arrive in order?

Usually they are not, unless the provider explicitly guarantees ordering for a defined scope. Test newer events before older events and use provider versions, sequences, or safe state reconciliation to prevent regression.

Related Guides