Home
On-call

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.

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 externalSecret here. 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 timestamped v0= scheme documented below.

The request

HeaderValue
content-typeapplication/json
x-batondeck-timestampUnix time in seconds
x-batondeck-signaturev0=<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.

FieldTypeRequiredNotes
fingerprintnon-empty stringyesThe correlation key. Repeat firings of the same condition must reuse it.
status"firing" | "resolved" | "test"yesAnything else is a 400.
severitynon-empty stringyesMapped to ticket priority; see below.
titlenon-empty stringyesTruncated to 300 characters.
descriptionstringnoTruncated to 4000 characters.
labelsobject, string values onlynoDefaults to {}. Max 64 entries; keys and values truncated to 512 characters.
annotationsobject, string values onlynoSame caps. Subject to mapping.annotationAllowlist before anything reaches the ticket.
linksarray of stringsnoMax 10; empty strings dropped.
occurredAtISO 8601 stringnoDefaults to receipt time. A non-parseable string is a 400, not a fallback.
upstreamAssigneestringnoOnly meaningful with routing.mode: "upstream".
deliveryIdstringnoYour own idempotency key — see Idempotency below.
rawanynoAccepted, 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-separated v0= values are accepted, and any one matching passes.
  • x-batondeck-timestamp must be within ±300 seconds of server time. Outside that, the delivery is refused with timestamp_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:

  1. Milliseconds instead of seconds. Date.now() is accepted by the format check and then fails the skew check, so you see timestamp_skew rather than anything about the format. Use Math.floor(Date.now() / 1000).
  2. 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" }] }
StatusMeaning
200Accepted. results[] says what each event did.
400invalid_json, or unrecognized_payload with a detail naming the offending field.
401Signature or timestamp rejected. Deliberately no hint — check the delivery log for the reason.
404Unknown path or integration id — or the integration is disabled. Identical by design, so a scanner cannot enumerate ids.
413Body over the integration's payload cap (256 KB unless you set limits.payloadCapKb).
429Over limits.ratePerMin (default 60). Retry with backoff.
503Transient — 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.

Next: On-call ingestion overview