Until today, every request to a webhooks.cc endpoint got the same mock response. Return 200 with {"received": true} for everything, regardless of whether the request was a Stripe invoice.paid or a GitHub push event.
This meant testing realistic multi-event workflows required multiple endpoints — one per scenario. Three Stripe event types? Three endpoints. Add GitHub and Shopify? Six endpoints. Each with its own URL to configure in the provider dashboard.
Today we're shipping conditional response rules. A single endpoint can now return different responses depending on what's in the request.
How rules work
Each endpoint can have an ordered list of rules. Each rule has conditions and a response. When a request arrives, the receiver evaluates rules top-to-bottom. The first rule whose conditions match determines the response. If nothing matches, the endpoint returns its default mock response (or 200 OK if none is set).
Every request is captured regardless of which rule matches. Rules only control what the endpoint sends back — they don't filter or discard anything.
A quick example
One endpoint handles all your Stripe webhooks. You want different responses for different event types:
import { WebhooksCC } from "@webhooks-cc/sdk";
const client = new WebhooksCC({ apiKey: process.env.WHK_API_KEY! });
const endpoint = await client.endpoints.create({
name: "stripe-all-events",
responseRules: [
{
name: "Invoice paid",
logic: "and",
conditions: [
{ field: "body_path", op: "eq", path: "type", value: "invoice.payment_succeeded" },
],
response: {
status: 200,
body: '{"received": true}',
headers: { "content-type": "application/json" },
},
},
{
name: "Payment failed — simulate error",
logic: "and",
conditions: [
{ field: "body_path", op: "eq", path: "type", value: "payment_intent.payment_failed" },
],
response: {
status: 500,
body: '{"error": "internal_server_error"}',
headers: { "content-type": "application/json" },
},
},
],
mockResponse: {
status: 200,
body: '{"received": true}',
headers: {},
},
});Send a payment_intent.payment_failed event to this endpoint and it returns 500. Send an invoice.payment_succeeded and it returns 200. Send anything else and it hits the default. One endpoint, three behaviors.
What you can match on
Rules support six condition types:
| Condition | Operators | Example |
|---|---|---|
| Method | equals | Return 200 for GET health checks, 201 for POST webhooks |
| Path | equals, contains, starts with, glob | Route /stripe/* to one response, /github/* to another |
| Header | exists, equals, contains | Detect Stripe by stripe-signature, GitHub by x-github-event |
| Body contains | contains | Match events containing "invoice.paid" |
| Body JSON path | exists, equals, contains | Match type == "checkout.session.completed" |
| Query param | exists, equals | Route based on ?source=test |
Body JSON path uses dot notation — data.object.status navigates nested objects, items.0.name accesses array elements. Values are compared as strings.
Path glob matching supports * (matches any characters except ) and ** (matches across segments): /api/*/hook matches /api/v1/hook, /api/** matches /api/v1/v2/hook.
Each rule can combine multiple conditions with AND (all must match) or OR (any can match).
Three scenarios this unlocks
Simulate different provider responses on one endpoint
Instead of creating separate endpoints for Stripe, GitHub, and Shopify, detect the provider by its signature header:
const endpoint = await client.endpoints.create({
name: "multi-provider",
responseRules: [
{
name: "Stripe",
conditions: [
{ field: "header", op: "exists", name: "stripe-signature" },
],
response: { status: 200, body: '{"source": "stripe"}', headers: {} },
},
{
name: "GitHub",
conditions: [
{ field: "header", op: "exists", name: "x-github-event" },
],
response: { status: 200, body: '{"source": "github"}', headers: {} },
},
{
name: "Shopify",
conditions: [
{ field: "header", op: "exists", name: "x-shopify-hmac-sha256" },
],
response: { status: 200, body: '{"source": "shopify"}', headers: {} },
},
],
mockResponse: { status: 200, body: '{"source": "unknown"}', headers: {} },
});Point all three providers at the same endpoint URL. Each gets a response that matches what your production handler would return.
Test error handling per event type
Your handler should process checkout.session.completed events but gracefully handle failures on less critical events. Simulate that:
await client.endpoints.update(endpoint.slug, {
responseRules: [
{
name: "Checkout — succeed",
conditions: [
{ field: "body_path", op: "eq", path: "type", value: "checkout.session.completed" },
],
response: { status: 200, body: '{"received": true}', headers: {} },
},
{
name: "Payment intent — fail",
conditions: [
{ field: "body_path", op: "eq", path: "type", value: "payment_intent.payment_failed" },
],
response: { status: 500, body: '{"error": "db_timeout"}', headers: {} },
},
],
mockResponse: { status: 429, body: '{"retry_after": 30}', headers: { "retry-after": "30" } },
});Now you can watch Stripe's retry behavior in real time: does it respect retry-after? Does it back off on 500s? How many times does it retry before giving up? Previously you'd need our static mock response and could only test one failure mode at a time.
Route by path on a single endpoint
Some webhook consumers use path-based routing — /webhooks/stripe, /webhooks/github. Test that pattern with one endpoint:
await client.endpoints.update(endpoint.slug, {
responseRules: [
{
name: "Stripe path",
conditions: [
{ field: "path", op: "matches", value: "/stripe/**" },
],
response: { status: 200, body: '{"handler": "stripe"}', headers: {} },
},
{
name: "GitHub path",
conditions: [
{ field: "path", op: "matches", value: "/github/**" },
],
response: { status: 200, body: '{"handler": "github"}', headers: {} },
},
],
});Requests to https://go.webhooks.cc/w/{slug}/stripe/events match the first rule. Requests to https://go.webhooks.cc/w/{slug}/github/hooks match the second.
Setting up rules in the dashboard
You don't need the SDK to create rules. Open any endpoint's settings in the dashboard:
Open endpoint settings
Click the gear icon in the endpoint URL bar.
Add a rule
In the Response Rules section, click Add Rule. Give it a name, set the logic (ALL or ANY), add conditions, and configure the response.
Order your rules
Use the arrow buttons to reorder rules. First match wins, so put more specific rules above general ones.
Set a default
The Default Response section below the rules handles anything that doesn't match. Leave it empty for 200 OK.
Changes take effect immediately — the Rust receiver picks up the new rules on the next request with no restart or deploy.
Technical details
Rules are evaluated in the Rust webhook receiver, the same service that handles all incoming webhooks. Rule matching adds microseconds, not milliseconds — it happens in-process before the response is sent.
Rules are stored as JSONB alongside the endpoint in Postgres. The receiver fetches endpoint config — including response rules — through the capture_webhook() stored procedure on every request. Conditional rules ride the same query path as the existing mock response.
Limits: 50 rules per endpoint, 10 conditions per rule. These are generous enough for any realistic testing scenario.
The feature works on both free and pro plans.
Response Rules Documentation
Full reference for condition types, operators, JSON path syntax, and glob patterns.