Skip to content
Back to blog
TestingMock responsesRetriesError handling

Mock Webhook Responses for Retry and Error Testing

Configure your webhook endpoint to return 500s, 429s, or custom responses on demand. Observe how Stripe, Shopify, and GitHub handle failures and retries.

Mar 10, 20267 min read

Your webhook handler returns 200 in development. It returns 200 in staging. Then it hits production, the database is slow, and it returns 500. Stripe retries. Your handler processes the event twice. A customer gets charged double.

You never tested what happens when your endpoint fails because you had no way to make it fail on demand.

webhooks.cc endpoints support configurable mock responses. Set the status code, headers, and body your endpoint returns — then watch how your webhook provider reacts when it gets a 500, a 429, or a 10-second timeout.

How mock responses work

Every webhooks.cc endpoint can return a custom response instead of the default 200. The receiver still captures the full request for inspection, but replies to the sender with whatever you configure.

Webhook provider → POST /w/{slug}
  → Receiver captures headers, body, method (stored for inspection)
  → Receiver returns your mock response (e.g., 500 Internal Server Error)
  → Provider sees the failure and schedules a retry
  → Retry arrives → captured again → mock response returned again

You control the mock via the SDK or the dashboard.

Set up a mock endpoint

1

Create an endpoint with a mock response

Use the SDK to create an endpoint that returns a 500:

import { WebhooksCC } from "@webhooks-cc/sdk";
 
const client = new WebhooksCC({ apiKey: process.env.WHK_API_KEY! });
 
const endpoint = await client.endpoints.create({
  name: "retry-test",
  expiresIn: "6h",
  mockResponse: {
    status: 500,
    body: '{"error": "internal server error"}',
    headers: { "content-type": "application/json" },
  },
});
 
console.log(endpoint.url);
// https://go.webhooks.cc/w/abc123

Every request to this endpoint gets captured and receives a 500 response.

2

Point your provider at the endpoint

Register the endpoint URL in your provider's webhook settings — Stripe Dashboard, GitHub repository settings, Shopify Admin API, etc. Then trigger an event.

The provider sends the webhook, gets a 500 back, and schedules a retry according to its own policy.

3

Watch the retries arrive

Open the webhooks.cc dashboard or use the SDK to list captured requests:

const requests = await client.requests.list(endpoint.slug, {
  limit: 50,
});
 
for (const req of requests) {
  console.log(req.receivedAt, req.method, req.headers["stripe-signature"] ? "retry" : "first");
}

Or stream them in real time:

for await (const req of client.requests.subscribe(endpoint.slug)) {
  console.log("Received:", new Date(req.receivedAt).toISOString());
}
4

Change the mock to test recovery

After observing the retry pattern, switch the endpoint to return 200 and confirm the provider stops retrying:

await client.endpoints.update(endpoint.slug, {
  mockResponse: {
    status: 200,
    body: '{"received": true}',
    headers: { "content-type": "application/json" },
  },
});

The next retry from the provider gets a 200 and delivery succeeds. You've now tested both the failure and recovery paths.

To remove the mock entirely and return the default 200:

await client.endpoints.update(endpoint.slug, {
  mockResponse: null,
});

Test scenarios

Simulate a server error (500)

The most common failure. Your server is overloaded, a deployment is in progress, or the database is down.

mockResponse: {
  status: 500,
  body: '{"error": "internal server error"}',
  headers: { "content-type": "application/json" },
}

Use this to verify that your provider retries and that your handler is idempotent when the retry eventually succeeds.

Simulate rate limiting (429)

Some providers respect 429 Too Many Requests and back off. Others treat it the same as a 500.

mockResponse: {
  status: 429,
  body: '{"error": "too many requests"}',
  headers: {
    "content-type": "application/json",
    "retry-after": "60",
  },
}

Test whether your provider honors the Retry-After header or follows its own schedule.

Simulate an auth failure (401)

Providers typically stop retrying on 4xx errors (except 429). A 401 can help verify that your provider gives up rather than retrying indefinitely.

mockResponse: {
  status: 401,
  body: '{"error": "unauthorized"}',
  headers: { "content-type": "application/json" },
}

Return a custom body

Test how your system handles unexpected response shapes — HTML error pages instead of JSON, empty bodies, or large payloads.

mockResponse: {
  status: 503,
  body: "<html><body><h1>Service Unavailable</h1></body></html>",
  headers: { "content-type": "text/html" },
}

How providers handle failures

Each webhook provider follows its own retry policy. Here's what to expect when your endpoint returns an error:

ProviderRetry behaviorWindowOn persistent failure
StripeExponential backoff: immediately, 5m, 30m, 2h, 5h, 10h, then every 12h3 days (live) / few hours (test)Marks endpoint as failing in dashboard
Shopify8 retries with exponential backoff4 hoursDeletes the webhook subscription
GitHubNo automatic retriesManual redelivery via API or UI (3-day window)
Twilio1 retry after initial failureMinutesFalls back to secondary URL if configured

Shopify deletes your webhook subscription after 8 consecutive failures. If you're testing with a Shopify webhook, switch the mock back to 200 before the 4-hour window closes, or you'll need to re-register.

Automate the whole test

Combine mock responses with the SDK's waitForAll to write an automated retry test:

import { WebhooksCC, matchMethod } from "@webhooks-cc/sdk";
 
const client = new WebhooksCC({ apiKey: process.env.WHK_API_KEY! });
 
// 1. Create endpoint that returns 500
const endpoint = await client.endpoints.create({
  name: "retry-test",
  expiresIn: "1h",
  mockResponse: {
    status: 500,
    body: '{"error": "internal server error"}',
  },
});
 
// 2. Send a signed Stripe webhook
await client.endpoints.sendTemplate(endpoint.slug, {
  provider: "stripe",
  template: "invoice.paid",
  secret: "whsec_test_123",
});
 
// 3. Wait for the initial delivery
const [first] = await client.requests.waitForAll(endpoint.slug, {
  count: 1,
  timeout: "15s",
  match: matchMethod("POST"),
});
 
console.log("First attempt captured at:", first.receivedAt);
 
// 4. Switch to 200 so the next retry succeeds
await client.endpoints.update(endpoint.slug, {
  mockResponse: { status: 200, body: '{"received": true}' },
});
 
// 5. Wait for the retry
const requests = await client.requests.waitForAll(endpoint.slug, {
  count: 2,
  timeout: "10m",
  match: matchMethod("POST"),
});
 
console.log(`Captured ${requests.length} deliveries (initial + retry)`);
 
// 6. Clean up
await client.endpoints.delete(endpoint.slug);

The sendTemplate method generates a properly signed provider payload. Use it instead of raw send when you want the provider's retry behavior to match production — some providers only retry signed deliveries.

Compare retries with diffRequests

After capturing multiple delivery attempts, diff them to see what changed between retries:

import { diffRequests } from "@webhooks-cc/sdk";
 
const diff = diffRequests(requests[0], requests[1], {
  ignoreHeaders: ["date", "x-request-id"],
});
 
if (diff.matches) {
  console.log("Retry payload is identical to original — idempotency safe");
} else {
  console.log("Payloads differ:", diff);
}

Most providers send identical payloads on retry. Some (like Shopify) include a X-Shopify-Triggered-At timestamp that changes, but the body stays the same.

FAQ

SDK Reference

Full API reference for endpoints, mock responses, and request capture.

CLI Reference

Use whk tunnel to forward retries to your local server for debugging.