Skip to content

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 by alert.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.
  1. Each account has a single router with a token like R… (prefix R). Public URL — https://notifly.ru/router/R<token>/<путь>.
  2. An external service sends POST (or GET) with a JSON body to that URL.
  3. The router runs the request through all enabled rules. Each rule checks two things:
    • URL path (*path after the token) — whether it matches the rule pattern;
    • filter — whether the JSON payload (and headers) satisfy the conditions.
  4. For each matched rule the title/message templates are rendered (Go text/template) and a message is created in the rule’s channel.
  5. 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.
POST|GET https://notifly.ru/router/R<token>/<любой/путь>
Content-Type: application/json
  • Methods POST and GET are 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 200 with a body like {"accepted": true, "matched": 2}, where matched is 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}

A rule (WebhookRouterRule) consists of:

FieldJSON keyMeaning
ChannelappIdwhich channel to send to (App or Client — any account channel)
Namenamehuman-readable rule name
PathpathURL path pattern (see below); empty or / — any path
Filterfilterconditions over payload/headers (see below)
Title templatetitleTemplateGo template for message title
Message templatemessageTemplateGo template for message message
Markdownmarkdownrender message as Markdown
Priorityprioritypriority of the created message
Enabledenabledwhether the rule participates in routing (default true)
Positionpositionorder 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.

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 pathMatches 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 emptymatches any request (filter only)

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) or headers (HTTP request headers).
  • type — logic inside the group:
typeTriggers when…
andall group conditions matched
anyat least one condition matched
and_notnone of the conditions matched (NOT any)
any_notat 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:
opMeaning
eqthe field exists and its string value equals value
neqthe field is absent or its value does not equal value
containsthe value contains value (case-insensitive)
existsthe field is present (value can be omitted)
regexthe field value matches the regular expression value
gtnumerical field value is greater than value
ltnumerical 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).

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:

ExpressionGives
{{.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:

FunctionExample
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.

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).

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 returns paths — 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.

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/headers group, condition X-GitHub-Event eq push, channel “Deploys”.
  • Rule “Issues”: and/headers group, condition X-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}} обновлена

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.

MethodPathPurpose
GET/webhook-routerget the router (created on first access), including rules
PUT/webhook-routerupdate the router (name, enabled)
POST/webhook-router/rotate-tokengenerate a new R… token (old URL will stop working)
GET/webhook-router/rulelist rules
POST/webhook-router/rulecreate a rule
PUT/webhook-router/rule/:ridupdate a rule
DELETE/webhook-router/rule/:riddelete a rule
GET/webhook-router/eventevent history (q, status, from, to, cursor, limit)
GET/webhook-router/event/:eidsingle event in full (headers + body)
DELETE/webhook-router/event/:eiddelete an event from history
POST/webhook-router/rule/from-event/:eidgenerate a draft rule from an event (heuristic or AI via prompt)
GET/webhook-router/messagemessages created by the router (q — text filter)
DELETE/webhook-router/message/:middelete a message created by the router
POST|GET/router/:token/*pathpublic 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.