Skip to content
Back to blog
ProductMock ResponsesTestingNew Feature

New: One Endpoint, Multiple Responses

Webhook endpoints can now return different responses based on what's in the request. Match on headers, body fields, methods, or paths — first matching rule wins.

Apr 9, 20265 min read

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:

ConditionOperatorsExample
MethodequalsReturn 200 for GET health checks, 201 for POST webhooks
Pathequals, contains, starts with, globRoute /stripe/* to one response, /github/* to another
Headerexists, equals, containsDetect Stripe by stripe-signature, GitHub by x-github-event
Body containscontainsMatch events containing "invoice.paid"
Body JSON pathexists, equals, containsMatch type == "checkout.session.completed"
Query paramexists, equalsRoute 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:

1

Open endpoint settings

Click the gear icon in the endpoint URL bar.

2

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.

3

Order your rules

Use the arrow buttons to reorder rules. First match wins, so put more specific rules above general ones.

4

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.