Alerting & Escalation API¶
The alerting API ingests Alertmanager-compatible webhooks, matches firing alerts against a project's routing rules, and — optionally — pages an on-call chain until someone acknowledges or the alert resolves. All endpoints are project-scoped and require the project's alerts capability to be enabled.
Capability gate
Every endpoint on this page is gated behind the project's alerts capability. A project without it enabled gets 403 Forbidden (alerts_capability_not_enabled) on every request, including the ingest endpoint. Request it the same way as any other feature — see Requesting Feature Access — with "feature_key": "alerts".
See the setup & lifecycle guide for the full walkthrough (enable → mint an ingest token → wire Alertmanager → build a chain → what happens when an alert fires).
Authorization¶
All alerting resources use the shared envelope and the platform's usual authentication (session cookie, JWT Bearer, or a static access token).
Static-token scopes¶
Each controller in the alerting family is its own grantable static-token scope resource — there is no single umbrella alerts:* scope that covers routes, chains, steps, and rosters. Scope levels are read, write, manage, or * (see Authentication & Tokens).
| Resource | Scope prefix | Covers |
|---|---|---|
| Alerts | alerts | GET/POST /projects/{project_id}/alerts, ack/unack, the signed-email ack surface is unauthenticated |
| Alert routes | alert_routes | /projects/{project_id}/alert_routes |
| Escalation chains | escalation_chains | /projects/{project_id}/escalation_chains |
| Escalation steps | escalation_steps | /projects/{project_id}/escalation_chains/{id}/escalation_steps |
| On-call rosters | on_call_rosters | /projects/{project_id}/on_call_rosters — on_call_rosters:read also covers the GET .../shifts member route, which has no scope of its own |
| On-call roster members | on_call_roster_members | /projects/{project_id}/on_call_rosters/{id}/on_call_roster_members |
| On-call roster phone contacts | on_call_roster_phone_contacts | /projects/{project_id}/on_call_rosters/{id}/on_call_roster_phone_contacts — see External phone contacts |
| Notification endpoints | notification_endpoints | /projects/{project_id}/notification_endpoints — see Notification endpoints |
| On-call rotations | on_call_rotations | /projects/{project_id}/on_call_rosters/{id}/rotation |
| On-call overrides | on_call_overrides | /projects/{project_id}/on_call_rosters/{id}/on_call_overrides |
| Alert silences | alert_silences | /projects/{project_id}/alerts/{alert_id}/silences |
| Alert timeline (read-only) | alert_events | GET /projects/{project_id}/alerts/{alert_id}/alert_events |
| Maintenance windows | maintenance_windows | /projects/{project_id}/maintenance_windows — see Maintenance windows |
Ingest-token minting is the one exception
POST /projects/{project_id}/alert_ingest_tokens (mint an Alertmanager ingest credential) is gated on the resource it provisions, not its own controller name: it requires alerts:manage (or alerts:*) on the calling static token, or — for an interactive session — org-admin or a membership holding the alerts "delete"-equivalent permission. A token scoped only to alert_ingest_tokens:* cannot mint a new ingest token.
An Alertmanager ingest token is deliberately narrow: it is a project-bound static token carrying only alerts:write, good for POST /projects/{project_id}/alerts and nothing else in this API.
Custom-action scope levels (Wave 7–8)
A static token's required scope level for a non-CRUD custom action (anything that isn't index/show/create/update/destroy) defaults to manage unless explicitly lowered. The three pure reads (route preview, resolved targets, insights) are lowered to read; the rest keep the stricter default:
| Action | Required level | Why |
|---|---|---|
alert_routes:preview | read | Pure dry-run, mutates nothing — lowered explicitly, same precedent as on_call_rosters:shifts/notification_endpoints:deliveries. |
escalation_chains:resolved_targets | read | Pure read, same shape as preview above — lowered explicitly. |
alerts:insights | read | Pure read (aggregate SQL, no writes) — lowered explicitly, added proactively at the same time as the endpoint itself, learning from preview/resolved_targets both needing a follow-up fix after their omission was caught late. |
alerts:test | manage (default) | Deliberately not lowered, unlike the similarly-named notification_endpoints:test, because it can open a real escalation instance and place a real phone call — see Send a test alert. |
alerts:seed_presets | manage (default) | Not lowered — creates routes/chains. |
alert_routes:clone | manage (default) | Not lowered — creates a route. |
escalation_chains:clone | manage (default) | Not lowered — creates a chain + steps. |
RBAC (interactive sessions)¶
For session/JWT callers, fine-grained authorization runs through AlertAbility (CanCanCan). Organization admins can manage every alerting resource in the org's projects. Non-admin members need the project's alerts permission category granted at the appropriate level (read / create / update / delete) — the same permission-preset mechanism used elsewhere in the platform (see Roles & RBAC). delete is the manage-equivalent level: it is what's required to mint ingest tokens and create/delete escalation chains, routes, and rosters.
Alert ingestion¶
Ingest alerts (Alertmanager webhook)¶
POST /projects/{project_id}/alerts
Authorization: Bearer <ingest-token>
Content-Type: application/json
Point an Alertmanager webhook_config receiver directly at this URL. Accepts either Authorization: Bearer <token> or X-Access-Token: <token> — mint the token with POST /alert_ingest_tokens below, which also returns a ready-to-paste webhook_config snippet.
Alerts are upserted by fingerprint — Alertmanager redelivers the same firing alert on every evaluation cycle, so this endpoint is safe to hammer. A firing alert seen for the first time (or recurring after having resolved) is evaluated against the project's alert routes; a resolved alert cancels any in-flight escalation for it. A token scoped to a different project is rejected with 403 static_token_cross_project — the same project-envelope convention every other project-scoped consumer in this API relies on (see Error responses).
Request body — Alertmanager webhook v4 payload:
{
"version": "4",
"groupKey": "{}:{alertname=\"HighCPU\"}",
"status": "firing",
"receiver": "bnerd-alerting",
"groupLabels": { "alertname": "HighCPU" },
"commonLabels": { "alertname": "HighCPU", "severity": "critical" },
"commonAnnotations": { "summary": "CPU > 90% for 5m" },
"externalURL": "https://alertmanager.example.com",
"alerts": [
{
"status": "firing",
"labels": { "alertname": "HighCPU", "severity": "critical", "instance": "web-01" },
"annotations": { "summary": "CPU > 90% for 5m" },
"startsAt": "2026-06-30T20:00:00Z",
"endsAt": "0001-01-01T00:00:00Z",
"fingerprint": "a1b2c3d4e5f6",
"generatorURL": "https://prometheus.example.com/graph?g0.expr=..."
}
]
}
| Field | Notes |
|---|---|
alerts | Required, non-empty array. |
alerts[].labels | Must include severity (critical, high, warning, or info) — this is what alert routes match against. |
alerts[].fingerprint | Alertmanager's stable per-alert identity — the upsert key. |
alerts[].endsAt | "0001-01-01T00:00:00Z" (Alertmanager's zero-value) means "not resolved yet" and is treated as null. |
Response 200 — always 200 for a well-formed payload; per-alert failures are reported in data.errors, not as an HTTP error, so Alertmanager doesn't retry-storm the whole batch over one bad alert. A payload that fails entirely (zero processed, at least one error) responds 502 Bad Gateway instead, so Alertmanager retries rather than silently losing the alert.
{
"metadata": { "organization_id": "org-uuid", "project_id": "proj-uuid", "timestamp": "2026-06-30T20:00:00Z" },
"data": {
"processed": 1,
"errors": []
}
}
Response 422 — malformed payload (missing/empty alerts array):
{
"request_id": "01970000-...",
"errors": [
{ "code": "malformed_payload", "message": "Request body must include a non-empty `alerts` array (Alertmanager webhook v4 payload).", "status": 422 }
]
}
Mint an ingest token¶
Mints a project-bound StaticAccessToken carrying only alerts:write — the credential Alertmanager's webhook_config presents to the ingest endpoint above. Requires alerts:manage (see Authorization).
Response 201 — the raw token is shown once, here only; it cannot be retrieved later (list/revoke it via the generic GET/DELETE /tokens(/:id) — an ingest token is an ordinary static token owned by the issuing account):
{
"metadata": { "organization_id": "org-uuid", "project_id": "proj-uuid", "timestamp": "2026-06-30T20:00:00Z" },
"data": {
"token_id": "uuid",
"token": "bnat_...",
"scopes": ["alerts:write"],
"project_id": "proj-uuid",
"webhook_config_snippet": "receivers:\n - name: bnerd-alerting\n webhook_configs:\n - url: 'https://api.bnerd.cloud/projects/proj-uuid/alerts'\n http_config:\n bearer_token: '...'"
}
}
Alerts¶
List alerts¶
Returns alerts in the project, most-recently-received first (deterministic tiebreak on id descending when two alerts share a timestamp).
Behavior change (Amendment A5, Wave 7): limit now defaults to 25
Before Wave 7, a paramless GET /projects/{project_id}/alerts returned every alert in the project, unbounded. It now defaults to the 25 most-recent alerts. Any existing integration that relies on an unbounded list from a bare call will silently start seeing only the 25 most-recent alerts — update it to paginate (limit/offset) or explicitly request a larger limit (max 200).
Query parameters (Amendment A5, Wave 7)
| Parameter | Type | Default | Description |
|---|---|---|---|
q | string | — | Case-insensitive substring match, OR'd across fingerprint, the alertname label, and the summary annotation. The search term's own %/_ characters are escaped before being wrapped in the ILIKE pattern, so a literal % or _ in your search matches literally rather than acting as a SQL wildcard. |
status | string | — | firing or resolved, exact match. An unrecognized or absent value is a no-op (no filter applied), never a 422. |
acknowledged | string | — | "true" (has at least one acknowledgement) or "false" (has none) — independent of status (an acked alert is usually still firing; the two AND together when both are given, not a combined enum). An unrecognized or absent value is a no-op. |
limit | integer | 25 | Clamped to 1..200. Never 422s on an out-of-range value — limit=99999 clamps to 200, limit=0 clamps to 1. |
offset | integer | 0 | "Load more" pagination offset, floored at 0. |
Response 200
{
"metadata": { "organization_id": "org-uuid", "project_id": "proj-uuid", "timestamp": "2026-06-30T20:00:00Z", "count": 1, "total": 5 },
"data": [
{
"id": "uuid",
"project_id": "proj-uuid",
"fingerprint": "a1b2c3d4e5f6",
"status": "firing",
"severity": "critical",
"labels": { "alertname": "HighCPU", "severity": "critical", "instance": "web-01" },
"annotations": { "summary": "CPU > 90% for 5m" },
"starts_at": "2026-06-30T20:00:00Z",
"ends_at": null,
"generator_url": "https://prometheus.example.com/graph?g0.expr=...",
"last_received_at": "2026-06-30T20:05:00Z",
"created_at": "2026-06-30T20:00:00Z",
"updated_at": "2026-06-30T20:05:00Z",
"acknowledged": false,
"acknowledged_at": null,
"acknowledged_by": null,
"acknowledgement_count": 0
}
]
}
metadata.count is unchanged — this page's item count (data.length). metadata.total is new (Amendment A5) — the count after q/status/ acknowledged filters but before limit/offset, i.e. the total number of matching alerts across every page. Use it to render pagination (page count, "N of M results"), not count.
| Field | Description |
|---|---|
status | firing or resolved. |
severity | critical, high, warning, or info — ordered highest-to-lowest for route matching. |
acknowledged | true once any account has acknowledged the alert (in-app, signed email, or phone), regardless of escalation state. |
acknowledged_at | The earliest acknowledgement recorded, not the latest. null if never acknowledged. |
acknowledged_by | Who recorded that earliest acknowledgement, or null if never acknowledged. An account is { "id": "acct-uuid", "name": "Bernd Hansen" }; a roster's external phone contact — which has no account — is { "label": "Vendor NOC (Acme)" } instead. Never a bare account ID and never a phone number. |
acknowledgement_count | Total acknowledgements recorded for this alert (in-app + email + phone, across recurrences). |
Once acknowledged, those three fields populate together:
{
"acknowledged": true,
"acknowledged_at": "2026-06-30T20:06:00Z",
"acknowledged_by": { "id": "acct-uuid", "name": "Bernd Hansen" },
"acknowledgement_count": 1
}
...or, if the acknowledging call was picked up by an external phone contact rather than an account:
{
"acknowledged": true,
"acknowledged_at": "2026-06-30T20:06:00Z",
"acknowledged_by": { "label": "Vendor NOC (Acme)" },
"acknowledgement_count": 1
}
Show an alert¶
Response 200 — a single alert object (same shape as the list). Response 404 — not found in this project.
Acknowledge an alert (in-app)¶
Halts escalation: acknowledges every one of the alert's currently active escalation instances via the same Alerts::Acknowledge entry point the signed-email link uses. Idempotent — acking an already-acked or already-resolved alert still returns 200.
Ack is a pause, not a stop
Acknowledging does not silence the alert forever. If the escalation chain has a re_escalation_after window configured and the alert is still firing when that window elapses, the chain re-escalates from step 1 — see the lifecycle guide.
Response 200 — the updated alert object (acknowledged: true).
Un-acknowledge an alert¶
Accidental-click recovery: clears every acknowledgement recorded for this alert, so acknowledged reports false again. Shares the same authorization gate as POST .../ack. Idempotent — un-acking an alert with no acknowledgements still returns 200.
Does not resume escalation
This clears the acknowledgement ledger only. It does not resurrect a halted escalation instance and does not cancel or reschedule the already-enqueued ack-window re-escalation job. Paging safety is preserved by that job firing on its own timer if the alert is still firing — not by resuming the chain synchronously here.
Response 200 — the updated alert object (acknowledged: false).
Acknowledge via signed email link¶
The one unauthenticated surface in this API. {signed_id} is a Rails message_verifier-signed, time-limited token minted by the escalation step's email delivery (AlertMailer#ack_link), encoding the alert, the recipient account, and the escalation instance. The signature itself is the authentication — the acknowledgement is attributed to the account embedded in the token, never to a session. Links are valid for 24 hours.
GET returns a confirmation payload for a client to render before committing to the POST:
{
"metadata": { "organization_id": "org-uuid", "project_id": "proj-uuid", "timestamp": "2026-06-30T20:00:00Z" },
"data": {
"alert_id": "uuid",
"account_id": "uuid",
"escalation_instance_id": "uuid",
"alert_summary": "CPU > 90% for 5m",
"alert_severity": "critical"
}
}
POST executes the acknowledgement (idempotent):
{
"metadata": { "organization_id": "org-uuid", "project_id": "proj-uuid", "timestamp": "2026-06-30T20:00:00Z" },
"data": { "acked": true, "alert_id": "uuid" }
}
Response 401 — invalid_signed_token (tampered) or signed_link_expired (past its 24h validity). Response 404 — the alert or the account embedded in the token no longer exists.
Send a test alert¶
POST /projects/{project_id}/alerts/test
Authorization: Bearer <token>
Content-Type: application/json
Self-serve endpoint (Wave 7) to synthesize and fire a real, firing alert through the platform's actual routing — the exact same seam a real Alertmanager delivery uses (Alert.upsert_from_payload! + Alerts::Router.call, with no test-mode branch or bypass). The fingerprint is always "test-<uuid>" and the alert always carries the label bnerd_test: "true", so it's unambiguous in the UI and easy to exclude from anything that should only count real pages.
| Field | Required | Description |
|---|---|---|
route_id | no | When given, labels are derived from that route's own matchers — an eq matcher contributes its exact value; a re matcher (which can't be inverted into a concrete value) contributes an editable placeholder. Overridden per-key by anything also present in labels. |
labels | no | Explicit labels — always wins over route-derived labels for the same key. bnerd_test: "true" is force-added regardless of what's supplied. severity defaults to critical if omitted or not a recognized value. |
This is not a simulation — it respects (and can trigger) everything a real alert does
Because this goes through the real router unmodified:
- It is suppressed by an active maintenance window or an in-progress per-route cooldown exactly like a real alert — a test alert that doesn't page during a maintenance window is correct behavior, not a bug.
- If the matched route opens an escalation chain with a
phone-channel step, firing a test alert places a real outbound call to whoever is currently on call — indistinguishable from a genuine page on the receiving end. There is no phone-specific bypass. The dashboard's "Send test alert"/"Test" buttons show a confirmation before firing when a phone-channel step could be reached (see Try it: send a test alert).
alerts:test requires manage-level scope on a static token — deliberately stricter than most custom read-only actions on this page, specifically because of the phone-call risk above (see Static-token scopes). The endpoint is also throttled to 6 requests/minute per credential+project; a 7th request within the window gets 429.
Auto-resolves 10 minutes after firing (TestAlertResolveJob) unless resolved sooner — see Recurring jobs for the job schedule. Resolving early is a plain resolved-status re-post through the same ingest endpoint real Alertmanager deliveries use (POST /projects/{project_id}/alerts with status: "resolved" and the same fingerprint) — there's no dedicated "resolve" action. Either path — the 10-minute auto-resolve or an early manual resolve — produces the full resolved-transition side effects: a resolved timeline event, cancelling any active escalation instance, and the alert_resolved notification endpoint fan-out, identically to a real alert's resolution.
This is a human action, audited normally as alerts.test (unlike the machine POST /alerts ingest endpoint, which explicitly opts out of auditing). Response 201 — the created alert (same shape as list alerts). Response 429 — the per-credential+project throttle above.
Seed recommended defaults¶
The "Seed recommended defaults" empty-state action (Wave 7) — creates a starter set of alert routes and escalation chains via Alerts::SeedPresets, for a project that has nothing configured yet.
Idempotent by refusal, not by silent no-op
Rejected with 422 presets_already_configured if the project already has any alert route or escalation chain — not just routes. The stricter double-check (versus Alerts::SeedPresets' own internal routes-only guard) exists so an operator who has started configuring routes/chains by hand never wonders whether clicking this clobbered their edits. There is no partial-overwrite or merge behavior — either the project is genuinely empty and gets the full preset set, or the request is refused outright.
Response 201
{
"metadata": { "organization_id": "org-uuid", "project_id": "proj-uuid", "timestamp": "2026-07-03T20:00:00Z" },
"data": { "routes": 2, "escalation_chains": 1 }
}
routes/escalation_chains are the resulting counts for the project after seeding (not a delta). Response 422 — presets_already_configured.
Alert insights¶
A pure read — alerts:read, not :manage. Six aggregate metrics over a project's alert history in one call: totals, MTTA, daily volume by severity, the noisiest alerts, escalation outcomes, and on-call load — pure SQL aggregation over existing tables, no new tables, strictly scoped to the project.
| Parameter | Type | Default | Description |
|---|---|---|---|
days | integer | 7 | 7, 30, or 90 — a strict enum. Unlike the alert list's limit/offset, which clamp silently, anything outside this set is rejected with 422 invalid_days, never coerced to the nearest valid value. |
Response 200
{
"metadata": { "organization_id": "org-uuid", "project_id": "proj-uuid", "timestamp": "2026-07-03T20:00:00Z" },
"data": {
"totals": { "alerts": 42, "firing_now": 3, "acked_share": 0.7143 },
"mtta": { "median_seconds": 180.0, "p90_seconds": 420.5 },
"volume": [
{ "date": "2026-07-01", "severities": { "critical": 2, "warning": 5 } },
{ "date": "2026-07-02", "severities": { "critical": 1, "info": 3 } }
],
"top_noisy": [
{ "alertname": "HighCPU", "occurrences": 14 },
{ "alertname": "DiskSpaceLow", "occurrences": 6 }
],
"escalations": {
"opened": 9, "exhausted": 1, "repeat_rate": 0.2222,
"acks_by_channel": { "in_app": 5, "email": 2, "phone": 1 }
},
"on_call_load": [
{ "account_id": "acct-uuid", "name": "Bernd Hansen", "notified": 12, "called": 2, "acked": 8 }
]
}
}
totals¶
| Field | Description |
|---|---|
alerts | Count of alerts with starts_at in the window (>= now - days). |
firing_now | Not windowed by days. A true live snapshot — every currently-firing alert in the project, regardless of when it started. |
acked_share | acked / alerts, rounded to 4 decimals — windowed, like alerts. 0.0 (not null) when alerts is 0. |
firing_now is deliberately the one unwindowed metric
Every other number in this response is scoped to the days window; firing_now alone is not. A still-firing alert that started before the window (e.g. it's been firing for 12 days and you asked for days=7) still counts toward firing_now — it does not count toward the windowed alerts/acked_share. This is intentional, not an inconsistency: windowing "what's on fire right now" would silently under-report the single most operationally important number on this page just because an alert happens to have started outside the reporting window.
mtta — median/p90 time to first acknowledgement¶
| Field | Description |
|---|---|
median_seconds / p90_seconds | Seconds from an alert's starts_at to its first acknowledgement, PERCENTILE_CONT(0.5)/(0.9) over the per-alert sample set. null (not 0) when no alert in the window has been acknowledged at all. |
One sample per alert, its first ack — not one sample per ack event
An alert acknowledged by three different accounts contributes exactly one latency value (its earliest acknowledgement) to the median/p90, not three — a heavily multi-acked, high-visibility alert doesn't get over-weighted in the stats relative to a quietly single-acked one. mtta is computed by grouping acknowledgements per alert and taking MIN(created_at) (the acknowledgement's own timestamp) as that alert's first-ack time, then computing the percentile over that per-alert set.
Accepted v1 behavior: un-acking erases the original latency
Un-acking hard-deletes the acknowledgement row(s) — there is no tombstone. If an alert is acked, un-acked, and then acked again, MTTA reflects the surviving (re-)acknowledgement's latency; the original ack's timing is gone along with its row, exactly as if it had never happened. This is a deliberate, documented v1 limitation rather than an oversight — MTTA always answers "how fast, based on ack rows that currently exist," not "what was the alert's true original response time before any correction."
volume — daily counts by severity¶
| Field | Description |
|---|---|
date | An ISO 8601 date ("YYYY-MM-DD"), one entry per day that had at least one alert. |
severities | { severity => count } for that day — only severities that actually occurred appear as keys. |
Day buckets are UTC-calendar-day, not your local day
A day bucket is starts_at truncated to UTC midnight boundaries — not the viewer's local day, and not any particular organization's or account's timezone (there is no single "whose midnight" to pick for a multi-contributor project). The boundary case to watch for is shortly after local midnight, not before it: an alert firing at 00:30 in a UTC+2 timezone on July 4 is 22:30 UTC on July 3 — it lands in the previous UTC-calendar-day bucket even though the org's own clock already reads July 4. If you're rendering this client-side, render the date string exactly as given — re-parsing it and reformatting in the viewer's local timezone can visibly shift which day a bar appears under.
top_noisy — up to 10 noisiest alert names¶
| Field | Description |
|---|---|
alertname | The alertname label value, or "(unknown)" if absent. |
occurrences | Count of fired + refired timeline events for that alertname in the window — i.e. how many times it started firing (an initial fire counts once, each recurrence counts again), not how many Alert rows exist (multiple occurrences of the same alertname may share one row across recurrences, or span several). Sorted descending, capped at 10. |
An entry near the top of this table with a high occurrences count is the platform's signal that a route needs cooldown, a maintenance window, or its matchers tightened — see the guide walkthrough for how to read this table actionably.
escalations¶
| Field | Description |
|---|---|
opened | Count of escalation instances opened in the window. |
exhausted | Of those, how many reached the exhausted state (nobody acked before the chain ran out of steps/repeats). |
repeat_rate | Share of opened instances that looped back at least once (repeat_count > 0), rounded to 4 decimals. 0.0 when opened is 0. |
acks_by_channel | { via_channel => count } — how acknowledgements in the window were made (in_app, email, phone). |
on_call_load — per-account notify/call/ack counts¶
| Field | Description |
|---|---|
account_id / name | The account. name is null if the account no longer exists but its historical ledger rows do. |
notified | Count of notified timeline events attributed to this account in the window. |
called | Count of outbound phone escalation calls placed to this account. |
acked | Count of acknowledgements this account made. |
Rows come from historical ledger data (timeline events, calls, acknowledgements already recorded at the time they happened) — not a live re-run of on-call resolution — so this reflects who was actually paged/called/acked during the window, including anyone who has since left the roster. See the guide walkthrough for using this as a rotation fairness check.
Response 422 — invalid_days (days present but not 7, 30, or 90).
Alert routes¶
Routes decide, per firing alert, who gets an in-app notification and/or whether an escalation chain opens. A project's routes are evaluated in priority order (ascending); the first route whose matchers all pass wins — there is no accumulation across multiple matching routes.
List alert routes¶
Returns routes ordered by priority ascending.
Create an alert route¶
POST /projects/{project_id}/alert_routes
Authorization: Bearer <token>
Content-Type: application/json
{
"name": "Critical DB alerts",
"priority": 10,
"matchers": [
{ "label": "severity", "op": "eq", "value": "critical" },
{ "label": "service", "op": "re", "value": "^db-.*" }
],
"escalation_chain_id": "chain-uuid",
"in_app_target_type": "org_admins",
"min_severity_for_notification": "warning",
"enabled": true
}
| Field | Required | Description |
|---|---|---|
priority | yes | Integer; lower evaluates first. |
min_severity_for_notification | no | critical, high, warning, or info — the alert's severity must be at or above this to notify/escalate. Optional and nullable — null, "", "any" (case-insensitive), or omitting the field entirely on create all mean "Any severity": the route matches on its label matchers alone, skipping the severity gate. PATCH leaves an omitted field unchanged, same as any other partial update. |
matchers | no | Array of { label, op, value }. AND-combined — every matcher must match for the route to apply. op is eq (exact string match) or re (regex against the label value). No matchers = matches everything (subject to the severity threshold, if one is set). |
escalation_chain_id | no | Opens this chain when the route matches. Omit for in-app-only routing. |
in_app_target_type | no | Who gets the in-app notification when the route matches — none, org_admins (default), roster, member, or role. See In-app notification targets below. |
in_app_target_roster_id | when in_app_target_type: roster | The on-call roster to notify — must belong to the route's project. |
in_app_target_member_id | when in_app_target_type: member | The single account to notify — must hold a project membership in the route's project. |
in_app_target_role | when in_app_target_type: role | The organization role to notify (every member holding it) — Admin or Member. |
notification_endpoint_ids | no | Notification endpoints to attach to this route — replaces the full set on every submit; [] detaches all. Ids that don't resolve to a NotificationEndpoint of this route's project are silently dropped, same as an unresolvable ref elsewhere in this table. Counts toward the "a route must do something" guard below alongside in_app_target_type and escalation_chain_id — but only an enabled attached endpoint counts (an attached-but-disabled endpoint doesn't satisfy the guard, since nothing would actually fire on match). |
notification_cooldown_seconds | no | Integer, >= 0, default 0 (no cooldown). See Noise control: per-route cooldown below. |
enabled | no | Disabled routes are skipped entirely during matching. |
Response 201 — the created route. Response 422 — validation error (e.g. an invalid regex in a re matcher, a missing label/value, an in_app_target_type whose companion in_app_target_* field doesn't resolve inside the route's project, the no-op guard below, or duplicate_notification_endpoint_attachment on a concurrent double-attach race).
In-app notification targets¶
in_app_target_type picks who the in-app notification goes to, independent of whether the route also opens an escalation chain:
in_app_target_type | Recipient |
|---|---|
org_admins (default) | Every organization admin — the same recipient set the old in_app_notification: true boolean always notified. |
roster | Every member of the on-call roster named by in_app_target_roster_id. |
member | The single project member named by in_app_target_member_id. |
role | Every member of the organization holding in_app_target_role (Admin or Member). |
none | No in-app notification. Only valid on a disabled route, or one that also carries an escalation_chain_id — see the guard below. |
A route must do something
An enabled route with in_app_target_type: "none", no escalation_chain_id, and no attached enabled notification endpoint is rejected with 422 validation_failed ("an enabled route must notify someone, escalate, or target an endpoint" — see Error responses). The intent: a route that matches alerts and silently does nothing with them is almost always a misconfiguration, not a deliberate choice — disable the route instead, or point it at a chain that itself does nothing (next section).
Attaching and detaching endpoints in the same request that also flips in_app_target_type/escalation_chain_id is evaluated together — e.g. setting notification_endpoint_ids: [] on a route whose only "does something" was a single attached endpoint trips this guard exactly like clearing the last recipient any other way.
Record-only: an escalation chain with zero steps¶
An escalation chain with no steps is a deliberate, explicit "record-only" marker, not an oversight — it satisfies the guard above (the route "escalates") while paging nobody: Alerts::Router opens no escalation instance for it (there'd be nothing to advance or time out). Use it for a catch-all route you want to keep in_app_target_type: "none" — e.g. an info-level catch-all that should log the alert but never notify anyone — by pointing it at an otherwise-empty chain instead of leaving the route with no chain at all.
Show / update / delete an alert route¶
GET /projects/{project_id}/alert_routes/{id}
PATCH /projects/{project_id}/alert_routes/{id}
DELETE /projects/{project_id}/alert_routes/{id}
PATCH accepts any subset of the create fields. DELETE returns 204 No Content.
Preview a route (dry run)¶
POST /projects/{project_id}/alert_routes/preview
Authorization: Bearer <token>
Content-Type: application/json
{ "labels": { "severity": "critical", "service": "db-primary" } }
A pure read — alert_routes:read, not :write/:manage. Builds an in-memory, never-persisted candidate alert from labels and reports which route would match it, right now, using the exact same AlertRoute.first_match_for the real router uses — no reimplemented matching logic to drift out of sync. Creates no Alert row, enqueues no job, opens no escalation instance, and writes no timeline event.
Response 200
{
"metadata": { "organization_id": "org-uuid", "project_id": "proj-uuid", "timestamp": "2026-07-03T20:00:00Z" },
"data": {
"matched_route": { "id": "route-uuid", "name": "Critical DB alerts", "priority": 10, "...": "..." },
"would_notify": ["Bernd Hansen", "Verena"],
"would_escalate": "DB on-call chain",
"endpoints": [{ "id": "endpoint-uuid", "name": "Slack #db-incidents", "kind": "slack" }],
"suppressed_by": []
}
}
| Field | Description |
|---|---|
matched_route | The full alert route object, or null if nothing matches — a legitimate result, not an error. |
would_notify | Names of the accounts the matched route's in_app_target_type would notify. [] if no route matched or the route doesn't notify in-app. |
would_escalate | The matched route's escalation chain name, or null. |
endpoints | The matched route's attached notification endpoints (id/name/kind) that would fan out. |
suppressed_by | ["maintenance_window:{name}", ...] for every currently-active maintenance window whose matchers also match the candidate's labels. |
Scoped to maintenance windows only — not silence or cooldown
suppressed_by can only ever report maintenance windows. A silence is keyed to a real, already-persisted alert's id, and cooldown reads a real alert's own last_notified_at history — neither concept has meaning for a hypothetical, never-saved candidate. If you need to know whether a specific real alert is currently silenced or in cooldown, check that alert directly rather than previewing.
Exact fidelity with live suppression
The preview's maintenance-window check reuses the identical MaintenanceWindow.active scope the real router's suppression check uses internally — the same [starts_at, ends_at) boundary (inclusive start, exclusive end) described in Maintenance windows. There is no separate preview-specific window-matching logic to drift out of sync, including at the boundary instant.
Clone a route¶
Deep-copies an existing route: name becomes "Copy of {name}", priority is set to the project's current max + 1 (so it sorts to the back of the first-match order and can never silently steal traffic from its source), and it's created disabled. All fields — matchers, severity threshold, in-app target, escalation chain reference, cooldown seconds — and every attached notification endpoint are duplicated. The clone references the same escalation chain / roster / member as its source (not a deep copy of those) — editing the clone's own fields doesn't touch the source, but until you point it elsewhere it still pages/notifies the identical targets.
Response 201 — the cloned route (same shape as create). Response 404 — the source route id isn't in this project.
Notification endpoints¶
A notification endpoint fans an alert out to a machine or a chat tool instead of (or alongside) a human — a monitoring bridge, an internal automation webhook, a Slack channel, a Teams channel. Endpoints are project-scoped resources, created independently of any route, then attached to one or more routes via notification_endpoint_ids. On a route match, the platform delivers to every enabled attached endpoint on both the alert firing and its later resolved transition — an endpoint attached to a route with no in-app target and no escalation chain is a fully valid, standalone routing outcome (it satisfies the "a route must do something" guard on its own).
Kinds¶
kind | Payload shape | Use for |
|---|---|---|
webhook | Canonical JSON (below), HMAC-signed | Your own automation, an internal bridge, anything that wants a stable machine-readable schema |
slack | Slack incoming-webhook blocks payload | A Slack channel's Incoming Webhook URL |
teams | Legacy MessageCard | A Microsoft Teams channel's Incoming Webhook connector URL |
slack and teams payloads are pre-formatted for their respective incoming-webhook contract — point url straight at the webhook URL Slack/Teams gives you and there is nothing else to configure on that side. Only webhook endpoints carry a signing secret; slack/teams deliveries are unsigned, matching what those platforms expect.
List / show notification endpoints¶
GET /projects/{project_id}/notification_endpoints
GET /projects/{project_id}/notification_endpoints/{id}
Authorization: Bearer <token>
Neither response includes the secret field — see Create a notification endpoint for the one time it's shown. url is never returned in full: every response carries masked_url instead (everything past the host redacted, e.g. "https://hooks.example.com/…").
Response 200 — show:
{
"metadata": { "organization_id": "org-uuid", "project_id": "proj-uuid", "timestamp": "2026-07-03T20:00:00Z" },
"data": {
"id": "uuid",
"project_id": "proj-uuid",
"name": "Ops automation bridge",
"kind": "webhook",
"masked_url": "https://hooks.example.com/…",
"enabled": true,
"last_delivery": {
"status": "success",
"event": "alert_firing",
"response_code": 200,
"created_at": "2026-07-03T19:55:00Z"
},
"created_at": "2026-07-01T10:00:00Z",
"updated_at": "2026-07-01T10:00:00Z"
}
}
last_delivery is null on an endpoint that has never had a delivery attempt.
Create a notification endpoint¶
POST /projects/{project_id}/notification_endpoints
Authorization: Bearer <token>
Content-Type: application/json
{
"name": "Ops automation bridge",
"kind": "webhook",
"url": "https://hooks.example.com/bnerd-alerts",
"enabled": true
}
| Field | Required | Description |
|---|---|---|
name | yes | Unique within the project. |
kind | yes | webhook, slack, or teams — see Kinds. |
url | yes | Rejected with 422 if it isn't a valid http(s) URL, uses plain http outside a dev/test environment, or resolves to a private/reserved address — see SSRF protection below. |
enabled | no | A disabled endpoint is skipped by delivery — attached to a route or not, nothing fans out to it. |
Response 201 — a webhook-kind endpoint's response includes secret, the raw HMAC signing key, shown once, here only — it is never returned by any later GET, and a PATCH response shows it again (the same value; PATCH does not rotate it) simply because the endpoint object's full shape is re-rendered. slack/teams endpoints never have a secret (null) — their deliveries aren't signed.
{
"metadata": { "organization_id": "org-uuid", "project_id": "proj-uuid", "timestamp": "2026-07-03T20:00:00Z" },
"data": {
"id": "uuid",
"project_id": "proj-uuid",
"name": "Ops automation bridge",
"kind": "webhook",
"masked_url": "https://hooks.example.com/…",
"secret": "3f9c1a...redacted-64-hex-chars",
"enabled": true,
"last_delivery": null,
"created_at": "2026-07-03T20:00:00Z",
"updated_at": "2026-07-03T20:00:00Z"
}
}
Save the secret now — there is no recovery endpoint
Store the secret in whatever system will verify incoming deliveries (see Verifying webhook signatures below) the moment you create the endpoint. If it's lost, there is no "reveal again" API — DELETE the endpoint and create a fresh one, which mints a new secret and requires updating the receiver.
Response 422 — validation_failed (missing required field, duplicate name in this project, or a url rejected by the SSRF guard).
Update / delete a notification endpoint¶
PATCH /projects/{project_id}/notification_endpoints/{id}
DELETE /projects/{project_id}/notification_endpoints/{id}
Authorization: Bearer <token>
PATCH accepts any subset of name, kind, url, enabled and, like create, returns the secret again in its response (a webhook endpoint's secret is stable across updates — PATCH never rotates it). DELETE detaches the endpoint from every route it was attached to and returns 204 No Content.
Send a test delivery¶
Synthesizes a sample alert and runs it through the real delivery pipeline — the same formatter, signing, and EndpointDelivery bookkeeping a live alert would use, with event: "test" — so a successful test-send is a genuine end-to-end proof the endpoint is reachable and correctly configured, not a synthetic ping that bypasses the real code path.
Response 202 — the test delivery is enqueued asynchronously, not delivered synchronously in this response; check deliveries or the endpoint's last_delivery a moment later for the outcome.
{
"metadata": { "organization_id": "org-uuid", "project_id": "proj-uuid", "timestamp": "2026-07-03T20:00:00Z" },
"data": { "ok": true, "test_alert_id": "uuid" }
}
Recent deliveries¶
Newest first, capped at the 50 most recent delivery attempts.
Response 200
{
"metadata": { "organization_id": "org-uuid", "project_id": "proj-uuid", "timestamp": "2026-07-03T20:00:00Z", "count": 1 },
"data": [
{
"id": "uuid",
"alert_id": "uuid",
"event": "alert_firing",
"status": "failed",
"response_code": null,
"attempts": 5,
"last_error": "Faraday::ConnectionFailed: execution expired",
"created_at": "2026-07-03T19:55:00Z"
}
]
}
| Field | Description |
|---|---|
event | alert_firing, alert_resolved, or test. |
status | pending (attempt in flight/queued for retry), success (2xx response), or failed (exhausted retries or a permanent failure). |
attempts | Number of delivery attempts made for this row so far. |
last_error | The most recent failure detail, or null on success. A (permanent) suffix means this failure will never retry — see Retries below. |
The webhook JSON payload¶
A webhook-kind endpoint's body (slack/teams bodies are their own platform's native shape, not this one):
{
"event": "alert_firing",
"alert": {
"id": "uuid",
"fingerprint": "a1b2c3d4e5f6",
"status": "firing",
"severity": "critical",
"labels": { "alertname": "HighCPU", "severity": "critical", "instance": "web-01" },
"annotations": { "summary": "CPU > 90% for 5m" },
"starts_at": "2026-07-03T19:55:00Z",
"ends_at": null,
"last_received_at": "2026-07-03T19:55:00Z"
},
"project": { "id": "proj-uuid", "name": "production" },
"alert_url": "https://app.bnerd.cloud/projects/proj-uuid/alerts/uuid",
"sent_at": "2026-07-03T19:55:01Z"
}
event is alert_firing or alert_resolved for a real delivery, test for a test delivery — the shape is otherwise identical.
Verifying webhook signatures¶
Every webhook-kind delivery carries three headers a receiver can use to authenticate and de-duplicate it:
| Header | Contains |
|---|---|
X-Bnerd-Event | alert_firing, alert_resolved, or test — same value as the body's event field. |
X-Bnerd-Delivery | The EndpointDelivery row's UUID — stable across retries of the same attempt, so a receiver can dedupe a redelivered request. |
X-Bnerd-Signature | sha256=<hex> — an HMAC-SHA256 of the raw request body, keyed with the endpoint's secret. |
To verify a delivery: compute HMAC-SHA256(secret, raw_request_body) yourself over the exact bytes of the body as received (don't re-serialize parsed JSON — whitespace/key-order differences will break the signature), hex-encode it, prefix with sha256=, and compare to the X-Bnerd-Signature header using a constant-time comparison — never == on the two strings, which leaks timing information an attacker can use to forge a valid signature byte by byte.
# Ruby / Rails example
expected = "sha256=" + OpenSSL::HMAC.hexdigest("SHA256", endpoint_secret, raw_body)
ActiveSupport::SecurityUtils.secure_compare(expected, request.headers["X-Bnerd-Signature"])
# Python example
import hmac, hashlib
expected = "sha256=" + hmac.new(endpoint_secret.encode(), raw_body, hashlib.sha256).hexdigest()
hmac.compare_digest(expected, request.headers["X-Bnerd-Signature"])
slack/teams deliveries carry no signature header — Slack's and Teams' own incoming-webhook URLs are the shared secret for those kinds (the URL itself is unguessable and should be treated as sensitive), so there's nothing further to verify on receipt.
Retries¶
A failed delivery attempt is classified transient or permanent:
| Outcome | Classification | Behavior |
|---|---|---|
| 2xx response | success | Recorded, no retry. |
5xx response, or 429 | transient | Retried with exponential backoff, up to 5 attempts total. |
| Connection error / timeout | transient | Same retry policy as above. |
| Any other 4xx | permanent | Recorded failed immediately, last_error suffixed (permanent) — retrying would hit the identical rejection again. |
| 3xx (redirect) | permanent | Redirects are never followed — see SSRF protection — recorded failed, suffixed (permanent). |
URL blocked by the SSRF guard, or an unknown endpoint kind | permanent | Recorded failed, suffixed (permanent); this is a configuration problem retrying can't fix. |
Once a delivery is exhausted (5 transient attempts, or hits a permanent classification on any attempt), it stays failed — there is no automatic re-drive. Send a test delivery after fixing the underlying issue to confirm the endpoint works again.
SSRF protection¶
Endpoint URLs are validated twice: once at save time (create/update), and again immediately before every single delivery attempt — because DNS can be re-pointed to a private address in the window between the two, a save-time-only check would not be sufficient. A URL is rejected (422 at save; recorded as a permanent delivery failure at send time) if it:
- isn't a valid
http(s)URL, or uses plainhttpoutside a dev/test environment; - fails to resolve at all; or
- resolves to a private, loopback, link-local, carrier-grade-NAT, unique-local, multicast, or unspecified address — including the
169.254.169.254cloud-metadata address specifically.
The outbound connection is then pinned to the exact address that validation just checked, rather than re-resolving the hostname a second time independently — closing the DNS-rebinding gap where a second lookup at connect-time could return a different (private) address than the one that was validated. Redirects are never followed for the same reason: a 3xx to a private target would otherwise bypass the guard entirely.
ALLOW_PRIVATE_WEBHOOKS is a trusted/dev-only escape hatch
Setting the ALLOW_PRIVATE_WEBHOOKS=true environment variable disables the private/reserved-address check platform-wide for that deployment. It exists for local development and trusted internal test environments where private-network webhook receivers are expected — never enable it on a production deployment that accepts endpoint URLs from anyone outside full operator trust, since it re-opens the SSRF surface this guard exists to close.
Attaching endpoints to escalation steps¶
A route's notification_endpoint_ids (above) fans out flat and immediate — every match, right away. An escalation step can attach its own endpoint set the same way, but staged in time with the rest of the chain — the natural tool for widening the blast radius on continued non-acknowledgement: page the on-call roster on step 1, and only post to an incident-response chat channel or hand off to an automation webhook on step 2 if nobody's acked by then. Route and step attachment are complementary, not redundant — use a route endpoint for "always," a step endpoint for "only if this drags on."
POST /projects/{project_id}/escalation_chains/{chain_id}/escalation_steps
{
"step": {
"position": 2,
"target_type": "org_admins",
"channels": ["in_app", "email"],
"delay_seconds": 0,
"ack_timeout_seconds": 600,
"notification_endpoint_ids": ["<incident-response-webhook-id>"]
}
}
Same field, same replace-the-full-set semantics as a route: send every id you want attached ([] detaches all, omitting the key on PATCH leaves attachments unchanged), and ids that don't resolve to a NotificationEndpoint in the chain's project are silently dropped. A step's show/index response includes its notification_endpoint_ids.
Notify-only: no ack, no gating
An endpoint attached to a step is fire-and-forget — a webhook can't press 1 on a phone call or click "acknowledge" in-app — so attached endpoints never influence the step's ack-timeout, advancement, repeat, or exhaustion. That state machine is driven entirely by the step's account/roster/phone targets, exactly as before this feature existed. A step whose only action is one or more attached endpoints — no roster reachable by channels — is a fully valid step: it still fires its endpoints and still schedules and honors its ack-timeout watchdog (there is nobody who could ack it, so it always times out and advances — expected, not a bug, for an endpoint-only step).
Attached endpoints fire on every genuine step run, alongside the step's other channels, and are silence-suppressed exactly like a step's human channels: a silenced alert's step still advances the chain on schedule, but pages neither its people nor its attached endpoints.
A step's endpoint fan-out is recorded as a notified timeline event with channel: "endpoint" — not the route-level endpoint_dispatched event kind, since a step's endpoints fire time-staged with the rest of the step rather than immediately on route match.
Escalation chains¶
A chain is an ordered list of steps plus the re-escalation and repeat behavior around them.
List / show escalation chains¶
show includes the chain's steps, ordered by position.
Create an escalation chain¶
POST /projects/{project_id}/escalation_chains
Authorization: Bearer <token>
Content-Type: application/json
{
"name": "Production on-call",
"enabled": true,
"re_escalation_after": 1800,
"repeat_mode": "n_times",
"max_repeats": 3,
"repeat_delay_seconds": 300
}
| Field | Required | Description |
|---|---|---|
name | yes | |
enabled | no | A disabled chain is never (re)opened — Alerts::Router skips it silently. |
re_escalation_after | no | Seconds to wait after an ACK before re-escalating if the alert is still firing — the ack dead-man's-switch. null or 0 means "hold until resolve" (never auto-re-escalate). |
repeat_mode | no | once (default) — exhaust after the last step, notify org admins once. n_times — loop back to step 1 up to max_repeats times before exhausting. loop — never exhausts on its own; keeps re-paging from step 1 until acked or resolved. |
max_repeats | conditionally | Required (and must be a positive integer) when repeat_mode is n_times; ignored otherwise. |
repeat_delay_seconds | no | Delay before the step-1 re-page on a loop-back. |
Response 201 — the created chain (with an empty steps: []). Response 422 — e.g. repeat_mode: "n_times" without max_repeats.
Update / delete an escalation chain¶
PATCH /projects/{project_id}/escalation_chains/{id}
DELETE /projects/{project_id}/escalation_chains/{id}
PATCH accepts any subset of the create fields. DELETE also deletes the chain's steps and returns 204 No Content.
Clone a chain¶
Deep-copies an existing chain: name becomes "Copy of {name}", created disabled, and every step is duplicated in order — same target_type/target reference, channels, delay_seconds, ack_timeout_seconds, repeat, and any attached notification endpoints. A cloned step still pages the same roster/member/org-admins as its source until you edit it — cloning duplicates the chain's shape, not a fresh, blank set of targets.
Response 201 — the cloned chain, with its cloned steps (same shape as create). Response 404 — the source chain id isn't in this project.
Who's on call right now (resolved targets)¶
A pure read — escalation_chains:read, not :manage — reports, per step, exactly who EscalationStep#resolve_targets would page right now if this chain were actively escalating. This is not a reimplementation: it calls the identical resolution method the paging engine itself uses, so it can never drift out of sync with what actually happens during a real page. Rotation-aware, and absence-aware — an absent account is excluded exactly as it would be during real paging, including falling back to every roster member if absence would otherwise empty the resolved set (see Absence exclusion).
Response 200
{
"metadata": { "organization_id": "org-uuid", "project_id": "proj-uuid", "timestamp": "2026-07-03T20:00:00Z" },
"data": {
"escalation_chain_id": "chain-uuid",
"steps": [
{
"step_id": "step-uuid",
"position": 1,
"target_type": "roster",
"accounts": [{ "id": "acct-uuid", "name": "Bernd Hansen" }]
},
{
"step_id": "step-uuid-2",
"position": 2,
"target_type": "org_admins",
"accounts": []
}
]
}
}
accounts is id/name only — never email or a phone number, narrower than the roster listing endpoint's own PII surface. An empty accounts array on a step is a legitimate result ("nobody currently resolves" for that step, e.g. an empty roster with no rotation and no members) — not an error, and distinct from the fallback case where an otherwise-empty resolution instead returns every roster member. Response 404 — the chain id isn't in this project.
Escalation steps¶
Steps are nested under a chain and always operate on a step root key in the request body — not flat top-level fields. (action is a Rails-reserved top-level param name that would otherwise be silently overwritten by the routing action; nesting under step avoids the collision.)
List / show escalation steps¶
GET /projects/{project_id}/escalation_chains/{escalation_chain_id}/escalation_steps
GET /projects/{project_id}/escalation_chains/{escalation_chain_id}/escalation_steps/{id}
Returned ordered by position ascending.
Add a step to a chain¶
POST /projects/{project_id}/escalation_chains/{escalation_chain_id}/escalation_steps
Authorization: Bearer <token>
Content-Type: application/json
{
"step": {
"position": 1,
"target_type": "roster",
"target_roster_id": "roster-uuid",
"channels": ["in_app", "email"],
"delay_seconds": 0,
"ack_timeout_seconds": 300,
"repeat": 1
}
}
| Field | Required | Description |
|---|---|---|
position | yes | Order within the chain (1-based). |
target_type | yes | roster (page every member of an on-call roster), member (page one specific account), or org_admins (page every organization admin). |
target_roster_id | when target_type: roster | Must belong to the chain's project. |
target_member_id | when target_type: member | Must hold a project membership in the chain's project. |
channels | no | in_app, email, phone, or sms (Wave 9) — any combination. There is no separate action field: the channel itself is the mechanism. in_app/email deliver a notification; phone always places an outbound voice call to the step's resolved targets; sms always texts them, notify-only — it can never acknowledge an alert (no "press 1" equivalent). See Phone escalation / SMS escalation. |
delay_seconds | no | Wait time before this step fires, counted from the moment the escalation reaches it. |
ack_timeout_seconds | no | How long to wait for an ack before advancing to the next step (or exhausting/looping). Defaults to 300 (5 minutes) if omitted. |
repeat | no | Retry attempts on a provider failure placing/sending the phone/sms channel's call or text (not repeats on success) — only meaningful for steps with phone/sms in channels; ignored otherwise. Defaults to 1. See Retry on provider failure. |
notification_endpoint_ids | no | Notification endpoints to attach to this step — same replace-full-set semantics as a route's field of the same name, but fired notify-only and time-staged with the rest of the step rather than immediately on route match. See Attaching endpoints to escalation steps. |
Response 201 — the created step. Response 422 — e.g. target_type: "member" with no target_member_id, or a target that doesn't belong to the chain's project.
The phone channel always calls
There is no action field on a step — channels alone decides what happens. A step with "phone" in channels places a real voice call to every resolved target of that step (subject to the verified-phone gate), every time it fires. Combine it with in_app and/or email in the same channels array to notify on all three simultaneously. See Phone escalation for the full setup.
Update / delete an escalation step¶
PATCH /projects/{project_id}/escalation_chains/{escalation_chain_id}/escalation_steps/{id}
DELETE /projects/{project_id}/escalation_chains/{escalation_chain_id}/escalation_steps/{id}
PATCH body is the same step:-nested shape (any subset of fields). DELETE returns 204 No Content.
On-call rosters¶
A roster is a named group of accounts a step can target as a unit.
List / show on-call rosters¶
show includes the roster's members (each { id, email, name }, plus an absent flag — see Absence exclusion below).
Create / rename / delete an on-call roster¶
POST /projects/{project_id}/on_call_rosters
PATCH /projects/{project_id}/on_call_rosters/{id}
DELETE /projects/{project_id}/on_call_rosters/{id}
PATCH accepts { "name": "..." }. DELETE returns 204 No Content.
Add / remove roster members¶
GET /projects/{project_id}/on_call_rosters/{on_call_roster_id}/on_call_roster_members
POST /projects/{project_id}/on_call_rosters/{on_call_roster_id}/on_call_roster_members
DELETE /projects/{project_id}/on_call_rosters/{on_call_roster_id}/on_call_roster_members/{id}
The account must already hold a project membership in this project — pages must never reach someone with no relationship to it. A duplicate add (already on the roster) and a non-project-member account both fail with 422, never 500.
Response 201 — the roster member ({ id, on_call_roster_id, account_id, account, created_at }). Response 204 — member removed.
External phone contacts¶
A roster member (above) must already hold a project membership — but a phone-channel step often also needs to call people with no b'nerd account at all: an external NOC hotline, a vendor's own on-call line. A phone contact covers that case — a labeled phone number attached to a roster that only ever participates in phone-channel paging.
GET /projects/{project_id}/on_call_rosters/{on_call_roster_id}/on_call_roster_phone_contacts
POST /projects/{project_id}/on_call_rosters/{on_call_roster_id}/on_call_roster_phone_contacts
DELETE /projects/{project_id}/on_call_rosters/{on_call_roster_id}/on_call_roster_phone_contacts/{id}
consent must be truthy — the server stamps consent_acknowledged_at from the current time (any client-supplied timestamp is ignored) and records created_by_id from the acting account. Missing/false consent is 422 consent_required; a duplicate phone on the same roster, or an invalid E.164 number, is 422 — never a 500. DELETE removes the contact from every future call and returns 204 No Content.
Response 200/201 — { id, on_call_roster_id, label, phone, phone_masked, consent_acknowledged_at, created_by_id, created_at }. Both phone (E.164) and phone_masked (the roster UI's display form, e.g. "+49••••5678") are visible to any project member holding alerts:read; the write payload only ever accepts phone. The static-token scope is on_call_roster_phone_contacts (read/write/manage).
How a contact participates in paging:
- Only the
phonechannel resolves a roster's contacts — arostertarget'sin_app/emaildelivery stays account-only, since a contact has no account to notify in-app or an inbox to email. - Unlike an account, a contact is never gated on phone verification — see DSGVO: consent replaces verification below.
- A contact acknowledges exactly like an account: pressing 1 on the call. Because it has no account, its acknowledgement is attributed by
labelinstead of{ id, name }— seeacknowledged_byabove and theacked/call_placedrows in Event kinds.
DSGVO: consent replaces verification¶
- Consent is explicit and recorded, not inferred. Creating a contact requires an affirmative
consent: true; the platform stamps who added the contact (created_by_id) and when consent was acknowledged (consent_acknowledged_at) — both are part of the resource and auditable like any other record. - Purpose-limited storage. The number is stored solely to place escalation calls to it. It's visible only to project members holding
alerts:read, and deletable at any time — deleting a contact removes it from every future call immediately; there is no soft-delete or retention window. - No verification call. Recorded consent is the sole lawful basis for calling the number — a contact never receives the phone-verification call an account does.
- Minimal call metadata, no recordings. Only call status (queued, answered, no-answer, failed, ...) is logged for a contact's calls, the same as for an account's; the platform never records call audio.
- Redacted everywhere else. A contact's raw number never lands in the clear in an alert timeline event (masked to the first six characters, same as an account's — see
call_placedin Event kinds) or in the platform's audit log —phoneis a filtered parameter, so a create/update request body is redacted before it's ever logged.
On-call schedules & rotations¶
A roster's static membership list (above) is always the ultimate fallback, but a roster can optionally layer time-based scheduling on top of it: one rotation that hands off between its participants on a repeating cadence, plus any number of ad-hoc overrides for one-off coverage swaps. Both are fully optional and fully backward compatible — a roster with neither keeps paging its whole static membership list exactly as before this feature shipped.
Rotation¶
A roster has at most one rotation, enforced by a DB unique index — a second POST for an already-covered roster fails 422, never 500. It's a nested singular resource (.../rotation, no {id}), matching the "one rotation per roster" cardinality.
GET /projects/{project_id}/on_call_rosters/{roster_id}/rotation
POST /projects/{project_id}/on_call_rosters/{roster_id}/rotation
PATCH /projects/{project_id}/on_call_rosters/{roster_id}/rotation
DELETE /projects/{project_id}/on_call_rosters/{roster_id}/rotation
Authorization: Bearer <token>
{
"rotation_type": "weekly",
"handoff_time": "09:00",
"handoff_weekday": 1,
"timezone": "Europe/Berlin",
"anchor_date": "2026-07-06",
"enabled": true,
"participants": [
{ "account_id": "acct-a-uuid", "position": 0 },
{ "account_id": "acct-b-uuid", "position": 1 }
]
}
| Field | Required | Description |
|---|---|---|
rotation_type | yes | daily (24h shifts), weekly (7-day shifts), or custom (interval_days-length shifts). |
interval_days | when rotation_type: custom | Shift length in days. Ignored for daily/weekly. |
handoff_time | yes | HH:MM, interpreted in timezone. |
handoff_weekday | when rotation_type: weekly | 0 (Sunday) through 6 (Saturday) — the day handoffs happen. Ignored otherwise. |
timezone | yes | An IANA time zone name (e.g. "Europe/Berlin") — validated on save. |
anchor_date | yes | The date of the rotation's first handoff boundary (rolled forward to the next matching handoff_weekday for a weekly rotation). Everything after it is computed from this anchor plus the shift length. |
enabled | no | A disabled rotation is skipped entirely by resolution — the roster falls back to its static membership, same as having no rotation at all. |
participants | yes on create | [{ account_id, position }], set wholesale on every create/update — a reorder is a PATCH with a new array, not a partial merge. Every account must already hold a project membership; a duplicate account or duplicate position fails 422 (duplicate_participant), never 500. |
Response 201/200 — the rotation, with its resolved participants (each carrying the nested account summary plus an absent flag — see Absence exclusion below). Response 204 — deleted. Response 422 — validation_failed (bad timezone, malformed handoff_time, missing interval_days on a custom rotation, an already-existing rotation on POST) or duplicate_participant.
DST-safe handoffs
A rotation's handoff always lands at handoff_time local wall-clock time in timezone, across daylight-saving transitions — a naive "add N × 86400 seconds" would silently drift the handoff by an hour whenever a shift crosses a DST boundary; this rotation doesn't. A handoff time that would fall inside a spring-forward gap (e.g. 02:30 on the night Europe skips from 02:00 to 03:00 CEST) resolves forward to the next valid local instant — one handoff still happens, it's never skipped.
Overrides¶
An override is an ad-hoc, time-windowed coverage swap — sick day, planned trade, whatever doesn't belong in the standing rotation. There's no :show/:update: an override is create-or-destroy only — replace one by deleting it and creating a new one.
GET /projects/{project_id}/on_call_rosters/{roster_id}/on_call_overrides
POST /projects/{project_id}/on_call_rosters/{roster_id}/on_call_overrides
DELETE /projects/{project_id}/on_call_rosters/{roster_id}/on_call_overrides/{id}
Authorization: Bearer <token>
{
"account_id": "acct-covering-uuid",
"starts_at": "2026-07-10T00:00:00Z",
"ends_at": "2026-07-11T00:00:00Z",
"reason": "Swap with Bernd"
}
| Field | Required | Description |
|---|---|---|
account_id | yes | The covering account — must already hold a project membership in the roster's project, or the request fails 422. |
starts_at / ends_at | yes | Half-open window [starts_at, ends_at) — ends_at must be strictly after starts_at, or 422. |
reason | no | Free-text note, shown alongside the override. |
created_by is stamped server-side from the authenticated caller and is never client-settable. Response 201 — the override, including created_by_id. Response 204 — override removed.
Two or more overrides can be simultaneously active on the same roster — they union, rather than one silently winning: every account covered by an active override at a given instant is paged.
Resolution rules¶
Who a roster actually pages at a given instant at (used for both live paging and the roster's current_on_call field) is decided in this order:
- Active overrides win. If one or more overrides cover
at, exactly those accounts page — the computed rotation (if any) is bypassed entirely for the window. Simultaneous overrides union (see above). - Else the enabled rotation's scheduled participant. If the roster has an
enabled: truerotation and it resolves to a participant atat, that one account pages. - Else all roster members. A roster with no rotation, or a disabled one, pages its full static membership list — unchanged behavior for every roster that predates this feature.
- Never page nobody. If rule 2 was expected to apply (an enabled rotation exists) but it resolves to no one — no participants configured yet, or
atprecedes the rotation's very first handoff — the platform falls back to paging every roster member (same set as rule 3) instead of paging no one, and records anon_call_fallbacktimeline event on the alert being paged. The same fallback (without a timeline event, since there's no alert in hand) also covers a roster with no rotation and no members at all.
A roster's GET/show/index payload includes a current_on_call field — the accounts these rules resolve to right now — alongside the always-full members list.
Absence exclusion¶
Amendment A1: before rules 1–3 above are applied, accounts with an active absence at at are excluded from whatever set the rule would otherwise resolve — this runs before the never-page-nobody fallback (rule 4), not after. Absence does not cascade across rules: an absent override person does not fall through to the rotation or the plain-members rule, it's simply removed from the override's own resolved set.
- If excluding absent accounts still leaves at least one account, that (smaller) set pages — no fallback triggered.
- If excluding absent accounts would empty the resolved set entirely, the roster falls back to every roster member, exactly like rule 4 — an all-absent roster still pages, it never pages nobody.
- If the roster has no members at all to fall back to (an active override or rotation participant for someone who isn't on the roster is a real, reachable state), the fallback pages the absent person(s) instead of returning nobody — paging someone marked away beats paging no one.
This is reflected in the roster and rotation payloads so a dashboard can render an "away" indicator without re-deriving resolution logic itself:
| Field | Where | Meaning |
|---|---|---|
absent | Every entry in a roster's members and current_on_call, and every entry in a rotation's participants | Computed at the time of the response (Time.current), not per-shift. |
current_on_call_all_absent | Roster payload, top level | true only when current_on_call is non-empty and every account in it is absent — i.e. the all-absent fallback fired. The single signal to drive an "everyone is away, paging anyway" warning. |
current_on_call is already absence-filtered
Under a normal partial absence, current_on_call simply excludes the absent account(s) — they are not present-and-flagged, they're absent from the array. An absent: true entry can only appear inside current_on_call in the all-absent-fallback case (when current_on_call_all_absent is also true). To show "who on this roster is away" unconditionally, read members (the static list, always unfiltered) — not current_on_call.
GET .../shifts (below) has no absent field — a future/past shift segment has no single "now" to evaluate absence against.
The shifts endpoint¶
A server-computed, calendar-ready schedule for a roster — rotation segments and override segments overlapping [from, to], spliced together exactly the way resolution would decide them at any instant in the window, so a dashboard's upcoming-shifts strip can never disagree with who actually gets paged. from/to are optional date-time query params (default now / now + 7 days); a present-but-unparseable value is a 422 invalid_window, never a silent fallback to the default.
Rotation segments are clipped against any overlapping override windows (an active override always outranks the rotation — rule 1 above), so the returned segments never disagree about who covers a given instant. Overrides themselves may still overlap each other in the response (consistent with their union semantics). The result is sorted by starts_at and capped at 100 segments.
Response 200
{
"metadata": { "organization_id": "org-uuid", "project_id": "proj-uuid", "timestamp": "2026-07-03T20:00:00Z", "count": 2 },
"data": [
{
"source": "rotation",
"starts_at": "2026-07-06T07:00:00Z",
"ends_at": "2026-07-10T00:00:00Z",
"account": { "id": "acct-a-uuid", "name": "Alex", "email": "alex@example.com" }
},
{
"source": "override",
"starts_at": "2026-07-10T00:00:00Z",
"ends_at": "2026-07-11T00:00:00Z",
"account": { "id": "acct-covering-uuid", "name": "Bernd Hansen", "email": "bernd@example.com" }
}
]
}
| Field | Description |
|---|---|
source | rotation or override — which layer produced this segment. |
starts_at / ends_at | The segment's window, clipped to [from, to]. |
account | Who's on call for this segment. |
Response 422 — invalid_window (from or to present but not a parseable timestamp).
Silences¶
A silence suppresses paging for one alert while it is active — the escalation state machine keeps advancing quietly underneath (steps still time out and advance/exhaust/loop), it just delivers no notifications while silenced. V1 is per-alert only; matcher-based silences are deferred to a future release.
The resolved-event endpoint fan-out is the one thing silence doesn't reach
A silenced alert's firing recurrences suppress notify, escalation, and notification endpoint fan-out alike — Alerts::Router.call returns before any of it runs. The one-time alert_resolved endpoint fan-out, however, is dispatched directly from the ingest controller on the firing→resolved transition, bypassing .call (and hence the silence check) entirely — a silenced alert's attached webhook/Slack/Teams endpoints still receive that single resolved ping.
List silences for an alert¶
Most recent first.
Silence (snooze) an alert¶
POST /projects/{project_id}/alerts/{alert_id}/silences
Authorization: Bearer <token>
Content-Type: application/json
Accepts either an explicit expires_at or a duration_seconds convenience (computed relative to request time) — at least one is required.
Resumes automatically on expiry if the alert is still firing — no manual action needed to un-silence.
Response 201
{
"metadata": { "organization_id": "org-uuid", "project_id": "proj-uuid", "timestamp": "2026-06-30T20:00:00Z" },
"data": {
"id": "uuid",
"alert_id": "uuid",
"expires_at": "2026-06-30T20:30:00Z",
"reason": "Known noisy alert during deploy",
"created_by": { "id": "acct-uuid", "name": "Bernd Hansen" },
"created_at": "2026-06-30T20:00:00Z",
"updated_at": "2026-06-30T20:00:00Z"
}
}
Lift a silence early¶
Stops future suppression only
Lifting a silence does not itself resume paging. If the alert is still firing, it re-pages on its next genuine occurrence (a redelivered recurrence) — there is no automatic immediate re-route on lift in V1.
Response 204 — silence lifted.
Maintenance windows¶
A maintenance window is a scheduled, matcher-based suppression — the scheduled counterpart to a silence's ad-hoc, per-alert one. While a window is active and its matchers match an alert's labels, Alerts::Router suppresses all paging for that alert: no in-app/email notify, no escalation instance opened (or re-opened on recurrence), and no notification endpoint fan-out on either alert_firing or alert_resolved. It's checked first, ahead of the silence check, so an active window wins regardless of any per-alert silence state. See Noise control: maintenance windows for when to reach for a window versus a silence versus cooldown.
GET /projects/{project_id}/maintenance_windows
GET /projects/{project_id}/maintenance_windows/{id}
POST /projects/{project_id}/maintenance_windows
PATCH /projects/{project_id}/maintenance_windows/{id}
DELETE /projects/{project_id}/maintenance_windows/{id}
Authorization: Bearer <token>
{
"name": "DB migration — 2026-07-10",
"matchers": [
{ "label": "service", "op": "re", "value": "^db-.*" }
],
"starts_at": "2026-07-10T22:00:00Z",
"ends_at": "2026-07-10T23:30:00Z",
"note": "Planned schema migration, expect noisy replication-lag alerts"
}
| Field | Required | Description |
|---|---|---|
name | yes | |
matchers | no | Array of { label, op, value } — the same matcher language as an alert route (eq/re, AND-combined). An empty/omitted array is a catch-all window that suppresses every alert in the project for its duration. |
starts_at / ends_at | yes | ends_at must be strictly after starts_at, or 422 validation_failed. A window is active on [starts_at, ends_at) — inclusive start, exclusive end. |
note | no | Free-text, e.g. the change ticket or reason. |
Response 200/201 — the window:
{
"metadata": { "organization_id": "org-uuid", "project_id": "proj-uuid", "timestamp": "2026-07-03T20:00:00Z" },
"data": {
"id": "uuid",
"name": "DB migration — 2026-07-10",
"matchers": [{ "label": "service", "op": "re", "value": "^db-.*" }],
"starts_at": "2026-07-10T22:00:00Z",
"ends_at": "2026-07-10T23:30:00Z",
"note": "Planned schema migration, expect noisy replication-lag alerts",
"created_by": { "id": "acct-uuid", "name": "Bernd Hansen" },
"created_at": "2026-07-03T20:00:00Z",
"updated_at": "2026-07-03T20:00:00Z"
}
}
Response 204 — window deleted. Response 422 — validation_failed (ends_at not after starts_at, or missing name).
matchers has no shape validation — an invalid regex fails open, silently
Unlike an alert route, a maintenance window does not validate its matchers on save — there is no equivalent of the route's "invalid re matcher" 422. A malformed regex in a window's matchers saves cleanly and only degrades at match time: it's rescued, logged, and treated as "no match" — meaning the window silently fails open for that alert (it pages normally, the window doesn't suppress it). Double check your re matchers before relying on a window to suppress paging during planned work.
One-shot only
A window has no recurrence in v1 — no "every weekend" or cron-style schedule. Recurring maintenance needs a fresh POST (or an automation calling the API) for each occurrence.
Alert timeline¶
Every lifecycle transition on an alert — fired, routed, escalated, notified, acknowledged, silenced, and more — is recorded to an append-only ledger. It's what powers the alert detail page's history view.
List an alert's timeline¶
Returns events newest-first.
| Param | Default | Notes |
|---|---|---|
limit | 50 | Clamped to 1..200. |
offset | 0 | "Load more" pagination offset. |
RBAC runs through the same alerts permission category as the alert itself (read level), evaluated against the specific alert instance — not a project-wide check. The static-token scope is alert_events (see Authorization); read is the only meaningful level, since there is no mutating action on this resource.
Response 200
{
"metadata": { "organization_id": "org-uuid", "project_id": "proj-uuid", "timestamp": "2026-07-03T20:00:00Z", "count": 2 },
"data": [
{
"id": "uuid",
"kind": "acked",
"occurred_at": "2026-07-03T20:06:00Z",
"account": { "id": "acct-uuid", "name": "Bernd Hansen" },
"escalation_instance_id": "ei-uuid",
"payload": { "account_name": "Bernd Hansen", "via_channel": "in_app" }
},
{
"id": "uuid",
"kind": "step_started",
"occurred_at": "2026-07-03T20:05:00Z",
"account": null,
"escalation_instance_id": "ei-uuid",
"payload": { "position": 1, "channels": ["in_app", "email"], "target_names": ["Platform on-call"] }
}
]
}
| Field | Description |
|---|---|
kind | See Event kinds below. |
account | The account that triggered the event (e.g. who acked, who silenced). null for an engine-emitted event (a step firing, a watchdog exhausting the chain) — and also null when an external phone contact triggered it, since a contact has no account; its payload.account_name carries the label instead (see acked below). |
escalation_instance_id | The escalation instance this event belongs to, or null for events that aren't instance-scoped (fired, resolved, silenced, ...). |
payload | Event-kind-specific detail — see the table below. |
Event kinds¶
| Kind | Fires when | Notable payload fields |
|---|---|---|
fired | An alert is seen for the very first time (new fingerprint). | severity, alertname |
refired | A previously resolved alert starts firing again on the same fingerprint — a recurrence. | — |
resolved | A resolved delivery for an alert that was firing. | — |
routed | The alert matched an alert route. | route_id, route_name, chain_name (if the route opens a chain) |
route_suppressed | The alert matched routing but delivery was suppressed. | reason — "silenced" (an active silence, no notification and no escalation open), "cooldown" (the route's notification_cooldown_seconds hasn't elapsed since the last notify — notify and endpoint fan-out suppressed, but escalation is unaffected and opens/re-opens normally), or "maintenance_window" (an active maintenance window — notify, escalation, and endpoint fan-out all suppressed) |
escalation_opened | An escalation instance opens on the route's chain. | chain_name, repeat_count |
step_started | A step's delay_seconds elapses and it becomes the active step. | position, channels, target_names |
notified | A step delivers on one channel to its resolved targets. | channel (in_app, email, phone, sms (Wave 9), or endpoint — see Attaching endpoints to escalation steps), target_names |
call_placed | A phone-channel step places an outbound call. | target_name, to — masked to the first 6 characters only (e.g. "+49151…"); the full number never lands in the timeline (DSGVO) |
call_status | Twilio's status callback reports a new call status. | status |
acked | The alert is genuinely acknowledged (in-app, signed email link, or pressing 1 on a phone call). | account_name, via_channel — account_name is a roster contact's label, not an account name, when an external phone contact placed the ack (row-level account is null in that case) |
unacked | Every acknowledgement on the alert is cleared. | — (the account on the row identifies who un-acked) |
silenced | A silence is created. | silence_id, expires_at, reason |
silence_lifted | A silence ends — either an explicit DELETE or its own expiry. | silence_id, lifted_by ("expiry" when the timer lifted it; absent for a manual DELETE) |
escalation_exhausted | The last step's ack-timeout elapses with nobody having acked and the chain has no more loop-backs left (repeat_mode: once, or an n_times chain past max_repeats). | — |
escalation_repeated | An n_times/loop chain loops back to step 1 after exhausting its steps. | repeat_count |
on_call_fallback | An on-call roster target resolution's never-page-nobody rule kicked in — either an enabled rotation had no participant to resolve to, or absence exclusion emptied the resolved set. Every roster member was paged instead — or, for a roster with zero members to fall back to, the resolved absent on-call person(s) were paged rather than nobody (see Absence exclusion). | roster_id, at — the payload doesn't distinguish which of these triggers/outcomes applies |
endpoint_dispatched | An EndpointFanoutJob is enqueued for one notification endpoint attached to the matching route — fires once per attached enabled endpoint, on both alert_firing and alert_resolved. This records the dispatch, not the delivery outcome — see the endpoint's own deliveries for success/failure/retry detail. | endpoint_name, endpoint_kind, event |
Genuine-transitions guarantee¶
Every row is written through a single internal choke point (Alerts::Timeline.record!) — never directly by a controller or job — which only ever appends on a genuine state transition, never on a replay:
- A still-firing Alertmanager redelivery of an alert with an already-active escalation instance does not re-emit
fired/escalation_opened. - A double ack click (or two callers racing to ack the same alert) emits
ackedonce, only on the call that actually halted an active instance or created the alert's first-ever acknowledgement record — not once per halted instance, and not on a pure replay. - A redelivered Twilio status callback carrying a status the platform already recorded emits nothing;
call_statusonly writes when the incoming status differs from the stored one.
Like the platform's notification delivery, a timeline write is best-effort: a failure to write an event is logged and swallowed, never allowed to break the ingest/routing/escalation call that's emitting it.
Retention¶
Timeline events are pruned after 90 days by a daily job. Once pruned, they're gone — there is no archive or export endpoint in V1.
Error responses¶
Errors follow the standard error envelope. Alerting-specific codes:
| Status | code | When |
|---|---|---|
401 | unauthorized | No token or an invalid token |
403 | static_token_cross_project | A project-scoped static token used against a different project — includes ingest (POST .../alerts), which treats this as a permission denial, not an invalid credential |
403 | alerts_capability_not_enabled | The project's alerts capability is not enabled |
403 | insufficient_scope | A static token minting an ingest token without alerts:manage/alerts:* |
404 | not_found | Resource not in this project/chain/roster's scope |
422 | malformed_payload | Alertmanager ingest payload missing/empty alerts array |
422 | validation_failed | Invalid field on a route/chain/step/roster/rotation/override/maintenance window — e.g. a route's bad regex matcher (routes validate matchers shape; maintenance windows do not), n_times without max_repeats, an enabled route that does nothing, a bad timezone, an override whose account isn't a project member, a negative notification_cooldown_seconds, or a window's ends_at not after starts_at/missing name |
422 | duplicate_participant | A rotation's participants array reuses an account or a position |
422 | invalid_window | An unparseable from/to on GET .../shifts |
422 | consent_required | Creating an external phone contact without a truthy consent |
422 | duplicate_phone_contact | An external phone contact's phone number is already on the roster |
422 | duplicate_notification_endpoint_attachment | A same-pair race attaching a notification endpoint to a route it's already attached to (concurrent double-submit) |
422 | presets_already_configured | POST .../alerts/seed_presets when the project already has any alert route or escalation chain |
422 | invalid_days | GET .../alerts/insights's days param present but not 7, 30, or 90 — a strict enum, never clamped |
429 | rate_limited | More than 6 test alerts per minute from the same credential+project. This is a Rack::Attack middleware response — the body shape is { "errors": [{ "code": "rate_limited", ... }] }, not the standard {code, message} error envelope every other row in this table uses. |
401 | invalid_signed_token / signed_link_expired | Signed email ack link tampered or past its 24h validity |