Webhook router
Webhook router is a single public endpoint for receiving webhooks. Any
external service (GitHub, GitLab, Grafana, Stripe, your CI, etc.)
sends a POST with a JSON body to the same URL like /router/R<token>/<path>, and Notifly
parses the payload and routes it to channels according to the rules you
configure in the admin.
This replaces the old “webhook” mechanism: instead of a separate URL for each integrated service you have one router per account (singleton), and routing is defined by rules. A single incoming request can match multiple rules and be sent to multiple channels.
Useful for:
- splitting Grafana alerts between teams (
/grafana→ different channels bycommonLabels.severity); - routing GitHub events (pushes to one channel, issues to another);
- receiving JSON webhooks from a CRM, payment provider or CI without writing a backend;
- a single “funnel” for webhooks that you can later re-sort with rules without changing the sender URLs.
How it works
Section titled “How it works”- Each account has a single router with a token like
R…(prefixR). Public URL —https://api.notifly.ru/router/R<token>/<path>. - An external service sends
POST(orGET) with a JSON body to that URL. - The router runs the request through all enabled rules. Each rule
checks two things:
- URL path (
*pathafter the token) — whether it matches the rule pattern; - filter — whether the JSON payload (and headers) satisfy the conditions.
- URL path (
- For each matched rule the
title/messagetemplates are rendered (Gotext/template) and a message is created in the rule’s channel. - The whole request is saved to the event history — with headers and full body, so you can debug rules or generate a new rule from an example.
Public endpoint
Section titled “Public endpoint”POST|GET https://api.notifly.ru/router/R<token>/<any/path>Content-Type: application/json- Methods
POSTandGETare supported. - The body must be a JSON object (form-urlencoded and other formats are not
parsed). An empty body — e.g. with
GET— is treated as an empty object{}. - The body limit is 64 KiB. A larger request is not processed: the response
is
{"accepted": true, "matched": 0}and no event is stored in the history. - If the body is not valid JSON, the event is saved with status
error(invalid JSON payload), rules are not evaluated, and the response is{"accepted": true, "matched": 0, "error": "invalid JSON"}. - No authorization is required — the token
R…in the URL acts as the secret. - The response is
200with a body like{"accepted": true, "matched": 2}, wherematchedis the number of triggered rules. If the router is disabled —{"accepted": false, "error": "router disabled"}. An unknown token gives404, too frequent requests —429.
Each incoming request counts as one event of the daily
quota (forwarding to multiple channels is not billed
separately). When the quota is exhausted the router returns {"accepted": false, "error": "daily event quota exceeded"} and notifies you.
Example: ping the router manually.
curl -X POST "https://api.notifly.ru/router/RAbcDef123/ci" \ -H "Content-Type: application/json" \ -d '{"status":"failed","message":"build #42 failed"}'# {"accepted":true,"matched":1}Routing rules
Section titled “Routing rules”A rule (WebhookRouterRule) consists of:
| Field | JSON key | Meaning |
|---|---|---|
| Channel | appId | which channel to send to — required; only your own channel (not one shared with you) |
| Name | name | human-readable rule name |
| Path | path | URL path pattern (see below); empty or / — any path |
| Filter | filter | conditions over payload/headers (see below) |
| Title template | titleTemplate | Go template for message title |
| Message template | messageTemplate | Go template for message message |
| Markdown | markdown | the message’s extras.markdown flag (the admin UI always sets true) |
| Priority | priority | priority of the created message (0 by default in the API, 5 in the admin UI) |
| Enabled | enabled | whether the rule participates in routing (default true) |
| Position | position | order in the rule list |
All enabled rules are evaluated independently — multiple rules may match, and a message will be sent to each of their channels.
Path matching
Section titled “Path matching”The path pattern uses Gin-style segments:
/literal— exact segment match (/github,/grafana/prod);/:param— any single segment, its value is available in templates as{{.Path.Params.param}};/*wildcard— a tail match (only at the end), available as{{.Path.Params.wildcard}}.
A rule without a path (empty or /) does not check the path at all —
it applies to any incoming path; only the filter is checked. Extra
segments in the request do not match a literal pattern.
Examples (sender posts to /router/R<token>/<path>):
rule path | Matches request path |
|---|---|
/github | /github |
/grafana/prod | /grafana/prod |
/teams/:team | /teams/backend (then {{.Path.Params.team}} = backend) |
/hooks/*rest | /hooks/ci/build/42 (then {{.Path.Params.rest}} = ci/build/42) |
/ or empty | matches any request (filter only) |
Filter: conditions and groups
Section titled “Filter: conditions and groups”filter is a set of groups of conditions. Groups are combined with logical
AND: the request passes the filter only if all groups pass. An empty filter
(null or without groups) always matches.
Group (RouterConditionGroup):
{ "type": "and", "target": "payload", "conditions": [{"path": "alert.severity", "op": "eq", "value": "critical"}]}target— where to take data from:payload(JSON body, default) orheaders(HTTP request headers).type— logic inside the group:
type | Triggers when… |
|---|---|
and | all group conditions matched |
any | at least one condition matched |
and_not | none of the conditions matched (NOT any) |
any_not | at least one condition did NOT match (NOT all) |
Condition (RouterCondition) — path, op and value:
path— dotted path to the field. Nested fields and array indices are supported:alert.severity,items.0.name.op— operator:
op | Meaning |
|---|---|
eq | the field exists and its string value equals value |
neq | the field is absent or its value does not equal value |
contains | the value contains value (case-insensitive) |
exists | the field is present (value can be omitted) |
regex | the field value matches the regular expression value |
gt | numerical field value is greater than value |
lt | numerical field value is less than value |
Comparisons for eq/neq/contains/regex are done on the string
representation of the value (number 5 → "5", true → "true"). For gt/lt
the field value is converted to a number.
For headers (target: "headers") the same syntax applies, but path is the
header name in its canonical form, as the server stores it: the first
letter and letters after a hyphen are upper-case, the rest lower-case. GitHub
sends X-GitHub-Event, but the condition must say X-Github-Event;
Content-Type and User-Agent are written as usual. The exact names are visible
in the event history.
Title and message templates
Section titled “Title and message templates”titleTemplate and messageTemplate are Go text/template strings.
If a template is empty or static (without {{), it is used as-is. If the
rendered title or message is empty, a “smart” fallback is used from the payload
(it takes the first meaningful field like title/message/status, otherwise a summary of top-level fields or the request path).
Available context:
| Expression | Gives |
|---|---|
{{.Payload.status}} | top-level field from the JSON body |
{{.Payload.alert.severity}} | nested field |
{{index .Headers "X-Github-Event"}} | value of an HTTP header (canonical name) |
{{.Path.Full}} | full request path (after the token) |
{{.Path.Params.team}} | value of a :param/*wildcard from the path |
{{.ReceivedAt}} | time the request was received |
Available functions:
| Function | Example |
|---|---|
upper | {{upper .Payload.level}} |
lower | {{lower .Payload.level}} |
default | {{default "unknown" .Payload.status}} |
json | {{json .Payload}} — value as a JSON string |
get | {{get "alert.text" .Payload}} — access by dotted path |
If a template fails to parse or to execute — for example, upper or default
received a missing or non-string field — the template text itself is inserted
into the message as is. So apply upper, lower and default to the result of
get: {{default "—" (get "status" .Payload)}}.
Template example:
titleTemplate: Grafana: {{upper .Payload.alert.severity}}messageTemplate: {{.Payload.message}} (status: {{default "—" (get "status" .Payload)}})Router messages are always marked as Markdown (client::display). The message
extras carry the original request payload, path and
headers; the sender is the router’s name. Escalation,
quiet hours and the channel’s maintenance
windows apply to these messages like to any
others.
In the admin UI’s rule dialog the “From template…” button pre-fills the form with ready recipes: “GitHub Actions: failed workflow”, “Grafana alert” (firing only, title/message from the payload) and “Keep only title/message”.
Event history
Section titled “Event history”Each incoming request is saved as an event (WebhookRouterEvent) with
full context: method, path, remoteIp, all headers, raw body
payloadRaw, flag payloadTruncated, list of matched rules
matchedRuleIds, created messages createdMessageIds and status:
matched— at least one rule matched;unmatched— no rule matched;error— body failed to parse as JSON.
History can be filtered by status (status), substring (q — searches the
request body, case-insensitively, within the fetched page), time range
(from/to in RFC3339) and paginated with a cursor (cursor — the id of the
last event received, limit — default 50, max 100). Events are kept for 30
days.
Generate a rule from an event (AI)
Section titled “Generate a rule from an event (AI)”The fastest way to create a rule is to take a real example from history.
The endpoint POST /webhook-router/rule/from-event/:eid returns a draft rule
based on a saved event:
{ "path": "/grafana", "suggested": [ { "type": "and", "target": "payload", "conditions": [{"path": "alert.severity", "op": "eq", "value": "critical"}] } ], "titleTemplate": "Alert: {{.Payload.status}}", "messageTemplate": "", "paths": ["status", "alert", "alert.severity", "message"]}- Without a body (or with an empty
prompt) a heuristic is used without calling AI: it takes “discriminating” fields (event/type/action/status/severity…) and useful headers (X-…-Event), and returnspaths— all dotted-paths from the payload, helpful for manually composing conditions. - With a body
{"prompt": "only critical alerts to #ops"}the AI assistant is used: it understands the textual request and builds conditions and templates accordingly (one request is charged from the daily AI-helper quotaaiRequestsPerDay, see quotas; when it is exhausted —429). If AI is not configured or returns an error — it automatically falls back to the heuristic.
The result is a draft: review and adjust it before saving via POST /webhook-router/rule.
Routing examples
Section titled “Routing examples”Grafana → “Critical” channel by severity
Section titled “Grafana → “Critical” channel by severity”The Grafana webhook contact point sends status, title, message and the
group’s common labels in commonLabels. Rule: path = /grafana, filter — one
and/payload group with the conditions status eq firing and
commonLabels.severity eq critical, templates {{get "title" .Payload}} and
{{get "message" .Payload}}, channel — “Critical”.
curl -X POST "https://api.notifly.ru/router/R<token>/grafana" \ -H "Content-Type: application/json" \ -d '{"status":"firing","title":"[FIRING:1] HighMemory","commonLabels":{"severity":"critical"},"message":"OOM on db-1"}'GitHub → different channels by event type
Section titled “GitHub → different channels by event type”GitHub puts the event type in the X-GitHub-Event header (write
X-Github-Event in conditions and templates). In the GitHub webhook settings
choose Content type application/json. Create two rules with the same path
/github but different header filters:
- Rule “Push”:
and/headersgroup, conditionX-Github-Event eq push, channel “Deploys”. - Rule “Issues”:
and/headersgroup, conditionX-Github-Event eq issues, channel “Issues”.
titleTemplate: GitHub {{index .Headers "X-Github-Event"}}messageTemplate: {{get "repository.full_name" .Payload}} {{get "action" .Payload}}REST API
Section titled “REST API”The router and rules are managed with an API token
(M…): a read-only token is enough for reading, changing the router and rules
needs a token with write access. All endpoints below are relative to the base URL
(https://api.notifly.ru), authorization header — X-Notifly-Key. Channel tokens
(including shared ones) are not accepted here — 403.
Public webhook reception (/router/...) does not require authorization — the secret is in the token itself.
| Method | Path | Purpose |
|---|---|---|
GET | /webhook-router | get the router (created on first access), including rules |
PUT | /webhook-router | update the router (name, enabled) |
POST | /webhook-router/rotate-token | generate a new R… token (old URL will stop working) |
GET | /webhook-router/rule | list rules |
POST | /webhook-router/rule | create a rule |
PUT | /webhook-router/rule/:rid | update a rule |
DELETE | /webhook-router/rule/:rid | delete a rule |
GET | /webhook-router/event | event history (q, status, from, to, cursor, limit) |
GET | /webhook-router/event/:eid | single event in full (headers + body) |
DELETE | /webhook-router/event/:eid | delete an event from history |
POST | /webhook-router/rule/from-event/:eid | generate a draft rule from an event (heuristic or AI via prompt) |
GET | /webhook-router/message | messages created by the router (q — text filter) |
DELETE | /webhook-router/message/:mid | delete a message created by the router |
POST|GET | /router/:token/*path | public webhook receiver (no auth) |
Example: create a rule via the API.
curl -X POST "$NOTIFLY_URL/webhook-router/rule" \ -H "X-Notifly-Key: <api-token>" \ -H "Content-Type: application/json" \ -d '{ "appId": 12, "name": "Grafana critical", "path": "/grafana", "filter": {"groups": [ {"type": "and", "target": "payload", "conditions": [ {"path": "status", "op": "eq", "value": "firing"}, {"path": "commonLabels.severity", "op": "eq", "value": "critical"} ]} ]}, "titleTemplate": "{{get \"title\" .Payload}}", "messageTemplate": "{{get \"message\" .Payload}}", "markdown": true, "priority": 8 }'See also: push messages, channels and tokens, email inbox (receiving emails by the same principle), Alertmanager, Grafana and Zabbix, quotas and plans.