Your webhook test passes once. The handler can still accept a changed body, process the same event twice, or hide a failure that will make the provider try again.

Useful testing starts at the delivery boundary. Send the exact bytes that were signed, verify the header, validate the event, confirm the HTTP response, and measure the side effect. Then replay the event, force failures, and change the delivery order.

This is different from choosing a software testing type. The question here is not whether a test is unit or integration. It is which webhook behavior each scenario proves. The code examples are illustrative and were not run against a real provider.

Diagram showing a signed webhook request entering a test matrix with verification, deduplication, retry, and confirmed side effect.

Short answer

  • Preserve the raw body before JSON.parse and sign the same bytes the provider signs.
  • Test missing, invalid, and expired signatures, as well as the valid path.
  • Send the same event twice, including concurrently, and confirm one business effect.
  • Simulate timeouts, 5xx responses, redelivery, and out-of-order events without waiting on the real clock.

What must a webhook test prove?

A webhook test should answer five questions: is the request authentic, does the payload have the expected shape, does the endpoint acknowledge the delivery, is a repeat safe, and does the right effect happen once? A single 200 answers only part of the third question.

The Standard Webhooks specification, published by the Standard Webhooks project, treats the signature, timestamp, and delivery identifier as separate metadata around the body. The identifier can act as an idempotency key, while the timestamp helps limit replay. Your provider may use different names and formulas, so its contract remains the reference.

Question Minimum scenario Evidence to keep
Was the source authenticated? valid and invalid signature status and rejection reason
Was the body parsed correctly? valid, invalid, and unknown event validation error or accepted type
Can the provider stop retrying? success, 4xx, and 5xx response and delivery state
Is a duplicate safe? same ID twice one effect, two acceptable responses
Does order matter? related events in different orders final state or explicit reconciliation

This matrix turns an integration into a reviewable contract. It also stops the suite from using the word "webhook" for a direct call to an internal function. The test should cross the same boundary the provider will cross.

The most useful result is more than passed. It combines the response, accepted event, delivery ID, persisted state, and external effects. When a duplicate appears, that trail shows whether the handler rejected it early or did the work before discovering that it already existed.

How do you build a deterministic fixture?

Build the fixture as a complete HTTP request, not as a JavaScript object that skips serialization. The raw body, headers, test secret, event ID, and timestamp should be controllable. Then the suite can replay exactly the same delivery.

Node.js, "Crypto" provides createHmac for calculating an HMAC. The algorithm and signed text below are a generic contract. Stripe, GitHub, and other providers define their own headers, prefixes, and timestamp rules. Copy the provider's formula instead of treating this example as universal.

import { createHmac } from "node:crypto";

type WebhookRequest = {
  body: string;
  headers: Record<string, string>;
};

export function makeWebhookRequest(
  body: string,
  secret: string,
  eventId = "evt_test_123",
  timestamp = 1_758_000_000,
): WebhookRequest {
  const signedValue = `${timestamp}.${body}`;
  const signature = createHmac("sha256", secret)
    .update(signedValue)
    .digest("hex");

  return {
    body,
    headers: {
      "content-type": "application/json",
      "x-webhook-id": eventId,
      "x-webhook-timestamp": String(timestamp),
      "x-webhook-signature": signature,
    },
  };
}

Use a fixed string for the body during the test. If the handler parses and then serializes JSON before verification, differences in spaces, property order, or escaping can invalidate a correct signature. Keep the secret in the fixture, never in the test error text or logs.

If the application framework has already consumed the body, add a test that proves the configuration. Verification needs the original bytes. Stripe's documentation, in "Receive Stripe events in your webhook endpoint", also says to verify the signature against the received body and notes that each endpoint has its own secret.

Which negative cases should fail?

Start with cases an attacker or transport failure can produce: missing header, wrong secret, body changed after signing, timestamp outside the window, and invalid JSON. The happy-path test matters only after these boundaries have a defined response.

Stripe documents the timestamp and signature as replay protection in "Receive Stripe events in your webhook endpoint". The same documentation explains that a new attempt can receive a new signature and timestamp. Your suite must therefore distinguish a legitimate retry of the same event from an old replay or forged request.

import { describe, expect, it } from "vitest";

describe("webhook verification", () => {
  it("accepts the exact signed body", async () => {
    const request = makeWebhookRequest(
      '{"type":"order.created","id":"evt_test_123"}',
      "test-secret",
    );

    const result = await handleWebhook(request, "test-secret");

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

  it.each([
    ["missing signature", (request: WebhookRequest) => {
      delete request.headers["x-webhook-signature"];
    }],
    ["changed body", (request: WebhookRequest) => {
      request.body = '{"type":"order.cancelled","id":"evt_test_123"}';
    }],
    ["wrong secret", (request: WebhookRequest) => {
      request.headers["x-webhook-signature"] = "not-the-signature";
    }],
  ])("rejects %s", async (_name, change) => {
    const request = makeWebhookRequest(
      '{"type":"order.created","id":"evt_test_123"}',
      "test-secret",
    );
    change(request);

    const result = await handleWebhook(request, "test-secret");

    expect(result.status).toBe(401);
  });
});

The snippet calls handleWebhook as a system boundary and is deliberately illustrative. A real suite should use the application's HTTP adapter, the provider's verifier, and a response that does not reveal the secret. Also test a rotated secret, unexpected content type, missing required fields, and an unknown event.

How do you prove duplicates do not repeat the effect?

Send the same event with the same identifier twice and confirm that the first delivery creates the expected effect while the second returns safely without running the business logic again. Then send two calls at the same time. That case finds a race a sequential test cannot find.

Do not use only an in-memory Set to prove idempotency. It disappears when the process restarts and does not coordinate two replicas. The Standard Webhooks specification recommends using the delivery ID as an idempotency key. The concrete choice can be a unique database constraint, an atomic queue operation, or another durable store.

it("processes the same delivery once", async () => {
  const request = makeWebhookRequest(
    '{"type":"order.created","id":"evt_test_123"}',
    "test-secret",
  );

  const [first, second] = await Promise.all([
    handleWebhook(request, "test-secret"),
    handleWebhook(request, "test-secret"),
  ]);

  expect([first.status, second.status].sort()).toEqual([202, 202]);
  expect(await countCreatedOrders("evt_test_123")).toBe(1);
});

The test must observe the effect that matters. Counting calls to a mocked function is not enough when the bug lives in an unconstrained write, a queue, or a payment triggered afterward. If the handler accepts the delivery and enqueues work, also confirm that the deduplicated message appears only once.

This article does not claim client experience or a benchmark. Its practical rule is a verification boundary: if the test cannot say how many effects were produced for a repeated ID, it does not yet prove idempotency.

How do you test retries without waiting on the real clock?

Make the external adapter fail in a controlled way and advance a fake clock between attempts. The suite should verify call count, chosen delays, final status, and produced effect. Do not use sleep to wait for a retry that takes minutes.

Stripe documents that an endpoint that does not return success may receive more attempts and recommends returning quickly before complex logic in "Receive Stripe events in your webhook endpoint". The exact rule varies by provider, but the test should make clear which response causes a redelivery in the chosen contract.

Scenario Handler response What to verify
processing completed 200 or 202 one effect and no expected retry
temporary failure before the effect 5xx provider or simulator tries again
already persisted duplicate 200 or 202 no new effect
invalid payload 4xx delivery does not enter business logic
failure after the effect explicit contract reconciliation or deduplication prevents repetition

For the clock boundary, see how to test retry logic in JavaScript without waiting. Webhooks add another question: does the retry repeat only the delivery, or does it also repeat an external call that already succeeded? Give that boundary its own test.

What if events arrive out of order?

Test ordering when a resource's state depends on more than one event. Send an update before the event that creates the resource, then replay the normal sequence. The system must reject, store, reconcile, or apply the events under an explicit rule. Silently ignoring the difference is not a policy.

GitHub lists out-of-order deliveries among webhook problems in "Troubleshooting webhooks". It also recommends inspecting the delivery and the response received by the server. In your suite, keep the ID and type of every event so a failure shows the received sequence.

A simple way to test this is to model the expected final state and compare the consequence rather than the order of calls. If the domain requires monotonicity, the event needs a version or timestamp that storage can compare. If order cannot be guaranteed, the consumer needs to fetch current state or schedule reconciliation.

How should local and provider tests be split?

Use three layers. The first tests the verifier, parser, and deduplication rule with local fixtures. The second crosses the HTTP endpoint and checks status, storage, and queue behavior. The third uses the provider's sandbox, CLI, replay, or test delivery to confirm that the local adapter follows its protocol.

GitHub's documentation on testing webhooks describes forwarding deliveries to a local server and inspecting what was sent and received. This is useful for checking the real format, but it does not replace deterministic tests that should run on every pull request.

Do not turn a network mock into integration proof. The article about mocking APIs in Playwright without false positives explains the difference between controlling UI state and checking the real contract. For webhooks, a mock can produce rare cases while a sandbox call confirms provider headers, bytes, and behavior.

Keep a small piece of evidence for each scenario: redacted input, ID, status, decision reason, final state, and effect count. Do not store secrets or personal-data payloads without a retention rule. The goal is a reproducible failure, not a second production database inside CI artifacts.

Webhook checklist before CI

Use this order when reviewing a new handler or changing an existing integration:

  1. Fix a representative payload and preserve the raw body used for signing.
  2. Test valid, missing, changed, expired, and wrong-secret signatures.
  3. Validate the schema before calling business logic.
  4. Confirm behavior for unknown events and invalid payloads.
  5. Deliver the same ID twice and confirm one effect.
  6. Deliver two copies in parallel and exercise atomic deduplication.
  7. Force a timeout and 5xx before and after the effect to discover the retry contract.
  8. Replay events out of order and record the reconciliation decision.
  9. Run one test in the sandbox or through the provider's official replay mechanism.
  10. Make CI publish only redacted evidence and fail when a contract promise is no longer proven.

If timing tests are still fragile, control dates and timers in JavaScript before increasing the runner's retries. Repeating a test can expose flakiness, but it does not fix missing deduplication or a signature calculated from the wrong body.

Frequently asked questions

Does a test that receives 200 prove the webhook works?

No. 200 proves only the response for that scenario. You still need to prove signatures, validation, duplicates, failures, and the business effect. A useful test records persisted state and effect count rather than an HTTP status code alone.

Should I test against the provider's real API?

Yes, in a separate layer. A sandbox, CLI, or replay checks the real protocol, but it is slower and depends on credentials, network, and availability. Local fixtures should cover error combinations in CI; the provider should confirm the adapter and delivery format.

Can I use a mock to validate the signature?

You can mock the delivery source to test your rule, but the mock must generate the same bytes and headers as the contract. If it calls a function that already returns "valid signature," the test proves nothing about integration. Keep one signed sample and one negative test.

What is the best deduplication key?

Use the stable identifier the provider defines for a delivery or event. Do not derive the key only from payload text if two legitimate deliveries can have the same content. Persist the decision atomically and confirm in a test that concurrent calls produce one effect.

Conclusion

A reliable webhook is not an endpoint that returned 200 once. It is a tested boundary with preserved bytes, verified signature, validated payload, clear response, durable deduplication, controlled retries, and an explicit rule for event order.

Start with local fixtures and negative cases. Then cross the endpoint, simulate concurrency, and confirm the adapter in the provider's sandbox or official replay path. When every scenario leaves small, verifiable evidence, CI can protect the integration without pretending that one happy test represents the internet.

Sources consulted