Skip to content

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 by commonLabels.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.
  1. Each account has a single router with a token like R… (prefix R). Public URL — https://api.notifly.ru/router/R<token>/<path>.
  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://api.notifly.ru/router/R<token>/<any/path>
Content-Type: application/json
  • Methods POST and GET are 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 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"}. An unknown token gives 404, 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}

A rule (WebhookRouterRule) consists of:

FieldJSON keyMeaning
ChannelappIdwhich channel to send to — required; only your own channel (not one shared with you)
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
Markdownmarkdownthe message’s extras.markdown flag (the admin UI always sets true)
Priorityprioritypriority of the created message (0 by default in the API, 5 in the admin UI)
Enabledenabledwhether the rule participates in routing (default true)
Positionpositionorder 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.

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

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

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

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

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.

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": "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 quota aiRequestsPerDay, 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.

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/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 {{index .Headers "X-Github-Event"}}
messageTemplate: {{get "repository.full_name" .Payload}} {{get "action" .Payload}}

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.

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