Custom webhook — any source
Your tool is not in the list. This is the source for it: point it at a BatonDeck endpoint, describe where the fields live in its payload, and alerts become incident tickets. No code from you, no release from us.
Use this when:
- your monitoring tool is not one of the seven with a built-in connector, or
- it is, but you want a different field on the ticket title than the connector picks.
Do not use it for a sender you fully control — use Generic signed JSON instead and emit our shape directly. It is stricter, which means it tells you when you get it wrong.
1. Create the integration
On-call → gear → New → Custom webhook. Pick any auth mode your tool can send — this is the only source that offers all four, because it is the only one where we cannot know what your tool supports:
| Mode | Your tool must be able to |
|---|---|
api_key | send a custom header (x-batondeck-key) |
basic | send HTTP Basic auth |
hmac | compute hmac-sha256 over "<unix-seconds>.<body>" |
jwt | present Authorization: Bearer <jwt> from an issuer with a public JWKS |
Over MCP:
create_integration {
projectId, name: "acme-monitor", source: "webhook", boardId: "B-…",
auth: { mode: "api_key", apiKey: "<a long random value you generate>" },
routing: { mode: "broadcast" }
}2. Send one real alert, then read it back
Create the endpoint before you write the mapping. Fire a real alert at it. It will be refused —
custom webhook: this integration has no field mapping yet — and that refusal stores the body.
Open the delivery log, download the payload, and write the mapping against the bytes your tool
actually sends rather than against its documentation.
This is the workflow the feature is designed around. An unmapped integration is not a broken one.
3. Describe where the fields are
update_integration {
projectId, integrationId, version,
patch: { mapping: {
extract: {
fingerprint: ["alert.id", "$body_hash"],
title: ["alert.name", "summary"],
severity: ["alert.level"],
status: ["alert.state"],
labels: "alert.tags",
links: ["alert.url"]
},
statusMap: { ALARM: "firing", OK: "resolved" },
defaults: { severity: "warning" }
} }
}Paths
A path is dots and numeric indices: alert.rules[0].name. That is the whole grammar — no wildcards,
no filters, no regular expressions. The mapper runs in the service that parses your vendor's JSON and
holds no database access at all, so every operator the grammar gains is an operator a hostile payload
can reach. Anything the grammar cannot express, statusMap and defaults usually can.
Three sigils:
$body_hash | a stable hash of the request body — the fingerprint of last resort |
$now | the time the delivery arrived |
$.a.b | resolve against the whole body rather than the current event (see events below) |
Fields
Every field except events, labels and annotations takes a list of paths, tried in order. The
first one that resolves to a string, number or boolean wins. A key that is present but null counts
as a miss — vendors use null to mean absent, and putting the word "null" on a ticket as the
severity helps nobody.
| Field | Type | Notes |
|---|---|---|
fingerprint | path list | The only required one. See below. |
title | path list | Falls back to defaults.title, then "Alert". |
severity | path list | The raw word. mapping.severityMap turns it into a ticket priority. |
status | path list | Run through statusMap, then the built-in vocabulary. |
description | path list | |
occurredAt | path list | ISO, epoch seconds or epoch milliseconds. |
labels | one path | To an object of scalars, or to an array of "k:v" / "k=v" strings. |
annotations | one path | Same two shapes. |
links | path list | Each may resolve to a string or an array. Non-http(s) values are dropped. |
events | one path | To an array — see below. |
dedupKey | path list | Your tool's own per-delivery id, if it has one. |
fingerprint is the correlation key, and it is the one thing you must get right
Two deliveries with the same fingerprint land on the same ticket. That is the entire point: an alert that fires, repeats forty times and then resolves is one ticket with an occurrence count, not forty-one tickets.
So point it at whatever your tool calls the alert/rule/monitor identity — not at a timestamp, not at
an event id that changes per notification. If nothing stable exists, end the chain with $body_hash:
identical bodies then correlate, which is the best available answer.
Grouped deliveries: events
Many tools batch. Set events to the array and you get one ticket per alert instead of one ticket
for the batch:
extract: {
events: "alerts",
fingerprint: ["labels.alertname", "$body_hash"],
title: ["annotations.summary"],
status: ["status"],
labels: "labels",
links: ["generatorURL"]
}Inside events, paths resolve against each element. Use $. to reach back up to a field the
batch shares — $.commonLabels.cluster, $.groupKey.
A fingerprint does not change with batch size or ordering. The same alert produces the same fingerprint whether it arrives alone or alongside thirty-nine others, and whatever position it holds — so it correlates onto the ticket it already opened. That matters because most senders batch whatever happens to be firing, in no guaranteed order.
The one exception: if your per-element path resolves to the same value for several alerts in one
batch, those get numbered (X#0, X#1, …) so a forty-alert batch cannot collapse onto one ticket
with thirty-nine alerts silently lost. That is a signal your fingerprint path is pointing at
something shared rather than at each alert's identity — fix the path; the numbering is a backstop,
not a feature.
status
statusMap first, matched case-insensitively. Then a built-in vocabulary, so most tools need no map
at all:
- resolved —
resolvedokclosedrecoveredclearclearednormalsuccessup - firing —
firingalertingalarmopenopenedtriggeredactiveproblemcriticaldown
Anything else falls to defaults.status, then firing. A statusMap entry always wins over the
built-in list, which is what makes the built-in list safe to have.
Limits
Every one of these bounds what an untrusted payload can cost or put on a ticket:
| Path depth | 12 segments |
| Path length | 200 characters |
| Paths per field | 6 |
| Labels / annotations | 50 entries |
| Value length | 1024 characters |
| Links | 10 |
| Events per delivery | 100 |
statusMap entries | 50 |
Over a limit, the extra is dropped — the delivery still lands.
When it goes wrong
A bad path is refused when you save, naming the field and the index
(extract.fingerprint[1] ('a[x]'): malformed path). It is never a silent miss at 3am, because a
silently-missed path is indistinguishable from your vendor changing its payload.
At delivery time, only two things refuse:
| Delivery log says | Cause |
|---|---|
unrecognized_payload: custom webhook: this integration has no field mapping yet | Step 3 not done. The body is stored — use it. |
unrecognized_payload: custom mapping: no fingerprint | Your fingerprint chain missed everything. Add $body_hash to the end. |
unrecognized_payload: custom mapping: extract.events … did not resolve to an array | events points at an object, or at nothing. |
Everything else degrades to a default rather than refusing: a half-mapped ticket on the board beats a dropped alert you have to read a log to discover.
