Generic signed JSON
The BatonDeck-native source. You control the sender, so it can compute our signature — this is the only source that gets the full HMAC scheme: timestamped, replay-bounded, and dual-accepting during a rotation.
Use it for your own scripts, CI jobs, synthetic checks, custom monitors, and any tool whose webhook body you can shape.
- Auth:
hmac— the server mints the secret and shows it once. - Endpoint:
POST https://<your-oncall-host>/i/<integrationId> - Payload: the canonical alert shape, validated strictly.
Create the integration
create_integration {
projectId, name: "ci-canary", source: "generic", boardId: "B-…",
auth: { mode: "hmac" },
routing: { mode: "oncall", onCallAgent: "claude-oncall" }
}The response carries endpointPath (/i/ing_…) and secret, with shownOnce: true. Store it
before you close the response — it is encrypted at rest with no read-back, and losing it means
rotate_integration_secret.
Do not pass
externalSecrethere. Bringing your own secret marks the integration externally-issued, which switches it to PagerDuty's scheme —v1=, raw body, no timestamp — and the rest of this page no longer applies. Omit it and let the server mint one to get the timestampedv0=scheme documented below.
The request
| Header | Value |
|---|---|
content-type | application/json |
x-batondeck-timestamp | Unix time in seconds |
x-batondeck-signature | v0=<hex> (see below) |
Body schema
Every field below is top-level. Unknown keys are refused by name — a typo comes back as
400 {"error":"unrecognized_payload","detail":"generic payload: unknown keys: severty"} rather than
being silently dropped. This is the one connector whose schema we own, so it tells you what you got
wrong.
| Field | Type | Required | Notes |
|---|---|---|---|
fingerprint | non-empty string | yes | The correlation key. Repeat firings of the same condition must reuse it. |
status | "firing" | "resolved" | "test" | yes | Anything else is a 400. |
severity | non-empty string | yes | Mapped to ticket priority; see below. |
title | non-empty string | yes | Truncated to 300 characters. |
description | string | no | Truncated to 4000 characters. |
labels | object, string values only | no | Defaults to {}. Max 64 entries; keys and values truncated to 512 characters. |
annotations | object, string values only | no | Same caps. Subject to mapping.annotationAllowlist before anything reaches the ticket. |
links | array of strings | no | Max 10; empty strings dropped. |
occurredAt | ISO 8601 string | no | Defaults to receipt time. A non-parseable string is a 400, not a fallback. |
upstreamAssignee | string | no | Only meaningful with routing.mode: "upstream". |
deliveryId | string | no | Your own idempotency key — see Idempotency below. |
raw | any | no | Accepted, but ignored: the stored raw payload is the whole body regardless. |
The thing people get wrong: labels and annotations values must be strings.
{"labels":{"port":8080}} is a 400. The vendor connectors coerce numbers and booleans out of
payloads we do not control; this one refuses them, because you can fix your own sender. Send
{"labels":{"port":"8080"}}.
Severity
severity is a free string. The built-in mapping to ticket priority covers critical/fatal/
disaster/emergency → urgent, error/high/major → high, warning/warn/normal/minor →
normal, info/low/none/ok → low. Anything else lands on normal — set
mapping.severityMap on the integration if your vocabulary differs.
The signature
signature = "v0=" + hex( HMAC-SHA256( secret, "<unix-seconds>" + "." + <raw request body> ) )- The base string is the timestamp, a literal
., then the exact bytes you send as the body. - Sent as
x-batondeck-signature: v0=<hex>. Several comma-separatedv0=values are accepted, and any one matching passes. x-batondeck-timestampmust be within ±300 seconds of server time. Outside that, the delivery is refused withtimestamp_skew— that bound is what makes a captured request unreplayable.- During a rotation the previous secret keeps verifying for 24 hours, so you can re-paste without dropping deliveries.
Two ways to get this wrong, both of which look like a server bug:
- Milliseconds instead of seconds.
Date.now()is accepted by the format check and then fails the skew check, so you seetimestamp_skewrather than anything about the format. UseMath.floor(Date.now() / 1000). - Signing different bytes than you send. Re-serializing the object after signing (or
echo's trailing newline in a shell) changes the digest. Sign a string, then send that same string.
Signing in bash
HOST="https://<your-oncall-host>"
INTEGRATION_ID="ing_0123456789abcdef0123456789abcdef"
SECRET="whsec_…" # the shown-once value from create_integration
BODY='{"fingerprint":"db-primary-cpu","status":"firing","severity":"critical","title":"DB primary CPU > 95%","labels":{"service":"db","env":"prod"},"links":["https://grafana.example/d/abc"]}'
TS=$(date +%s)
# printf, not echo: a trailing newline would be signed but not sent.
SIG=$(printf '%s.%s' "$TS" "$BODY" | openssl dgst -sha256 -hmac "$SECRET" | awk '{print $NF}')
# --data-binary, not -d: -d strips newlines and can alter the bytes.
curl -sS -X POST "$HOST/i/$INTEGRATION_ID" \
-H 'content-type: application/json' \
-H "x-batondeck-timestamp: $TS" \
-H "x-batondeck-signature: v0=$SIG" \
--data-binary "$BODY"Signing in Node
import { createHmac } from 'node:crypto';
const host = 'https://<your-oncall-host>';
const integrationId = 'ing_0123456789abcdef0123456789abcdef';
const secret = process.env.BATONDECK_WEBHOOK_SECRET; // whsec_…
// Serialize ONCE. This exact string is both signed and sent.
const body = JSON.stringify({
fingerprint: 'db-primary-cpu',
status: 'firing',
severity: 'critical',
title: 'DB primary CPU > 95%',
description: 'Sustained above 95% for 10 minutes on db-primary-1.',
labels: { service: 'db', env: 'prod' },
links: ['https://grafana.example/d/abc'],
});
const ts = Math.floor(Date.now() / 1000); // SECONDS — not Date.now()
const sig = createHmac('sha256', secret).update(`${ts}.${body}`).digest('hex');
const res = await fetch(`${host}/i/${integrationId}`, {
method: 'POST',
headers: {
'content-type': 'application/json',
'x-batondeck-timestamp': String(ts),
'x-batondeck-signature': `v0=${sig}`,
},
body,
});
console.log(res.status, await res.json());Idempotency
The effective key for a delivery is <deliveryId>#<fingerprint>. When you send no deliveryId, the
first half is a hash of the request body — so two byte-identical bodies are treated as a retry of
one delivery, and the second does nothing. That is what makes your own retries safe, and it is
also why a repeat firing needs something to vary: send a distinct deliveryId, or let occurredAt
carry the difference.
Responses
A successful call returns the per-event outcome:
{ "accepted": 1, "results": [{ "deliveryId": "…#db-primary-cpu", "status": 200, "result": "created" }] }| Status | Meaning |
|---|---|
200 | Accepted. results[] says what each event did. |
400 | invalid_json, or unrecognized_payload with a detail naming the offending field. |
401 | Signature or timestamp rejected. Deliberately no hint — check the delivery log for the reason. |
404 | Unknown path or integration id — or the integration is disabled. Identical by design, so a scanner cannot enumerate ids. |
413 | Body over the integration's payload cap (256 KB unless you set limits.payloadCapKb). |
429 | Over limits.ratePerMin (default 60). Retry with backoff. |
503 | Transient — configuration or the core was briefly unreachable. Retry; the delivery id makes it safe. |
The strictest status wins when one delivery carries several events, so a 200 is always a
genuine "you may forget this delivery".
Verifying it works
send_test_event proves the board target, correlation, routing and the agent doorbell — but it
originates inside the core and skips signature verification entirely. To prove your signing
code, send one real signed request with "status":"test" and confirm a 200.
