Build a Webhook-to-API Gateway: Extract, Validate, and Forward

Published April 16, 202615 min read

A lot of integrations need something more specific than a plain webhook receiver. You need a webhook-to-API gateway: accept the inbound event, extract the useful fields, validate whether the event should continue, and then forward a new request to another API in the exact shape that system expects.

The usual answer is custom webhook middleware in Express, FastAPI, or a serverless function. The problem is that your "small middleware" quickly turns into an event routing service with logging, retries, validation rules, response handling, secrets, and debugging headaches. This guide shows how to build the same webhook processing pipeline in Hooklistener using a full action chain: extract_json -> condition -> assign_variable -> http_request.

Why Teams End Up Writing Webhook Middleware

The job usually sounds harmless:

  • Receive a webhook from Stripe, Shopify, GitHub, an internal app, or a partner platform.
  • Check whether the event is the right type, size, status, or source before it hits the rest of your stack.
  • Forward a smaller, cleaner request to a downstream API like a CRM, ERP, risk service, ticketing system, or internal ingestion endpoint.

Then reality shows up. The sender expects a fast acknowledgment. The destination is slower. Some events should be ignored. Some need conditional routing. Some fields require normalization. Now your webhook gateway has branching logic, retries, body transforms, and debug tooling. You are not just receiving a webhook anymore. You are maintaining a translation and routing layer.

gateway-middleware.js
app.post("/provider/webhook", async (req, res) => {
  const eventType = req.body.event;
  const amount = req.body.payment.amount;

  if (eventType !== "payment.succeeded" || amount < 10000) {
    return res.status(202).json({ skipped: true });
  }

  const outbound = {
    paymentId: req.body.payment.id,
    customerId: req.body.payment.customer_id,
    amountCents: amount,
    region: req.body.account.region.toUpperCase(),
  };

  await fetch("https://internal.example.com/risk/payments", {
    method: "POST",
    headers: { "content-type": "application/json" },
    body: JSON.stringify(outbound),
  });

  res.status(202).json({ forwarded: true });
});

That code is reasonable. It is also one more service to deploy, monitor, patch, and explain to the next engineer. If your actual need is webhook conditional forwarding plus light transformation, you can usually do it faster at the edge where the webhook lands.

What a Good Webhook-to-API Gateway Actually Does

Receive reliably

Accept the incoming request on a public URL, keep the raw payload visible, and preserve enough context to replay the event later if the mapping changes.

Validate quickly

Decide whether the event should continue based on type, amount, status, headers, query params, or path-level metadata without blocking on the downstream system.

Shape the outbound request

Build a clean API request body, normalize fields, set headers, and carry through only what the next system needs.

Forward without slowing the ACK

Return a fast response to the webhook sender, then hand off the outbound API call to a background worker so downstream latency does not become inbound webhook latency.

The Action Chain Pipeline

In Hooklistener, the cleanest webhook gateway pattern looks like this:

  1. 1. `extract_json` pulls request fields into reusable variables.
  2. 2. `condition` decides whether the chain should continue or stop.
  3. 3. `assign_variable` derives normalized or composite values for the outbound API.
  4. 4. `http_request` sends the transformed request to the downstream destination.

The marketing version of this is "no-code routing." The engineering version is more useful: you get a visible, replayable webhook processing pipeline where extraction, validation, and forwarding happen in order instead of being buried in ad hoc middleware branches.

Example: Only Forward High-Value Payment Events

Suppose a provider sends every payment event to one webhook URL. Your internal risk API only wants `payment.succeeded` events above a threshold, and it expects a different body format than the source provider sends.

Incoming webhook payload

{
  "event": "payment.succeeded",
  "account": {
    "region": "eu"
  },
  "payment": {
    "id": "pay_123",
    "amount": 14500,
    "currency": "usd",
    "method": "card",
    "customer_id": "cus_99"
  },
  "metadata": {
    "priority": "high"
  }
}

Downstream API request

{
  "eventType": "payment.succeeded",
  "paymentId": "pay_123",
  "customerId": "cus_99",
  "amountCents": 14500,
  "currency": "usd",
  "region": "EU",
  "priority": "high"
}

Step 1: Extract the fields you will reuse

{
  "type": "extract_json",
  "phase": "sync",
  "config": {
    "source": "body",
    "extractions": [
      { "path": "event", "variable": "event_type" },
      { "path": "payment.id", "variable": "payment_id" },
      { "path": "payment.amount", "variable": "amount" },
      { "path": "payment.currency", "variable": "currency" },
      { "path": "payment.method", "variable": "payment_method" },
      { "path": "payment.customer_id", "variable": "customer_id" },
      { "path": "metadata.priority", "variable": "priority" }
    ]
  }
}

This step keeps the rest of the chain readable. Instead of repeating long field paths like `body.payment.customer_id` everywhere, you promote the values you care about into named variables once and reuse them downstream.

Step 2: Apply conditional forwarding rules

{
  "type": "condition",
  "phase": "sync",
  "config": {
    "conditions": [
      { "event_type": { "$eq": "payment.succeeded" } },
      { "amount": { "$gte": 10000 } },
      { "payment_method": { "$in": ["card", "bank_transfer"] } }
    ],
    "on_match": "continue",
    "on_no_match": "stop"
  }
}

This is webhook conditional forwarding in plain language: if the event is the wrong type, below the threshold, or from the wrong payment method, stop the chain. The webhook can still be accepted and captured, but the downstream API call never fires. That gives you routing control without writing `if/else` middleware.

Step 3: Normalize outbound values

{
  "type": "assign_variable",
  "phase": "sync",
  "config": {
    "variable": "region_code",
    "value": "$request.body.account.region.upper$"
  }
}

`assign_variable` is where you convert raw input into API-ready values. Uppercase the region. Build composite IDs. Normalize email casing. Create labels or route markers. It is the bridge between extraction and forwarding.

Step 4: Forward the transformed request

{
  "type": "http_request",
  "phase": "async",
  "config": {
    "method": "POST",
    "url": "https://internal.example.com/risk/payments",
    "headers": {
      "content-type": "application/json",
      "x-webhook-event": "$variable.event_type$",
      "x-source-path": "$request.path$"
    },
    "body": "{\"eventType\":\"$variable.event_type$\",\"paymentId\":\"$variable.payment_id$\",\"customerId\":\"$variable.customer_id$\",\"amountCents\":$variable.amount$,\"currency\":\"$variable.currency$\",\"region\":\"$variable.region_code$\",\"priority\":\"$variable.priority$\"}"
  }
}

At this point you have a proper webhook-to-API gateway: inbound webhook received, fields extracted, conditions applied, values normalized, and a transformed outbound request forwarded to the destination.

MongoDB-Style Condition Operators, Without the Ambiguity

Hooklistener uses dollar-prefixed operators that feel familiar if you have worked with MongoDB-style filters. That makes the condition action easy to scan, but it is worth being precise: the model is similar, not a claim of full MongoDB query compatibility. For example, Hooklistener uses `"$neq"` for not-equals rather than MongoDB's `"$ne"`.

OperatorUse CaseExample
$eq, $neqExact matchingevent_type == payment.succeeded
$gt, $gte, $lt, $lteThresholdsamount gte 10000
$contains, $startsWith, $endsWith, $regexString routingMatch header prefixes or event naming patterns
$in, $ninAllowed or blocked setsForward only card and bank transfer events
$exist, $refPresence and comparisonsRequire a header or compare two fields
$or, $and, $notBranching logicExpress richer routing rules without nesting app code

Basic conditions are usually enough for webhook routing, but you can go further when needed. The filter engine evaluates a list of conditions as an implicit AND, which already covers a lot of gateway logic. For more complex routing, top-level logical blocks let you encode richer validation rules in the action itself.

{
  "conditions": [
    { "event_type": { "$eq": "payment.succeeded" } },
    {
      "$or": [
        { "amount": { "$gte": 10000 } },
        { "priority": { "$eq": "vip" } }
      ]
    },
    {
      "$not": {
        "currency": { "$eq": "test" }
      }
    }
  ],
  "on_match": "continue",
  "on_no_match": "stop"
}

That pattern is useful when you need webhook routing based on a mix of event type, account tier, amount, feature flag, or environment. Instead of hiding the decision tree in code, you keep it in a visible action chain that the team can reason about.

Sync vs Async Phases Matter More Than Most Teams Think

The most important architecture choice in a webhook gateway is not the JSON mapping. It is whether a step runs before or after the acknowledgment goes back to the sender.

Sync: response path

Runs before the sender gets an answer.

  • Best for extraction, gating, validation, and response edits.
  • Use when the result must influence whether the chain continues.
  • Keep it lean so you do not turn destination latency into sender latency.

Async: background worker

Runs after the webhook has been captured and acknowledged.

  • Best for outbound API calls, storage, scripts, and side effects.
  • Values extracted during sync remain available to later async actions.
  • This is the right place for forwarding because it protects the ACK path.

This split is what makes the gateway pattern work cleanly. You validate on the response path. You forward on the worker path. If the outbound API is slow or temporarily unavailable, that becomes a forwarding problem to inspect and retry, not a reason the original webhook sender times out.

It is also why `modify_response` belongs in sync only: changing the sender's HTTP response after the sender already left would be nonsense. If you need a custom `200`, `202`, or response body, do it on the response path. If you need to call another API, do it in async.

Common Webhook Routing Patterns You Can Build

  • Filter noise before it reaches the destination. Ignore low-value events, health checks, duplicates, or sandbox traffic using conditions on method, path, query params, and body fields.
  • Route by business value. Forward only high-value payments, enterprise customers, or events with a matching priority tag.
  • Normalize before forwarding. Convert casing, count items, flatten nested objects, or repackage arrays before they touch a stricter API.
  • Preserve visibility. Keep the original webhook capture in the same place you apply the forwarding rules, so when something breaks you can replay the exact event instead of rebuilding it from logs.

Why This Beats Thin Middleware for Many Integrations

The biggest win is not that you save a few lines of code. It is that your gateway becomes observable. The raw webhook, the extracted variables, the condition outcome, and the outbound request logic all live in the same system instead of being spread across a provider dashboard, a terminal log, and a small service nobody wants to own.

Faster debugging

When a destination rejects a forwarded request, you can inspect the captured payload and update the chain instead of pushing a new middleware deploy just to test a field rename.

Safer acknowledgments

The sender gets a fast response path while forwarding work happens in the background. That is the right reliability boundary for most webhook integrations.

Cleaner ownership

Your integration behavior is visible as an ordered chain, which is easier for multiple engineers to audit than bespoke middleware branches in a sidecar service.

Less reinvention

You do not need to rebuild capture, replay, routing, and transformation separately. The gateway behavior sits on top of the same webhook visibility you already needed anyway.

When Custom Code Still Makes Sense

This approach is strong for webhook intake, filtering, transformation, and forwarding. It is not a reason to pretend every integration should become a configuration problem.

  • Write code when the gateway depends on heavyweight business logic or multiple external lookups.
  • Write code when you need provider-specific cryptography or a domain workflow that goes far beyond request shaping.
  • Use the action chain when the problem is really receive, validate, route, and forward.

Build the Gateway Without Owning Another Service

For many teams, the right webhook gateway is not a new middleware repo. It is a captured endpoint with an action chain:

Extract fields from the inbound webhook
Validate the event with explicit conditions
Forward a transformed API request asynchronously
Replay the same event when you need to refine the routing
Create a Webhook Gateway Endpoint

Related Reading