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
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/abc123Every request to this endpoint gets captured and receives a 500 response.
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.
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());
}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:
| Provider | Retry behavior | Window | On persistent failure |
|---|---|---|---|
| Stripe | Exponential backoff: immediately, 5m, 30m, 2h, 5h, 10h, then every 12h | 3 days (live) / few hours (test) | Marks endpoint as failing in dashboard |
| Shopify | 8 retries with exponential backoff | 4 hours | Deletes the webhook subscription |
| GitHub | No automatic retries | — | Manual redelivery via API or UI (3-day window) |
| Twilio | 1 retry after initial failure | Minutes | Falls 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.