Home
On-call

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.

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:

ModeYour tool must be able to
api_keysend a custom header (x-batondeck-key)
basicsend HTTP Basic auth
hmaccompute hmac-sha256 over "<unix-seconds>.<body>"
jwtpresent 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_hasha stable hash of the request body — the fingerprint of last resort
$nowthe time the delivery arrived
$.a.bresolve 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.

FieldTypeNotes
fingerprintpath listThe only required one. See below.
titlepath listFalls back to defaults.title, then "Alert".
severitypath listThe raw word. mapping.severityMap turns it into a ticket priority.
statuspath listRun through statusMap, then the built-in vocabulary.
descriptionpath list
occurredAtpath listISO, epoch seconds or epoch milliseconds.
labelsone pathTo an object of scalars, or to an array of "k:v" / "k=v" strings.
annotationsone pathSame two shapes.
linkspath listEach may resolve to a string or an array. Non-http(s) values are dropped.
eventsone pathTo an array — see below.
dedupKeypath listYour 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:

  • resolvedresolved ok closed recovered clear cleared normal success up
  • firingfiring alerting alarm open opened triggered active problem critical down

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 depth12 segments
Path length200 characters
Paths per field6
Labels / annotations50 entries
Value length1024 characters
Links10
Events per delivery100
statusMap entries50

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 saysCause
unrecognized_payload: custom webhook: this integration has no field mapping yetStep 3 not done. The body is stored — use it.
unrecognized_payload: custom mapping: no fingerprintYour fingerprint chain missed everything. Add $body_hash to the end.
unrecognized_payload: custom mapping: extract.events … did not resolve to an arrayevents 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.