Webhook Filtering and Routing with JavaScript
Filter and route webhooks by event type, branch, payload or header. Includes JavaScript examples, multi-destination patterns and debugging advice.
Webhook filtering decides whether an event should reach a destination. Webhook routing applies different filters to several outputs so one input can send releases to production, pull requests to CI, and incidents to a chat channel.
In Webhook Relay, the clearest approach is an output-attached JavaScript function. The function reads the request and calls r.stopForwarding() when that output should not receive it. Other outputs evaluate their own functions independently.
Filtering or routing?
+--> CI output: pushes to main
provider --> one input --+--> deploy output: version tags only
+--> Slack output: failed workflows only
- Filter: allow or reject an event for one output.
- Route: combine multiple outputs and filters to choose one or more destinations.
- Transform: change the accepted event into the destination's payload shape.
A single function can filter and transform, but make the rejection condition obvious near the top.
Filter by a JSON field
Only forward paid invoices:
let event
try {
event = JSON.parse(r.body)
} catch (error) {
r.stopForwarding()
return
}
if (event.type !== "invoice.paid") {
r.stopForwarding()
return
}
Malformed JSON is rejected rather than treated as an allowed event.
Filter GitHub webhooks by event header
GitHub includes an event name in X-GitHub-Event. Only allow pushes:
const eventType = r.headers["X-GitHub-Event"] || r.headers["x-github-event"]
if (eventType !== "push") {
r.stopForwarding()
return
}
Header capitalization can vary between HTTP stacks, so check the forms present in your captured request. Do not confuse event filtering with GitHub signature verification. Verify X-Hub-Signature-256 over the raw body first, as described in the signature guide.
Route branches and version tags
A Git push payload commonly includes a ref. This function allows the main branch:
const event = JSON.parse(r.body)
if (event.ref !== "refs/heads/main") {
r.stopForwarding()
return
}
Attach it to the CI output. On a separate production output, allow semantic-version tags:
const event = JSON.parse(r.body)
const ref = String(event.ref || "")
const isVersionTag = /^refs\/tags\/v\d+\.\d+\.\d+$/.test(ref)
if (!isVersionTag) {
r.stopForwarding()
return
}
This accepts refs/tags/v2.4.1 but rejects branch pushes and tags such as latest. Add an explicit prerelease pattern if your deployment process supports one.
Combine several conditions
Only send high-severity production alerts to an incident output:
const event = JSON.parse(r.body)
const severity = String(event.severity || "").toLowerCase()
const isProduction = event.environment === "production"
const isUrgent = severity === "critical" || severity === "high"
if (!isProduction || !isUrgent) {
r.stopForwarding()
return
}
Write named booleans instead of one dense expression. It is easier to compare the code with a delivery log during an incident.
Filter using query parameters
For a sender that includes tenant or environment in the URL:
const environment = r.query.environment
if (environment !== "production") {
r.stopForwarding()
return
}
Do not use a public query parameter as authentication. Anyone who knows the endpoint can change it. Use HMAC, a secret header, mTLS, or another proper webhook authentication method.
Filter multipart form submissions
Form values are arrays in r.formData. Only accept a supported form type:
const form = r.formData || {}
const formType = form.form_type && form.form_type[0]
if (formType !== "support_request") {
r.stopForwarding()
return
}
See Jotform webhook to JSON for parsing a JSON string embedded in a form field.
Filter and transform for one destination
This function sends only failed deployments to Slack and converts the event in one pass:
const event = JSON.parse(r.body)
if (event.type !== "deployment.finished" || event.status !== "failed") {
r.stopForwarding()
return
}
const message = {
text: `Deployment ${event.id} failed in ${event.environment}`
}
r.setHeader("Content-Type", "application/json")
r.setBody(JSON.stringify(message))
The original webhook remains unchanged for other outputs because the function is attached to the Slack output.
Allowlist instead of blocklist
For security-sensitive automation, define what is allowed and reject everything else:
const event = JSON.parse(r.body)
const allowedActions = ["build.requested", "deploy.requested"]
if (!allowedActions.includes(event.type)) {
r.stopForwarding()
return
}
A blocklist tends to miss new event types added by the provider. An allowlist keeps new or unknown events away from action endpoints until you review them.
Avoid accidental double delivery
Filters on two outputs may both accept the same event. That is useful for fan-out, but surprising if you intended exclusive routing.
Write the desired routing table before implementing it:
| Event | CI | Production | Slack |
|---|---|---|---|
| Push to feature branch | yes | no | no |
| Push to main | yes | no | no |
| Version tag | no | yes | yes |
| Failed workflow | no | no | yes |
Then test one captured sample for each row against every output. If exactly one destination must perform an action, make its condition mutually exclusive and make the action idempotent.
Debugging rejected events
When an expected delivery is missing:
- Confirm the request appears in the input log.
- Inspect the exact header names and JSON path.
- Check whether the function ran on the input or output.
- Test empty arrays, missing nested objects and different capitalization.
- Temporarily send a sanitized sample to Webhook Bin.
- Confirm a destination failure was not mistaken for a filter rejection.
Do not log full sensitive payloads just to debug a condition. Use event IDs and the small set of routing fields needed to explain the decision.
Next steps
Use the transformation cookbook when the accepted event also needs a new body, headers or path. For production routes, add retry and idempotency handling so an ambiguous delivery cannot trigger an action twice.
