Conditional Webhook Routing: Send Different Payloads to Different Services

Published April 16, 202611 min read

A plain webhook forward is rarely enough in production. One provider sends every event to a single URL, but you need to send paid invoices to finance, urgent incidents to ops, and some high-value events to two systems at once. That is conditional webhook routing: one inbound stream, different downstream behaviors based on what is inside the request.

This guide shows how to build that router in Hooklistener using two patterns that match the real backend model: action chain conditions for fixed routing decisions, and script-based forwarding destinations for advanced branching, webhook fan-out, and JavaScript routing logic after capture.

Why Teams Need a Webhook Router Instead of a Plain Receiver

The routing problem usually looks like this:

  • A payment or commerce platform delivers every event to one webhook endpoint.
  • Different downstream services care about different subsets of that stream.
  • Each service expects its own payload shape, headers, and delivery timing.

At that point you do not just need webhook forwarding. You need webhook routing rules. Some people call it a webhook splitter, some call it a webhook middleware layer, some call it a gateway. The name matters less than the outcome: inbound events should be evaluated once, routed predictably, and delivered without turning your ACK path into a fragile application server.

Sync decisions

Use sync actions when you need to inspect the request and decide whether a route should continue before the response is finalized.

Async delivery

Use async forwarding when the webhook sender should receive a fast acknowledgment while delivery and retries happen in the background.

The Two Routing Models That Matter

The cleanest way to think about conditional webhook routing is to separate fixed routing from dynamic routing.

ModelBest fitCore pieces
Fixed routeSend to a known service only when explicit conditions matchcondition + http_request
Dynamic routeChoose destinations or fan out based on JavaScript logicScript forwarding destination returning one or more outbound actions

You usually start with the fixed route because it is explicit and easy to audit. You move to script-based routing when the webhook router needs real branching, a dynamic destination list, or a fan-out pattern that would be awkward as a linear chain.

Fixed Routing with `condition` and `http_request`

A lot of routing logic is not actually complicated. It is just a yes-or-no gate before a downstream request. Suppose one webhook stream includes every invoice event, but your finance API only wants paid invoices from EU accounts above a threshold.

Incoming webhook

{
  "event": "invoice.paid",
  "account": {
    "region": "eu",
    "tier": "enterprise"
  },
  "invoice": {
    "id": "inv_109",
    "total_cents": 24500,
    "currency": "eur"
  },
  "customer": {
    "email": "Billing@Example.com"
  }
}

Gate the route with a condition action

{
  "type": "condition",
  "phase": "sync",
  "config": {
    "conditions": [
      { "body.event": { "$eq": "invoice.paid" } },
      { "body.account.region": { "$in": ["eu", "uk"] } },
      { "body.invoice.total_cents": { "$gte": 10000 } }
    ],
    "on_match": "continue",
    "on_no_match": "stop"
  }
}

That condition list is an implicit AND. All three clauses must match. If any one fails, the route stops there. This is the cleanest version of webhook conditional forwarding: inspect the payload, let matching events continue, and skip everything else without adding branchy middleware code.

Forward the transformed payload asynchronously

{
  "type": "http_request",
  "phase": "async",
  "config": {
    "method": "POST",
    "url": "https://finance.example.com/invoices",
    "headers": {
      "content-type": "application/json",
      "x-event": "$request.body.event$"
    },
    "body": "{\"invoiceId\":\"$request.body.invoice.id$\",\"region\":\"$request.body.account.region.upper$\",\"amountCents\":$request.body.invoice.total_cents$,\"currency\":\"$request.body.invoice.currency$\",\"customerEmail\":\"$request.body.customer.email.lower$\"}"
  }
}

The split between sync and async matters. The decision can happen immediately, but the outbound request does not need to block the webhook acknowledgment. That keeps inbound latency predictable even when the downstream service is slower or less reliable.

The Condition Operators You Can Route On

The condition engine is built around MongoDB-style operators. That is useful shorthand for developers, but the important part is the actual supported set and how it maps to webhook payloads.

Operator familyOperatorsRouting use case
Comparison$eq, $neq, $gt, $gte, $lt, $lteRoute by event type, totals, or thresholds
String$contains, $startsWith, $endsWith, $regexMatch event names, headers, or path fragments
Array$in, $ninRoute only approved regions, sources, or event groups
Special$exist, $refCheck field presence or compare against another value
Logical$or, $and, $notExpress richer routing trees without hand-written code

Field paths can reference the whole request surface:method, path, headers.content-type, query.source, body.invoice.id, and similar nested paths. That makes the router useful even when the important signal is outside the JSON body.

{
  "conditions": [
    { "body.event": { "$eq": "invoice.paid" } },
    {
      "$or": [
        { "body.account.tier": { "$eq": "enterprise" } },
        { "headers.x-priority": { "$eq": "urgent" } }
      ]
    },
    { "query.source": { "$neq": "sandbox" } }
  ]
}

That is often enough for a deterministic webhook routing rule. If you can describe the route as a finite set of explicit conditions, stay with the action chain. It is easier to reason about than a script.

Use Script-Based Forwarding for True Routing and Fan-Out

A webhook splitter stops being simple when one event may go to several services, different branches need different payload shapes, or the destination list is easier to compute in code than as configuration. That is exactly what script forwarding destinations are for.

router.js
function handle(request) {
  const body = request.body || {};
  const actions = [];

  if (body.event === "invoice.paid") {
    actions.push({
      url: "https://finance.example.com/invoices",
      method: "POST",
      headers: { "content-type": "application/json" },
      body: {
        invoiceId: body.invoice?.id,
        amountCents: body.invoice?.total_cents,
        region: body.account?.region,
      },
    });
  }

  if (body.event === "invoice.refunded") {
    actions.push({
      url: "https://erp.example.com/refunds",
      method: "POST",
      headers: { "content-type": "application/json" },
      body: {
        invoiceId: body.invoice?.id,
        refundReason: body.refund?.reason,
      },
    });
  }

  if (body.account?.tier === "enterprise") {
    actions.push({
      url: "https://analytics.example.com/account-events",
      method: "POST",
      headers: { "content-type": "application/json" },
      body: {
        event: body.event,
        accountTier: body.account?.tier,
        sourcePath: request.path,
      },
    });
  }

  return actions;
}

That single script can return multiple outbound actions, which means one webhook can be routed to finance and analytics, or to refunds and ops, depending on content. That is real webhook fan-out, not just a yes-or-no gate.

The async behavior is important:

  • Script forwarding runs after the request is captured.
  • Returned actions are queued as outbound forwards.
  • Retries and failures do not affect the incoming webhook response that has already been sent.

That separation is what makes this model useful in production. You can route aggressively without putting downstream service health on the critical path for webhook receipt.

Multiple Forwarding Destinations on One Endpoint

An endpoint is not limited to one destination. You can attach multiple forwarding destinations, and each enabled destination receives the captured webhook independently. That opens up a few practical architectures:

Business routing

One script destination decides which application services should receive the event.

Human notifications

A Telegram destination can notify humans without changing the business-system routing logic.

Controlled fan-out

The script can still return several outbound actions on top of those separate destinations, giving you fine-grained branching without duplicating the inbound endpoint.

This is the difference between a single-purpose forwarder and a real webhook routing layer. The endpoint receives once, then the platform fans out according to rules you can inspect and test.

Which Routing Pattern Should You Use?

Prefer action-chain conditions when

  • The route targets a known service with explicit fixed rules.
  • You want routing behavior that is highly visible and easy to audit from configuration alone.
  • The real problem is "should this request continue to this destination or not?"

Prefer script routing when

  • The destination list is dynamic or depends on richer branching logic.
  • One event may need to fan out to more than one service.
  • The payload shape differs enough across branches that code is clearer than a long template.

In practice, many teams combine them. Use conditions and action chains for fixed, reviewable routes. Use script forwarding for the branch-heavy paths where a linear pipeline starts to feel unnatural. The point is not to force everything into one model. The point is to keep the router obvious and maintainable.

Build a Router, Not Another Sidecar Service

A good webhook router should let you:

Route webhooks by content instead of maintaining custom middleware branches
Send different payloads to different downstream services
Fan out from one incoming event when several systems need the same signal
Keep retries and downstream failures off the inbound ACK path
Create a Routed Webhook Endpoint

Related Reading