Skip to main content

Generic Alert Webhook

Overview​

Console parses exactly two alert payload shapes natively: Alertmanager and Grafana's legacy (8.x panel) alerting. Anything else — a cron job, an in-house health checker, a vendor webhook that doesn't speak either dialect — has to first grow a translator into one of those two formats before it can page anyone. Most things that could page a human do not, for exactly that reason.

The generic webhook is a third door: a small required core plus a details blob Console stores and returns but never interprets. If your sender can compute a stable identity for the condition it's reporting and can POST JSON, it can page through this endpoint without Console knowing anything about its vendor-specific shape.

POST /api/v1/alerts/webhook/generic

There is no token in the path and no path parameter at all — every generic source shares the same URL, and the token that authenticates a delivery travels in the Authorization header. See Authentication below for exactly why, and Never put the token in a URL for what happens if you try.

This page is the integration guide for the generic door specifically — how to authenticate, shape a payload, and read the response. It shares one pipeline with the Alertmanager and Grafana legacy doors (label resolution, grouping, severity mapping, the strict ingestion contract), and that shared machinery — the full reject-reason table, the strict-mode would-reject ratios, and per-source ingest health — is documented once, for all three doors, in Alerting & Correlation.

Creating a generic source​

Navigate to On-Call → Alert Sources (/oncall/alert-sources), click Add Source, and choose Generic (webhook) as the source type. Creating or managing sources requires the alertsources:write permission (alertsources:read to list/view, alertsources:delete to remove); like every alert source, the created row is scoped to a client.

The page's project selector is a filter, not a gate: it lands on All projects. With a project filtered, the dialog inherits it and shows it as a read-only row with a Change control; from All projects it asks which project, and submit is blocked until you answer.

You can copy the token again whenever you need it. The create response gives you the bare endpoint above and a plaintext token, and the token stays retrievable afterwards: the row's actions menu on the Alert Sources page has Show webhook token, which calls GET /api/v1/alert-sources/{sourceID}/token (alertsources:read, audit-logged) and opens the same dialog. Losing your copy is not a reason to delete and recreate a live source. Sources created before Console stored an encrypted copy of the token — and any deployment with no Vault Transit encryptor configured — are the exception: the reveal says so instead of showing a token.

Optionally, configure dedup_key_from on the source: an ordered list of payload field names Console derives a dedup key from when a delivery doesn't supply one explicitly. See The dedup key below — this is the field most worth getting right before you point a real sender at the door, because a mistake here doesn't show up as an error; it shows up as a stuck or duplicated page.

dedup_key_from has no UI as of this writing. The Sources form creates a generic source but does not expose this field — there is no input to set it and no read-only display of its current value on the Sources table. Set (or clear) it through the API directly:

curl -X PUT https://console.example.com/api/v1/alert-sources/{sourceID} \
-H "Authorization: Bearer REPLACE_WITH_A_SESSION_OR_API_TOKEN" \
-H "Content-Type: application/json" \
-d '{"dedup_key_from": ["check_name", "host"]}'

Send "dedup_key_from": [] (or omit the field, or send null) to clear a derivation back to "none configured" — the backend canonicalizes all three to the same meaning. This requires the alertsources:write permission, same as creating the source. See Known limitations and planned work below for the state of a UI editor.

The contract​

Every delivery is a single JSON object. Four fields are the required core — the fields that cannot be guessed without sometimes being wrong:

FieldTypeRequiredMeaning
dedup_keystringconditionally — see belowIdentifies the condition. The same key must appear on the firing delivery that opens a page and the resolved delivery meant to close it.
statusstringyesfiring or resolved, compared case-insensitively. Any other value (including absent) is refused with 400.
severitystringrecommendedThe sender's own severity label, mapped onto Console's P1–P5 tiers. See unknown severity.
summarystringrecommendedThe human-readable line an on-call engineer reads at 3am.

And two optional fields carried alongside the core:

FieldTypeMeaning
labelsobjectParticipates in alert grouping and in entity-scope resolution (the per-client label-mapping rows that turn e.g. env=prod into a Proxima environment).
detailsobjectCarried verbatim on the alert's raw payload and never interpreted by any part of the ingestion pipeline. Use it for whatever your sender wants to attach — Console stores it whole and returns it unchanged; nothing in Console ever parses a key out of it.

dedup_key, status and severity are required to be scalars in the JSON sense. summary is a string. Sending an object or array for any of those is a parse error.

Label values are never a reason to refuse a page​

No value under labels can cause a delivery to be rejected. A string, number or boolean value renders to its natural text form. A JSON object or array under a label key is compacted to its JSON text and stored as a string (Console does not guess which rendering of a nested structure is "the" value, so it keeps the whole thing rather than picking a lossy one). null renders as the empty string. This is deliberate: refusing a delivery over the shape of the least safety-critical field in the payload — while a real detection is trying to reach you — is exactly the failure this design avoids everywhere else, applied nowhere.

Authentication​

Every delivery must carry:

Authorization: Bearer <source token>

The scheme is checked exactly, and it is case-sensitive with exactly one separating space. The door reuses the same bearer-token extractor that fronts every other credential surface in this system (the seam-consistency rule: one invariant, everywhere a bearer token is checked) — it is not a bespoke parser written for this endpoint. That extractor requires the literal 7-character prefix Bearer (capital B, six lower-case letters, one space) before the token.

  • bearer <token> (lower-case scheme) is refused — the prefix doesn't match at all, so no token is even extracted.
  • Bearer <token> (two spaces) is also refused — the extracted "token" carries a leading space and matches no stored credential.
  • Bearer<token> (no space) is refused for the same reason as the lower-case case.

This is worth stating plainly because the senders most likely to hit this door — a cron job, an in-house script, a webhook client hand-rolled against a README — are also the senders least likely to have battle-tested bearer-auth handling. Send the header exactly as shown above.

Every authentication failure — a missing header, the wrong scheme, an unknown token, a token for a disabled source, or a token that belongs to an alertmanager/grafana_legacy source presented at this door instead — answers the identical 401 with a WWW-Authenticate: Bearer header and an unrevealing body. The response cannot be used to learn whether a token you're guessing at is real; the reason is visible only in Console's own logs.

Never put the token in a URL​

The endpoint takes no query parameters at all. A ?token= value on the URL is not read by anything — the handler only ever consults the Authorization header — so it authenticates nothing and is silently ignored. There is also no token-in-path form for this door (unlike the two legacy webhook endpoints, which do carry their token in the URL for compatibility reasons that predate this door and are not being changed).

This is a deliberate design choice, not an oversight. A URL — including its query string — lands in a proxy's access log, an error tracker, browser history, and anything else that records a request line. Console masks the query string on its own request logs for every alert-webhook route, whole, precisely because a confused sender is the population most likely to guess that a credential belongs in the URL — but that only covers logging Console itself controls. The edge proxy sitting in front of Console is not part of this repository, and nothing in this repo redacts its access log. A token pasted into a URL is a token sitting in plaintext in whatever log aggregation your infrastructure points that proxy's access log at — which is exactly why this door authenticates by header only, and why the create/reveal screens for a generic source never show you a URL with a token in it. If you're setting this door up and thinking "I'll just put ?token=... on the URL to make my sender's job easier" — don't; it will not authenticate, and if it worked, it would be a leak.

The dedup key​

This is the whole design the rest of the contract exists to support. The dedup key is the identity of the condition being reported: the same key must arrive on the firing delivery that opens a page and on the resolved delivery meant to close it. Get it wrong and the resolve matches nothing — the page Console already sent runs its full escalation chain with no way for the sender to stop it, because the sender's only handle on the page is a key that no longer matches.

Supplying it explicitly​

Send dedup_key as a non-empty string. This is the simplest and most robust option whenever your sender can compute a stable identity itself (a check name, a resource ID, anything that is the same for a condition's trigger and its recovery).

Deriving it from the payload (dedup_key_from)​

If your sender can't compute a key itself, configure dedup_key_from on the alert source: an ordered list of payload field names. Console builds the key by reading each named field from the delivery and joining the values. The derivation rule is exact and unforgiving on purpose:

  • Top-level payload keys only. A name in dedup_key_from is looked up directly against the JSON object's top-level keys — it never descends into labels or details.
  • Matched exactly and case-sensitively, with no normalization on either side. JSON object keys are case-sensitive, and the derivation does not guess: ["checkName"] matches a payload key "checkName" and nothing else — not "checkname", not "CheckName". (Console does normalize the configured field name for one purpose — refusing near-miss spellings of the two forbidden names below at write time — but that normalization never touches how a payload is read at delivery time. The two are deliberately different code paths.)
  • Every named field must resolve to a non-empty scalar, or the whole delivery is refused with 400. A partially-resolved key would silently be built from fewer fields than you configured — which is exactly the failure mode (two different conditions colliding into one alert group) this design exists to prevent.
  • status cannot be named as a derivation field, and neither can summary. Console refuses both at configuration time, before either can reach a delivery:
    • A status-derived key differs between a delivery's trigger and its own resolve — the field's two legal values are firing and resolved — so the resolve could never find the group it was meant to close. This is the same failure as sending the wrong key, just self-inflicted by the configuration.
    • A summary-derived key changes between samples of the same flapping condition. Summaries routinely carry numbers ("CPU at 98%" → "CPU at 97%" on the next evaluation), so a key built from one turns every sample of a single flapping check into its own alert group with its own escalation — the "forty pages for one outage" failure.

The 512-byte bound​

A dedup key — whichever way it was produced — is capped at 512 bytes. This isn't a style preference: the key lands in the alerts.fingerprint column, which carries a btree unique index with a real, physical row-size ceiling (a little under 2.7 KB in PostgreSQL, and that limit is measured in the compressed, encoded index entry — so it is data-dependent and cannot simply be probed with a short repeated string). An unbounded key would fail the INSERT inside the alert worker, on the far side of the queue from your POST — accepted with a 202, then silently dropped. A named 400 at the door, with a clear reason, beats a page that vanishes into a retry loop after you've already been told it worked. 512 bytes is comfortably below that index ceiling even after Console's internal escaping of a multi-field derivation, and is generous compared to vendors this shape is modeled on (PagerDuty, for reference, caps its own dedup_key at 255 bytes).

The 202 echoes the published key​

A successful 202 response body always includes the dedup key Console actually used:

{"status": "accepted", "dedup_key": "checkA|db-primary"}

This is the key after derivation, escaping, and the 512-byte bound — not the raw string off the wire. If you configured dedup_key_from, this is how you confirm the derivation did what you expected, at integration time, instead of discovering a mismatch mid-incident when a resolve closes nothing. (The two legacy webhook doors don't echo a key in their response body — their grouping key is derived from vendor-specific fields the sender never chose and can't act on, so publishing one there would just be a new field nobody reads. This door's key is different: your own sender chose it, or authored the derivation that built it, so it's the one thing worth handing back.)

Resolve semantics​

Send "status": "resolved" with the same dedup_key that opened the alert group, to close it.

  • A resolve that matches nothing is answered 202, not 404. If Console has already closed the group (an earlier resolve, a manual close, whatever the reason), a second resolve arriving for the same key is not a sender error — it's a correct statement about a condition that is, in fact, resolved. Console drops the delivery without creating anything and answers success.
  • A resolve never opens a group. Only a delivery that carries a firing alert can create one. This asymmetry exists because the opposite bug shipped in production once: a resolve for an already-closed group used to fall through to the same code path that opens groups, which matched nothing, inserted a brand-new group with an invented severity, and then immediately resolved it in the same request — one empty phantom alert group per redundant resolve.
  • Resolution is scoped by source. A resolve can only close a group opened by that same alert source. This means a generic source's webhook token can never be used to silence another source's alerts, even within the same client.

unknown severity​

If a delivery's severity is absent, or is a value Console doesn't recognize, the alert is stored with severity unknown — never rounded up or down to a plausible-looking P1–P5. Console does not guess at severity, on this door or any other: inventing one would be a page (or a missing page) built on a lie.

unknown is a first-class severity tier, not an error state:

  • It is fully visible in the UI and in the API alongside P1–P5.
  • It ranks least urgent of all tiers, so it can never masquerade as something more urgent downstream.
  • It routes normally: an unknown-severity alert group matches an escalation route that explicitly targets unknown, or a catch-all route — nothing more, nothing less. There's no special-casing.

Common severity words are recognized case-insensitively and mapped onto Console's tiers — for example critical/fatal/page → P1, error/high → P2, warning/minor → P3, info/low → P4, debug/trace → P5. Anything outside that table — including a misspelling — becomes unknown.

A source that's mostly unknown is visible, without you having to go looking​

If a sender is drifting — sending a severity label Console doesn't recognize, or none at all — that's exactly the kind of silent failure this endpoint is built to surface rather than hide. Two independent signals cover it:

  • The Alert Sources page (/oncall/alert-sources) shows, per source, a running count of severities Console could not map, directly under that source's health line: "N severities Proxima could not map (running total, not a recent rate)". It only appears once the count is non-zero, and it's explicitly labeled a lifetime total rather than a rate — a source that has simply never labeled severity accumulates this while otherwise being perfectly healthy, so the number is a lead worth investigating, not an alarm by itself.
  • The metric proxima_alert_ingest_unknown_severity_total{source_id, source_type, kind} (see the metric registry, docs/standards/metrics.md, in the repo) backs the same count for dashboards and alert rules, split by whether the label was absent (fix the sender) or unrecognized (add an alias). Read the two kind values separately — a source that never labels severity has a permanently high absent rate that isn't, on its own, a regression.

Replays​

Console suppresses an exact repeat of a delivery: the request body's raw bytes are hashed, and a delivery whose hash was seen within the last 60 seconds for the same source is answered

{"status": "skipped"}

with HTTP 200, and is not re-published for processing.

Be honest with yourself about what this buys you and what it costs. The generic contract has no field that varies with time — no timestamp, no incrementing counter — so if your sender posts the exact same JSON body for two genuinely distinct firings of the same check within one minute of each other, the second one is swallowed by this guard exactly as if it were a retry. In practice this means: a condition that resolves and re-fires with a byte-identical body inside 60 seconds pages once, not twice. For almost every real alerting sender that's the right behavior — a flap that fast is noise, and collapsing it is a feature, not a bug — but if your integration might legitimately fire the same condition, with the same summary, twice inside a minute, put something that varies (a timestamp in details, for instance — it's never interpreted, so it costs nothing) in the body so the two deliveries hash differently.

(The alertmanager door uses the same 60-second window, for a different reason: Alertmanager re-sends a group that is still firing every repeat_interval with a byte-identical body, and those repeats are what tells the stale-group reaper the outage is still live — a longer window would swallow them and let a still-firing alert be auto-resolved. Only the deprecated grafana_legacy door keeps a 24-hour window. See Alertmanager Configuration.)

Response codes​

StatusMeaningWhat to do
202 AcceptedThe delivery was parsed and published for processing. Body includes {"status": "accepted", "dedup_key": "..."}.Nothing — this is success. Keep the echoed dedup_key if you'll need to correlate a later resolve.
200 OKReplay suppressed: an identical body was already accepted from this source within the last 60 seconds. Body is {"status": "skipped"}.Nothing — treat this as delivered. See Replays.
400 Bad RequestThe delivery is malformed, or violates the contract in a way the door won't accept (an empty/invalid status, a missing or unresolvable dedup_key, a key over 512 bytes, or — only when the deployment has opted into PROXIMA_ALERT_INGEST_STRICT, and only for a firing delivery, see below — a missing summary or unrecognized severity; a resolved delivery is exempt from those two). The body's error.code names the reason.Do not retry as-is. Fix the payload; retrying an unchanged body will fail identically.
401 UnauthorizedAuthentication was refused — see Authentication.Check the Authorization header format and the token; do not retry until it's fixed.
500 Internal Server ErrorSomething broke on Console's side (a database or queue fault) while handling an otherwise-valid, authenticated delivery.Retry. This is Console's fault, not the sender's, and retrying is exactly the right response — a 5xx here specifically avoids the alternative of silently dropping a page during a transient blip.

Worked example​

# Trigger — opens (or joins) the alert group identified by dedup_key.
curl -i -X POST https://console.example.com/api/v1/alerts/webhook/generic \
-H "Authorization: Bearer REPLACE_WITH_YOUR_SOURCE_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"dedup_key": "checkA-db-primary",
"status": "firing",
"severity": "critical",
"summary": "db-primary: replication lag exceeds 30s",
"labels": {"env": "prod", "service": "db-primary"},
"details": {"lag_seconds": 47, "check_url": "https://monitoring.example.com/checkA"}
}'
# 202 {"status":"accepted","dedup_key":"checkA-db-primary"}

# Resolve — closes the SAME group. Same dedup_key, nothing else needs to match.
curl -i -X POST https://console.example.com/api/v1/alerts/webhook/generic \
-H "Authorization: Bearer REPLACE_WITH_YOUR_SOURCE_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"dedup_key": "checkA-db-primary",
"status": "resolved"
}'
# 202 {"status":"accepted","dedup_key":"checkA-db-primary"}

Note that the resolve above carries no severity and no summary — that's a legitimate, common shape for a resolve (the condition is over; there's nothing new to summarize), and this door accepts it, even with PROXIMA_ALERT_INGEST_STRICT on (see the rollout note below: a resolve is exempt from the strict contract's summary/severity gates, because refusing it would leave the group it names firing forever). The closing group's severity, labels and annotations (including its summary text), once set by a firing delivery, are not overwritten by a bare resolve — a delivery this thin only ever closes what is already open; it never blanks what already described it.

Limits​

  • Request body: capped at 1 MB, the same limit as every other alert webhook endpoint.
  • Rate: 60 requests per minute per source IP per backend process by default (PROXIMA_WEBHOOK_RATE_LIMIT), shared with the two legacy webhook doors. The limiter keeps its counter in process memory, not in a shared store — with N backend replicas behind the load balancer, the real budget for one source IP is 60 × N requests per minute, split across whichever replicas happen to receive its traffic. This is different from the auth rate limits elsewhere in Console, which are Valkey-backed and genuinely global across replicas.
  • Dedup key: 512 bytes — see above.

Known limitations and planned work​

Neither gap below affects whether the door works. Every request/response shape documented on this page — authentication, the contract, strict-mode gating, replay suppression, resolve semantics — is live in production today. Both gaps are about managing a generic source's configuration through the product's UI, not about whether alerts flow, and both are tracked with a proposed shape in docs/superpowers/specs/2026-09-03-generic-inbound-webhook-followup.md in the repo — an artifact, not just a mention on this page.

  • Re-viewing a token after creation. Resolved — the Show webhook token row action on the Alert Sources page (/oncall/alert-sources) reveals it on demand, gated on alertsources:read and audit-logged server-side. A source with no stored ciphertext (created before token storage, or created on a deployment with no encryptor configured) still cannot be revealed; the UI says which case it is rather than reporting a failure.
  • A dedup_key_from editor. What works today: setting and clearing it entirely through the API (see Deriving it from the payload above for the exact request shape and its []-clears semantics). What doesn't: seeing or changing it from the Sources UI — there's no input to set it and no read-only display of a source's current value on the Sources table. If you configure it via the API, keep your own note of what you sent; the product can't show it back to you yet.

Rollout note: PROXIMA_ALERT_INGEST_STRICT is per-deployment, not per-source​

Console has a strict ingestion contract, gated behind the environment variable PROXIMA_ALERT_INGEST_STRICT, that turns a blank summary and an unrecognized severity on this door from counted-but-accepted into an outright 400 refusal — but only for a firing delivery. A resolved delivery is exempt from both of those gates: summary and severity describe how urgent and how legible a condition is, and neither has any bearing on whether it is over, which is all a resolve is claiming. Refusing a resolve for either reason would leave the group it names firing forever, with no way for the sender to stop the escalation it started — a strictly worse outcome than the degraded-but-ingested alert this contract exists to catch. The hard refusals (no dedup_key, an invalid status) still apply to a resolve exactly as they do to a firing delivery — a resolve with no key cannot be matched to anything. The flag is read once, for the whole backend deployment — it is not a per-source or per-client setting. Turning it on to tighten this door's contract simultaneously arms the same enforcement on every other alert source in that deployment, including the legacy Alertmanager and Grafana sources that have been running leniently until now. Before flipping it, read what the flag would already be rejecting — proxima_alert_ingest_rejected_total{reason, refused="false"} records what would have been refused while the flag is off, across every source, so you can check the blast radius before you create it. See docs/standards/alert-ingestion.md in the repo for the full ingestion contract this flag governs, and PROXIMA_ALERT_INGEST_STRICT in the environment variable reference for the flag itself.