Webhook router
Webhook router is a single public endpoint for receiving webhooks. Any
external service (GitHub, GitLab, Grafana, Bitrix24, Stripe, your CI, etc.)
sends a POST to the same URL like /router/R<token>/<путь>, 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 byalert.severity); - routing GitHub events (pushes to one channel, issues to another);
- receiving Bitrix24, CRM, or payment webhooks 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://notifly.ru/router/R<token>/<путь>. - 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://notifly.ru/router/R<token>/<любой/путь>Content-Type: application/json- Methods
POSTandGETare supported. - The body must be a JSON object. Size limit — 256 KB; anything larger is truncated (the event is marked with
payloadTruncated: true). - If the body is not valid JSON, the event is saved with status
error(invalid JSON payload), and rules are not evaluated. - No authorization is required — the token
R…in the URL acts as the secret. - The response is always
200with a body like{"accepted": true, "matched": 2}, wherematchedis the number of triggered rules. If the router is disabled —{"accepted": false, "error": "router disabled"}.
Each incoming request counts as a single quota event of type “webhook-router”
(forwarding to multiple channels is not billed separately). When the daily quota
is exhausted the router will return {"accepted": false, "error": "daily event quota exceeded"} and notify you.
Example: ping the router manually.
curl -X POST "https://notifly.ru/router/RAbcDef123/grafana" \ -H "Content-Type: application/json" \ -d '{"status":"firing","alert":{"severity":"critical"},"message":"disk full"}'# {"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 (App or Client — any account channel) |
| 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 | render message as Markdown |
| Priority | priority | priority of the created message |
| Enabled | enabled | whether the rule participates in routing (default true) |
| Position | position | order in the list (for the UI) |
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 (for example X-GitHub-Event).
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 |
{{.Headers.X-GitHub-Event}} | value of an HTTP header |
{{.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 |
Template example:
titleTemplate: Grafana: {{upper .Payload.alert.severity}}messageTemplate: {{.Payload.message}} (статус: {{default "—" .Payload.status}})Enable markdown: true to render the message as Markdown in clients.
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 body/headers), time range (from/to in RFC3339) and paginated with a cursor (cursor, limit — default 50, max 100).
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": "слать только критичные алерты в #ops"}the AI assistant is used: it understands the textual request and builds conditions and templates accordingly (one AI request is charged from the daily quota; see Assistant and quotas). 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”Rule: path = /grafana, filter — one and/payload group with condition
alert.severity eq critical, channel — “Critical”.
curl -X POST "https://notifly.ru/router/R<token>/grafana" \ -H "Content-Type: application/json" \ -d '{"status":"firing","alert":{"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. 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 {{.Headers.X-GitHub-Event}}messageTemplate: {{get "repository.full_name" .Payload}}: {{.Payload.action}}Bitrix24 → single channel, filter by event
Section titled “Bitrix24 → single channel, filter by event”Bitrix24 sends event in the body. Rule: path = /bitrix, and/payload group,
condition event contains ONCRMDEAL, channel “CRM”.
titleTemplate: Bitrix: {{.Payload.event}}messageTemplate: Сделка {{get "data.FIELDS.ID" .Payload}} обновленаREST API
Section titled “REST API”Managing the router and rules requires a client token (C…),
an MCP code with write permission (M…) or Basic Auth. All endpoints below are
relative to the base URL (https://notifly.ru), authorization header —
X-Notifly-Key. Mutating operations are not available via “shared” access.
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: C<client-token>" \ -H "Content-Type: application/json" \ -d '{ "appId": 12, "name": "Grafana critical", "path": "/grafana", "filter": {"groups": [ {"type": "and", "target": "payload", "conditions": [ {"path": "alert.severity", "op": "eq", "value": "critical"} ]} ]}, "titleTemplate": "Grafana: {{upper .Payload.alert.severity}}", "messageTemplate": "{{.Payload.message}}", "markdown": true, "priority": 8 }'See also: push messages, channels and tokens, email inbox (receiving emails by the same principle), assistant and AI quotas.