Resource library

QA How-To

Replay Webhook Payloads for Local Testing

Replay webhook payloads local testing with Node.js fixtures, exact raw bodies, signatures, retries, duplicate checks, assertions, and practical debugging.

17 min read | 2,713 words

TL;DR

Capture a sanitized webhook request as a fixture containing the raw body and relevant headers, then send those same bytes to a local receiver with a small replay script. Verify the HTTP response, application side effects, signature behavior, and duplicate handling so the replay becomes a dependable regression test.

Key Takeaways

  • Store the raw request body, selected headers, method, and path as one sanitized fixture.
  • Replay exact bytes when signature verification depends on the original payload.
  • Use a small Node.js sender so fixtures, headers, timeouts, and expected responses stay repeatable.
  • Give every replay a unique delivery ID unless you are deliberately testing duplicate handling.
  • Assert observable effects such as persisted records and idempotency, not only HTTP status codes.
  • Keep production secrets and personal data out of committed webhook fixtures.

Replay webhook payloads local testing becomes reliable when you preserve the request that matters, replay the same bytes and headers, and assert the resulting application state. This tutorial builds that workflow with Node.js, built-in web APIs, and the built-in test runner, so there is no third-party runtime dependency.

This is a focused companion to the Webhook API Testing Complete Guide for 2026. Use the pillar when you need the broader strategy for delivery, security, retries, idempotency, observability, and production readiness.

You will create a local receiver, a safe payload fixture, a reusable command-line replay client, and automated tests. The same design works with payloads exported from Stripe, GitHub, Shopify, an internal event service, or a webhook inspection tool.

What You Will Build

By the end, you will have:

  • A local HTTP webhook receiver that keeps the raw request bytes.
  • A version-controlled JSON fixture with realistic headers and payload data.
  • A replay command that supports a target URL, timeout, delivery ID, and optional secret.
  • HMAC signature generation over the exact bytes sent on the wire.
  • Automated checks for success, invalid signatures, and duplicate deliveries.
  • A debugging checklist for the failures that make local webhook testing misleading.

The finished request path is simple: fixture file -> replay client -> local receiver -> observable in-memory state. In a real service, replace the in-memory state with your database, queue, or domain assertion.

Prerequisites

Use Node.js 22 or newer. Node.js 22 includes stable fetch, AbortSignal.timeout, node:test, and the modules used here. Confirm your runtime:

node --version

Expected output starts with v22 or a newer major version. You also need a terminal and an empty working directory. curl is useful for the first smoke test but is not required by the replay client.

Create the project:

mkdir webhook-replay-lab
cd webhook-replay-lab
mkdir fixtures test
npm init -y

No npm install command is needed. All JavaScript examples use CommonJS so the default generated package.json can run them without a module-type change. Never copy a production signing secret into this project. Use a dedicated local value.

Step 1: Create a Local Webhook Receiver

Start with a receiver that reads the request as a Buffer. Do not call JSON.parse until after signature verification because parsing and reserializing JSON can change whitespace or key formatting.

Create server.js:

const http = require('node:http');
const crypto = require('node:crypto');

const port = Number(process.env.PORT || 3000);
const secret = process.env.WEBHOOK_SECRET || 'local_test_secret';
const processed = new Set();
const received = [];

function signatureFor(rawBody) {
  return crypto.createHmac('sha256', secret).update(rawBody).digest('hex');
}

function signaturesMatch(actual, expected) {
  const a = Buffer.from(actual || '', 'utf8');
  const e = Buffer.from(expected, 'utf8');
  return a.length === e.length && crypto.timingSafeEqual(a, e);
}

const server = http.createServer((req, res) => {
  if (req.method === 'GET' && req.url === '/_received') {
    res.writeHead(200, { 'content-type': 'application/json' });
    return res.end(JSON.stringify(received));
  }

  if (req.method !== 'POST' || req.url !== '/webhooks/orders') {
    res.writeHead(404).end('not found');
    return;
  }

  const chunks = [];
  req.on('data', (chunk) => chunks.push(chunk));
  req.on('end', () => {
    const rawBody = Buffer.concat(chunks);
    const expected = signatureFor(rawBody);

    if (!signaturesMatch(req.headers['x-webhook-signature'], expected)) {
      res.writeHead(401).end('invalid signature');
      return;
    }

    const deliveryId = req.headers['x-delivery-id'];
    if (!deliveryId) {
      res.writeHead(400).end('missing delivery id');
      return;
    }
    if (processed.has(deliveryId)) {
      res.writeHead(200).end('duplicate ignored');
      return;
    }

    try {
      const event = JSON.parse(rawBody.toString('utf8'));
      processed.add(deliveryId);
      received.push({ deliveryId, type: event.type, orderId: event.data.id });
      res.writeHead(202).end('accepted');
    } catch {
      res.writeHead(400).end('invalid json');
    }
  });
});

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

Run it in terminal one:

WEBHOOK_SECRET=local_test_secret node server.js

Verification: The terminal prints Webhook receiver listening at http://localhost:3000. In terminal two, run curl -i http://localhost:3000/_received. Expect HTTP/1.1 200 OK and an empty JSON array, [].

This receiver intentionally models three production concerns: authenticity, an idempotency key, and a visible side effect. For a deeper security-focused exercise, follow the step-by-step webhook signature verification tutorial.

Step 2: Save a Sanitized Webhook Fixture

A useful fixture preserves the request body as text instead of embedding it as a parsed object. That choice lets your replay client send identical UTF-8 bytes, including spaces and newlines. It also stores only headers that affect behavior.

Create fixtures/order-created.json:

{
  "name": "order.created example",
  "method": "POST",
  "path": "/webhooks/orders",
  "headers": {
    "content-type": "application/json; charset=utf-8",
    "user-agent": "local-webhook-replayer/1.0"
  },
  "body": "{\n  \"id\": \"evt_local_001\",\n  \"type\": \"order.created\",\n  \"createdAt\": \"2026-07-15T08:00:00.000Z\",\n  \"data\": {\n    \"id\": \"ord_local_1001\",\n    \"currency\": \"USD\",\n    \"amount\": 4999,\n    \"customerRef\": \"customer_fixture_01\"\n  }\n}"
}

Use invented identifiers and non-personal values. Remove authorization headers, cookies, email addresses, phone numbers, access tokens, and provider signatures captured from production. A captured signature will usually be invalid after sanitization anyway.

Validate the fixture syntax:

node -e "const f=require('./fixtures/order-created.json'); JSON.parse(f.body); console.log(f.name)"

Verification: Expect order.created example. If the outer fixture or inner body is invalid JSON, Node exits with a syntax error. Treat fixture validation as a pre-commit check.

A fixture should be minimal but behaviorally complete. Preserve event type, schema version when present, representative nested data, and metadata used for routing. Avoid huge payloads in the happy-path fixture. Add separate boundary fixtures for missing fields, unknown fields, Unicode, empty arrays, and maximum supported sizes.

Step 3: Replay Webhook Payloads Local Testing with curl

Before building the reusable sender, prove the receiver works with one direct request. Generate the HMAC from a temporary body file so curl sends the exact bytes that were signed.

Export the fixture body, calculate its signature, and post it:

node -e "process.stdout.write(require('./fixtures/order-created.json').body)" > /tmp/order-created-body.json
SIGNATURE=$(node -e "const fs=require('node:fs');const c=require('node:crypto');const b=fs.readFileSync('/tmp/order-created-body.json');process.stdout.write(c.createHmac('sha256','local_test_secret').update(b).digest('hex'))")
curl -i http://localhost:3000/webhooks/orders \
  -H "content-type: application/json; charset=utf-8" \
  -H "x-delivery-id: delivery-curl-001" \
  -H "x-webhook-signature: $SIGNATURE" \
  --data-binary @/tmp/order-created-body.json

Use --data-binary, not -d, when exact request bytes matter. The shell variable contains the hexadecimal signature, while the body file remains unchanged between signing and sending.

Verification: Expect HTTP/1.1 202 Accepted and accepted. Then run:

curl -s http://localhost:3000/_received

Expect an array containing delivery-curl-001, order.created, and ord_local_1001. Send the same command again with the same delivery ID. Expect HTTP/1.1 200 OK and duplicate ignored, with only one entry in /_received.

Step 4: Build a Reusable Replay Client

Create replay.js. It reads the fixture, resolves the fixture path against a base URL, adds a unique delivery ID, optionally generates the signature, and stops waiting after five seconds.

const fs = require('node:fs/promises');
const crypto = require('node:crypto');
const path = require('node:path');

async function replay({ fixturePath, baseUrl, secret, deliveryId }) {
  const text = await fs.readFile(fixturePath, 'utf8');
  const fixture = JSON.parse(text);
  const rawBody = Buffer.from(fixture.body, 'utf8');
  const url = new URL(fixture.path, baseUrl);
  const id = deliveryId || crypto.randomUUID();
  const headers = {
    ...fixture.headers,
    'x-delivery-id': id
  };

  if (secret) {
    headers['x-webhook-signature'] = crypto
      .createHmac('sha256', secret)
      .update(rawBody)
      .digest('hex');
  }

  const response = await fetch(url, {
    method: fixture.method || 'POST',
    headers,
    body: rawBody,
    signal: AbortSignal.timeout(5000)
  });
  const responseBody = await response.text();

  return {
    status: response.status,
    body: responseBody,
    deliveryId: id,
    url: url.toString()
  };
}

if (require.main === module) {
  const fixturePath = process.argv[2];
  if (!fixturePath) {
    console.error('Usage: node replay.js <fixture.json>');
    process.exit(2);
  }

  replay({
    fixturePath: path.resolve(fixturePath),
    baseUrl: process.env.WEBHOOK_BASE_URL || 'http://localhost:3000',
    secret: process.env.WEBHOOK_SECRET,
    deliveryId: process.env.DELIVERY_ID
  })
    .then((result) => {
      console.log(JSON.stringify(result, null, 2));
      if (result.status < 200 || result.status >= 300) process.exitCode = 1;
    })
    .catch((error) => {
      console.error(error.message);
      process.exitCode = 1;
    });
}

module.exports = { replay };

Run it while server.js is still active:

WEBHOOK_SECRET=local_test_secret \
DELIVERY_ID=delivery-script-001 \
node replay.js fixtures/order-created.json

Verification: The printed JSON has status 202, body accepted, the requested delivery ID, and the full local URL. Query /_received and confirm the new delivery appears exactly once. Running the same command again should print status 200 and body duplicate ignored.

The client signs the Buffer that it passes to fetch. That shared byte source prevents a subtle bug where code signs compact JSON but sends pretty-printed JSON.

Step 5: Test Negative and Boundary Payloads

A happy replay confirms wiring. Negative fixtures confirm that your receiver rejects bad input deliberately and without side effects. Start with invalid authentication by omitting the local secret:

DELIVERY_ID=delivery-no-signature-001 \
node replay.js fixtures/order-created.json

Verification: Expect status 401, body invalid signature, and a process exit code of 1. Query /_received; the rejected delivery must not appear.

Next, create fixtures/order-malformed.json with a body that is text but not valid JSON:

{
  "name": "malformed order event",
  "method": "POST",
  "path": "/webhooks/orders",
  "headers": {
    "content-type": "application/json"
  },
  "body": "{\"type\":\"order.created\",\"data\":"
}

Replay it with a valid signature:

WEBHOOK_SECRET=local_test_secret \
DELIVERY_ID=delivery-malformed-001 \
node replay.js fixtures/order-malformed.json

Verification: Expect status 400 and body invalid json. The receiver verifies authenticity first, then rejects parsing, which is the intended order. Again, the received-event count must not increase.

Build a small fixture matrix instead of mutating one file repeatedly:

Fixture Expected status Expected side effect
Valid order 202 One order event recorded
Same delivery ID 200 No second record
Missing signature 401 No record
Malformed JSON 400 No record
Missing delivery ID 400 No record
Unknown event type Defined by contract Logged, ignored, or rejected

Explicit expectations matter. A receiver that returns 200 for an unsupported event can be correct if the provider should stop retrying, but your test must also prove that no unsupported business action ran.

Step 6: Turn Replay into an Automated Test

Automate the replay at the HTTP boundary. Launch the server separately for this compact example, then let node:test call the same replay function used from the command line.

Create test/replay.test.js:

const test = require('node:test');
const assert = require('node:assert/strict');
const path = require('node:path');
const { replay } = require('../replay');

const fixturePath = path.join(__dirname, '..', 'fixtures', 'order-created.json');
const baseUrl = 'http://localhost:3000';
const secret = 'local_test_secret';

test('accepts a valid signed fixture', async () => {
  const deliveryId = `test-valid-${Date.now()}`;
  const result = await replay({ fixturePath, baseUrl, secret, deliveryId });

  assert.equal(result.status, 202);
  assert.equal(result.body, 'accepted');

  const received = await fetch(`${baseUrl}/_received`).then((r) => r.json());
  assert.ok(received.some((item) =>
    item.deliveryId === deliveryId && item.orderId === 'ord_local_1001'
  ));
});

test('does not process the same delivery twice', async () => {
  const deliveryId = `test-duplicate-${Date.now()}`;
  const first = await replay({ fixturePath, baseUrl, secret, deliveryId });
  const second = await replay({ fixturePath, baseUrl, secret, deliveryId });

  assert.equal(first.status, 202);
  assert.equal(second.status, 200);
  assert.equal(second.body, 'duplicate ignored');

  const received = await fetch(`${baseUrl}/_received`).then((r) => r.json());
  assert.equal(received.filter((item) => item.deliveryId === deliveryId).length, 1);
});

test('rejects an invalid signature', async () => {
  const result = await replay({
    fixturePath,
    baseUrl,
    secret: 'wrong_secret',
    deliveryId: `test-invalid-${Date.now()}`
  });

  assert.equal(result.status, 401);
  assert.equal(result.body, 'invalid signature');
});

With the receiver running under WEBHOOK_SECRET=local_test_secret, execute:

node --test test/replay.test.js

Verification: The summary reports three passing tests and zero failures. The first test checks both transport success and the recorded order ID. The duplicate test proves idempotency by counting side effects, not merely accepting the second response.

For a mature suite, start the application in test setup and stop it in teardown. Point it at an isolated database, poll asynchronous effects with a bounded timeout, and delete test records afterward. Never aim automated replay tests at production by default.

Step 7: Model Retries, Delays, and Duplicate Delivery

Providers retry when your endpoint times out or returns selected non-2xx responses. Your basic client sends once, so add a wrapper that retries transport failures and retryable status codes while retaining the same delivery ID.

Create retry-replay.js:

const crypto = require('node:crypto');
const { replay } = require('./replay');

const wait = (milliseconds) =>
  new Promise((resolve) => setTimeout(resolve, milliseconds));

async function replayWithRetry(options, maxAttempts = 3) {
  const deliveryId = options.deliveryId || crypto.randomUUID();
  let lastResult;

  for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
    try {
      lastResult = await replay({ ...options, deliveryId });
      if (![408, 429, 500, 502, 503, 504].includes(lastResult.status)) {
        return { ...lastResult, attempts: attempt };
      }
    } catch (error) {
      if (attempt === maxAttempts) throw error;
    }

    if (attempt < maxAttempts) await wait(250 * 2 ** (attempt - 1));
  }

  return { ...lastResult, attempts: maxAttempts };
}

module.exports = { replayWithRetry };

This illustrative schedule waits 250 ms and then 500 ms. It is intentionally fast for local tests. Real provider policies often include longer delays and jitter, so encode the provider's documented contract in provider-specific tests.

Quickly verify the non-retry path:

node -e "require('./retry-replay').replayWithRetry({fixturePath:'fixtures/order-created.json',baseUrl:'http://localhost:3000',secret:'local_test_secret'}).then(console.log)"

Verification: Expect status 202 and attempts: 1. To verify retries, use a test stub that returns 503 twice and 202 once, then assert three calls, the same delivery ID on every call, and one business side effect. Do not make your production handler fail randomly just to exercise the client.

Use the dedicated webhook retry and backoff testing guide to build deterministic failure injection. Then cover race conditions with the webhook event ordering and duplicate validation tutorial.

Step 8: Make Fixtures Safe and Maintainable

Treat replay fixtures as test code. Review changes, document their origin, and keep one behavior per fixture. A fixture named order-created-v2-missing-currency.json is more useful than payload-final-3.json.

Add a small provenance object outside the raw body if your team needs it:

{
  "name": "order.created schema v2",
  "source": {
    "provider": "example-commerce",
    "capturedFrom": "sandbox",
    "sanitized": true
  },
  "method": "POST",
  "path": "/webhooks/orders",
  "headers": {
    "content-type": "application/json"
  },
  "body": "{\"type\":\"order.created\",\"schemaVersion\":2,\"data\":{\"id\":\"ord_fixture_v2\"}}"
}

Never store an unredacted production request first and promise to clean it later. Sanitize before the file enters version control. If the payload must contain a secret-shaped value for parser testing, use an obviously fake marker such as test_token_not_valid.

Verification: Search the fixture directory for forbidden field names and run every body through JSON.parse when JSON is expected. In CI, add secret scanning and a schema validation command. A pull request should fail if a fixture contains a real-looking credential, invalid outer JSON, or an unexplained schema change.

For broader API fixture organization, pair these checks with a practical API test data management strategy and contract testing for REST APIs. Contract checks tell you when the provider schema changed; replay checks tell you whether your application still behaves correctly with representative deliveries.

Replay Webhook Payloads Local Testing: Approaches Compared

Choose the smallest tool that proves the behavior you care about. You can keep more than one approach in the same project.

Approach Best use Strength Limitation
curl --data-binary One-off diagnosis Transparent and widely available Shell quoting and manual IDs reduce repeatability
Node.js replay client Repeatable local and CI checks Exact bytes, generated signatures, assertions Requires maintaining a small helper
Provider CLI Provider-specific sandbox events Convenient event generation and forwarding Generated data may differ from a captured edge case
Webhook inspection service Viewing remote deliveries Fast capture and header inspection Data leaves your environment unless self-hosted
Full integration environment End-to-end confidence Exercises queues, databases, and workers Slower and harder to isolate

Troubleshooting

Problem: The receiver returns 401 invalid signature -> Confirm that the client and server use the same local secret. Sign the exact Buffer sent in the body. Do not parse and stringify between signature generation and transmission. Check whether the real provider signs a timestamp plus body rather than the body alone.

Problem: The receiver returns 404 not found -> Compare the fixture path with the registered local route. Remember that new URL('/webhooks/orders', baseUrl) replaces the base URL path. Use a base such as http://localhost:3000, and preserve any required API prefix in the fixture path.

Problem: fetch failed or ECONNREFUSED appears -> Start the local receiver, confirm its port, and query the health endpoint. If the app runs in Docker, localhost inside the container refers to that container. Use the appropriate service name or host gateway for your setup.

Problem: The request succeeds but no database row appears -> A 2xx response proves receipt, not downstream completion. Inspect queue and worker logs, correlate with the delivery ID, and poll the intended side effect with a firm timeout. Ensure your test database is the same database used by the local process.

Problem: Every replay is reported as a duplicate -> Generate a fresh delivery ID for independent tests. Reuse an ID only for an explicit idempotency scenario. Reset isolated test state between runs instead of weakening duplicate protection.

Problem: The payload works with curl but fails through the script -> Print the final URL, method, selected headers, body byte length, and a body hash from both paths. Avoid logging secrets or the full sensitive payload. Compare content-type, content encoding, newline handling, and proxy settings.

Common Mistakes and Best Practices

Do not treat a copied JSON object as a complete webhook request. Routing headers, signatures, delivery IDs, timestamps, and content type can all change handler behavior. Capture the request context that affects processing.

Use these practices:

  • Keep raw body text when verifying signatures.
  • Create separate fixtures for separate behaviors.
  • Replace personal and confidential data before saving.
  • Generate signatures with local test secrets at replay time.
  • Use unique IDs by default and fixed IDs for duplicate tests.
  • Assert business effects, rejected effects, logs, and queue messages where relevant.
  • Put explicit timeouts around requests and asynchronous polling.
  • Run fixtures against isolated local or CI resources.

Avoid these mistakes:

  • Sending reserialized JSON after signing different bytes.
  • Committing production signatures or bearer tokens.
  • Assuming every non-2xx response is retried by every provider.
  • Adding sleeps instead of polling a measurable condition.
  • Accepting a duplicate without proving the action ran once.
  • Using one enormous fixture to cover unrelated cases.
  • Pointing a replay command at production through an easy default.

A useful safety control is to default the client to localhost and require an explicit opt-in for any remote hostname. In CI, restrict outbound targets and inject only environment-specific test secrets.

Where To Go Next

You now have the core replay loop. Use the complete webhook API testing guide to place it inside a full test strategy. Then expand the harness in this order:

  1. Follow webhook signature verification step by step to cover timestamp tolerance, header parsing, secret rotation, and constant-time comparison.
  2. Add deterministic delivery failures with the webhook retry and backoff behavior tutorial.
  3. Prove idempotency under concurrency with webhook event ordering and duplicate tests.
  4. Run a small replay fixture suite in CI, with an isolated database and redacted logs.

When a production webhook exposes a new edge case, reproduce it with sanitized data, add the smallest fixture that preserves the behavior, write the failing assertion, and keep it as a regression test. That loop turns an incident into durable coverage.

Interview Questions and Answers

Q: Why should a webhook replay fixture preserve the raw body?

Many providers calculate a signature over the exact request bytes. Parsing JSON and serializing it again can change whitespace, escaping, or key order, which produces a different HMAC. Keeping raw text or bytes lets the test sign and transmit the same content.

Q: Which headers should you save with a webhook fixture?

Save headers that influence routing, parsing, authentication, versioning, idempotency, or business logic. Remove credentials and regenerate signatures with local secrets. Usually you do not need transient transport headers such as content-length, because the HTTP client calculates them.

Q: How do you test duplicate webhook delivery?

Send the same valid event twice with the same provider delivery ID. Assert that the endpoint gives the contractually correct response both times and that the business effect occurs exactly once. Counting records or emitted commands is stronger than checking status codes alone.

Q: How do you test an asynchronous webhook handler?

Assert immediate acceptance, then poll a specific observable outcome until it appears or a bounded timeout expires. Correlate the delivery ID through the HTTP handler, queue, worker, and storage layer. Avoid fixed sleeps because they are both slow and flaky.

Q: Should replay tests call production?

Normally, no. Local and CI replay tests should target isolated environments with fake secrets and controlled data. Production verification needs explicit authorization, provider-safe test events, careful rate limits, and a rollback or cleanup plan.

Q: What is the difference between replay testing and contract testing?

Contract testing validates that payload shape and semantics match an agreed schema. Replay testing sends a representative delivery through the running handler and verifies behavior. They complement each other because valid structure does not guarantee correct side effects.

Conclusion

To replay webhook payloads for local testing, preserve a sanitized raw body and relevant request metadata, generate local signatures at send time, and reuse a deterministic replay client. Validate both the response and the business outcome, including the absence of side effects for rejected or duplicate requests.

Start with the order fixture and receiver in this tutorial. Once the happy path passes, add one invalid signature, one malformed body, one duplicate delivery, and one retryable failure. Those few focused cases catch the webhook defects most likely to hide behind a simple 200 OK check.

Interview Questions and Answers

Why preserve the raw webhook body during replay testing?

Webhook signatures are commonly calculated over exact request bytes. Parsing and reserializing JSON can change those bytes even when the data is equivalent. I preserve the raw body, compute the test signature from it, and send the same buffer.

How would you design a maintainable webhook fixture?

I store one behavior per fixture, keep the raw body as text, and retain only behaviorally relevant metadata. I sanitize data before commit and document provider, schema version, and sandbox origin where useful. Signatures and delivery IDs are generated at runtime.

How do you verify webhook idempotency?

I send the same valid delivery ID more than once, including concurrent sends when risk warrants it. I assert the documented HTTP responses and prove the business effect occurred exactly once. A status-only assertion is insufficient.

How do replay tests differ from provider-generated test events?

Provider tools are convenient for standard sandbox events and forwarding. Replay fixtures preserve a specific edge case and run deterministically without depending on remote generation. I use both, with replay fixtures carrying important regressions into CI.

How do you test a webhook that queues work asynchronously?

I first assert the synchronous acceptance contract. Then I poll a correlated, observable side effect with a bounded timeout and inspect the delivery ID through the queue and worker. I avoid arbitrary sleeps and isolate the test data.

What security controls belong in local webhook replay tooling?

The tool should default to localhost, use dedicated test secrets, redact logs, and reject accidental remote targets unless explicitly enabled. Fixtures must contain sanitized data, and CI should scan for secrets. Production replay requires separate authorization and safeguards.

Which failures should a webhook replay suite cover first?

I begin with a valid event, invalid signature, malformed body, missing required metadata, duplicate delivery, and a retryable server failure. I then add schema-version and ordering cases based on provider behavior and business risk.

Frequently Asked Questions

How do I replay a webhook payload locally?

Save a sanitized raw request body and the headers that affect behavior, then POST those exact bytes to your local endpoint. Generate a fresh delivery ID and local signature at replay time, and verify the response plus the resulting application state.

Can I replay a webhook with curl?

Yes. Write the exact body to a file and use `curl --data-binary @file.json` so curl does not alter it. If the endpoint verifies signatures, calculate the signature from that same file and send it in the expected header.

Why does a replayed webhook have an invalid signature?

The secret may differ, or the replayed bytes may not match the signed bytes. JSON reformatting, newline changes, character encoding, and provider-specific timestamp formats are common causes.

Should webhook fixtures include headers?

Include headers used for content parsing, event versioning, routing, signature verification, and idempotency. Exclude or replace production secrets, cookies, authorization values, and signatures before committing the fixture.

How do I test webhook retries locally?

Use a controllable stub that returns a retryable status or delays a response for selected attempts. Assert the attempt count, delay policy, stable delivery identity, and exactly-once business effect after recovery.

How do I prevent webhook fixture data leaks?

Sanitize payloads before writing them to version control, use invented identifiers, and remove personal data and credentials. Add automated secret scanning and fixture schema checks in CI.

What should an automated webhook replay test assert?

Assert the HTTP status and response body, then verify the intended database, queue, or domain effect. Also verify that invalid and duplicate deliveries do not create unauthorized or repeated effects.

Related Guides