Run Custom JavaScript on Every Webhook (No Server Required)

Published April 16, 202612 min read

A lot of webhook logic is too specific for plain field mapping but too small to justify another service. You need a few lines of code to branch on payload content, build a custom outbound body, or choose the destination dynamically. The usual fix is a tiny Express app, a Lambda function, or an edge worker that quietly becomes one more thing to deploy and babysit.

This guide shows a better pattern: webhook JavaScript processing in Hooklistener using the run_script action and script-based forwarding destinations. You get custom logic on incoming webhooks, but the code runs inside a QuickJS WASM sandbox with hard limits, not inside a server you have to operate.

Why a "Tiny Webhook Handler" Becomes Infrastructure

The request usually starts out modest:

  • Inspect the inbound JSON and check a couple of fields.
  • Build a custom payload the downstream API expects.
  • Send to one destination for normal events and another for exceptional ones.

That is exactly where teams reach for a serverless webhook handler. It feels lighter than a full service, but the operational burden still accumulates. You need a public URL, secrets, logging, replay, debugging for malformed payloads, retries, and enough observability to explain why a specific event took the wrong branch. The code is short. The ownership is not.

mini-webhook-server.js
app.post("/partner-webhook", async (req, res) => {
  const body = req.body || {};
  const urgent = body.priority === "urgent";
  const customer = String(body.customer?.email || "").toLowerCase();

  const outbound = {
    sourceEvent: body.event,
    accountId: body.account?.id,
    customerEmail: customer,
    lineCount: Array.isArray(body.lines) ? body.lines.length : 0,
  };

  const target = urgent
    ? "https://ops.example.com/intake"
    : "https://crm.example.com/orders";

  await fetch(target, {
    method: "POST",
    headers: { "content-type": "application/json" },
    body: JSON.stringify(outbound),
  });

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

None of that code is exotic. It is also exactly the kind of webhook scripting that can live closer to capture, where the request is already stored, inspectable, and replayable. If the job is transform, branch, and forward, the better answer is often sandboxed code instead of another runtime to maintain.

Two Ways to Run Code on a Webhook

Hooklistener supports two scripting patterns. They both use the same QuickJS WASM sandbox, but they fit different jobs.

PatternBest forWhat happens next
run_script actionComputing a routing or payload plan inside an action chainThe script returns validated action descriptors that later chain steps can store or reuse
Script forwarding destinationDirect async routing, conditional forwarding, and fan-out after captureReturned actions are turned into actual outbound forwards automatically

Use `run_script` when

You want custom JavaScript inside a larger webhook processing pipeline that may also include extract_json, condition, assign_variable, or http_request.

Use script forwarding when

The script itself should decide where the webhook goes and can return zero, one, or several outbound requests after the capture step.

What the JavaScript Sandbox Actually Receives

The script must define a handle(request) function. Hooklistener passes in a request-like object with the pieces you usually need for webhook logic:

{
  "method": "POST",
  "path": "/incoming/orders",
  "headers": {
    "content-type": "application/json",
    "x-shop": "eu-west"
  },
  "query": {
    "source": "partner"
  },
  "body": {
    "event": "order.created",
    "priority": "urgent",
    "customer": {
      "email": "Ops@Example.com"
    },
    "order": {
      "id": "ORD-9001",
      "total_cents": 14900
    },
    "lines": [
      { "sku": "SKU-1", "qty": 2 },
      { "sku": "SKU-2", "qty": 1 }
    ]
  }
}

The runtime is intentionally narrow. It is designed for webhook processing logic, not for arbitrary application workloads.

QuickJS in WASM

Each run uses a fresh WebAssembly-backed QuickJS instance, so state and memory are isolated from other webhook executions.

No filesystem or network

The script cannot open files, call external APIs, or reach host functions. If you need outbound delivery, return action objects and let Hooklistener handle the HTTP side.

16 MB memory limit

The sandbox has a 16 MB memory ceiling, which is large enough for normal webhook shaping but keeps scripts from turning into unbounded workers.

5 second timeout

Each run is capped at 5 seconds wall-clock time. Combined with fuel metering, that keeps runaway loops from occupying the webhook pipeline indefinitely.

Output is also validated before it can be used:

  • The script must return an array, not an arbitrary object.
  • Each action must include a non-empty url.
  • Supported HTTP methods are validated and normalized.
  • A single run is capped at 10 actions.
  • Each action body is capped at 1 MB.

Example 1: Use `run_script` to Build an Outbound Plan

The most important nuance in this whole feature set is easy to miss: run_scriptdoes not directly fire the outbound request. It runs custom code, validates the returned actions, and can store the result in a variable. That makes it a strong fit for "compute the next step" logic inside an action chain.

Step 1: Generate the plan with JavaScript

{
  "type": "run_script",
  "phase": "async",
  "config": {
    "response_variable": "script_plan",
    "code": "function handle(request) {\n  var body = request.body || {};\n  var order = body.order || {};\n  var urgent = body.priority === 'urgent';\n\n  return [{\n    url: urgent ? 'https://ops.example.com/high-priority' : 'https://crm.example.com/orders',\n    method: 'POST',\n    headers: {\n      'content-type': 'application/json',\n      'x-event': String(body.event || 'unknown')\n    },\n    body: {\n      externalOrderId: order.id,\n      customerEmail: String(body.customer?.email || '').toLowerCase(),\n      totalCents: Number(order.total_cents || 0),\n      lineCount: Array.isArray(body.lines) ? body.lines.length : 0\n    }\n  }];\n}"
  }
}

This is webhook scripting in its cleanest form. The logic is compact, readable, and free to branch, loop, or normalize values. When the script succeeds, the response variable stores a structured result like {"ok": true, "result": [...]}. When it fails, the result becomes {"ok": false, "error": "..."}. The chain can continue either way, which is useful when scripting is a helpful step rather than a hard gate.

Step 2: Reuse the plan in a later HTTP action

{
  "type": "http_request",
  "phase": "async",
  "config": {
    "method": "POST",
    "url": "$variable.script_plan.result.0.url$",
    "headers": {
      "content-type": "application/json",
      "x-event": "$request.body.event$"
    },
    "body": "$variable.script_plan.result.0.body$"
  }
}

That pattern matters for safe payload handling. The script can return the outbound body as an object, Hooklistener JSON-encodes it, and the later http_request action can send that fully formed string. You avoid hand-building JSON with lots of interpolated fragments and edge cases around escaping.

If the goal is "run code on a webhook and then keep processing inside the chain," this is the right model. Think of run_script as a decision and payload generator, not as an ad hoc fetch wrapper.

Example 2: Let the Script Forward Directly After Capture

If what you really want is a serverless webhook handler that decides destinations on its own, use a script forwarding destination instead of an action-chain script. In that mode, the same sandbox returns outbound actions and Hooklistener turns them into real forwarding jobs after the webhook is captured.

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

  if (body.event === "order.created") {
    actions.push({
      url: "https://erp.example.com/inbound/orders",
      method: "POST",
      headers: { "content-type": "application/json" },
      body: {
        orderId: body.order?.id,
        totalCents: body.order?.total_cents,
        customerEmail: String(body.customer?.email || "").toLowerCase(),
      },
    });
  }

  if (body.priority === "urgent") {
    actions.push({
      url: "https://ops.example.com/alerts",
      method: "POST",
      headers: { "content-type": "application/json" },
      body: {
        event: body.event,
        orderId: body.order?.id,
        sourcePath: request.path,
      },
    });
  }

  return actions;
}

This is where webhook fan-out becomes simple. One incoming request can produce zero actions, one action, or several. The forward happens asynchronously after capture, so retries and failures do not change the HTTP response already sent back to the webhook source.

Async by design

Script forwarding destinations run after capture. They are for downstream delivery logic, not for customizing the webhook sender's immediate response.

Multiple destinations

An endpoint can have multiple forwarding destinations, and a single script destination can itself return multiple actions. That gives you both parallel destinations and per-event fan-out.

What This Is Good At, and What It Is Not

Good webhook scripting lives in the middle ground between static mapping and full application code. It is excellent when the logic depends only on the incoming request and the output is an HTTP action or small decision. It is the wrong tool when the script needs external state or long-running workflows.

Strong fit

  • Normalize fields that are awkward with pure templates.
  • Branch on event type, amount, region, or custom headers.
  • Return different payload shapes to different systems.
  • Fan out one webhook into several outbound API calls.

Weak fit

  • Calling third-party APIs directly from the script. The sandbox has no network access.
  • Heavy data processing that risks the 16 MB or 5 second guardrails.
  • Stateful workflows that need durable memory across events.
  • Full business workflows that belong in an application service, not in the transport layer.

When JavaScript Beats Templates

Not every webhook needs code. If the job is straight mapping, interpolation and modifiers are usually enough. Use JavaScript when you need custom loops, richer branching, dynamic destinations, or logic that would be unreadable as a long body template.

A practical rule:

  • Start with action-chain transformation when the output is mostly field mapping.
  • Move to run_script when the mapping becomes hard to express cleanly with modifiers alone.
  • Move to script forwarding destinations when the script should own the actual webhook routing and fan-out after capture.

That progression keeps the implementation honest. You are not forcing everything into "no-code" language, and you are not reaching for a server every time the routing needs a small amount of real logic.

Run Webhook Logic Without Owning Another Runtime

The useful version of "run custom JavaScript on every webhook" is not unlimited code execution. It is a constrained environment that lets you:

Inspect the incoming request with real branching logic
Generate validated outbound actions safely
Forward or fan out without blocking the inbound ACK
Replay captured payloads instead of redeploying a handler
Create a Scripted Webhook Endpoint

Related Reading