PagerDuty
PagerDuty delivers to BatonDeck through a v3 webhook subscription. It is the one third-party source that signs its deliveries — with its own secret, in its own scheme.
- Auth:
hmac, with PagerDuty's secret pasted asexternalSecret. - Endpoint:
POST https://<your-oncall-host>/i/<integrationId> - Header:
x-pagerduty-signature: v1=<hex>— PagerDuty sends this itself; you configure nothing. - One incident event = one incident ticket.
Use this when PagerDuty already owns your rotations. BatonDeck does not do schedules — it is the
layer that resolves the problem, not the one that decides whose phone rings. Point PagerDuty here
in upstream mode and the person PagerDuty picked determines which agent picks it up.
1. Create the subscription in PagerDuty first
Integrations → Generic Webhooks (v3) → New Webhook. Set the delivery URL to your endpoint, scope it to the service or account you want, and choose the event types.
Subscribe to incident.triggered and incident.resolved, and little else. BatonDeck treats
only incident.resolved as a resolution — incident.acknowledged, incident.escalated,
incident.priority_updated, incident.reopened and every other incident.* type all arrive as
firing. Subscribe to all of them and each acknowledgement becomes another occurrence on the ticket.
PagerDuty shows the signing secret exactly once, when the subscription is created. Copy it now.
2. Create the integration with PagerDuty's secret
create_integration {
projectId, name: "prod-pagerduty", source: "pagerduty", boardId: "B-…",
auth: { mode: "hmac", externalSecret: "<the secret PagerDuty just showed you>" },
routing: {
mode: "upstream",
pdUserMap: { "PXXXXXX": "claude-oncall", "PYYYYYY": "claude-backend" },
fallbackBroadcast: true
}
}The one thing to get right: pass
externalSecret. Create it with a bareauth: { mode: "hmac" }and BatonDeck mints a secret and shows it to you — a secret PagerDuty has no way to use. PagerDuty keeps signing with its own, every delivery is refused withsignature_mismatch, and both sides look correctly configured.externalSecrettells BatonDeck the source issued the credential; nothing is minted and nothing is echoed back, because you already have it.
When PagerDuty rotates the subscription secret, pass the new one through the same field:
rotate_integration_secret { projectId, integrationId, version, externalSecret: "<new PD secret>" }3. The signature scheme
PagerDuty's scheme, not BatonDeck's:
x-pagerduty-signature: v1=<hex of HMAC-SHA256( secret, <raw request body> )>- Prefix
v1=, notv0=. Several comma-separatedv1=values are accepted, and any one matching passes — which is how PagerDuty's own secret rotation works. - The signed base string is the raw body alone. There is no timestamp.
That last point has a consequence worth stating plainly: a captured PagerDuty delivery stays
valid forever, because there is no timestamp to age out. The BatonDeck scheme bounds replay with a
±300-second window; PagerDuty's cannot. What bounds it here instead is delivery-id idempotency —
each event is keyed <event.id>#<event.data.id>, and a replayed delivery carries the same
event.id, so it resolves to the delivery already processed and does nothing. Replay is neutralised
rather than refused.
4. Routing with pdUserMap
routing.mode: "upstream" uses PagerDuty's own assignment. BatonDeck reads the first assignee of
the incident and looks it up in pdUserMap:
pdUserMap: { "<PagerDuty user id>": "<BatonDeck agent name>" }The keys are PagerDuty user ids — PXXXXXX, not email addresses. They come from
event.data.assignees[].id in the webhook body; the quickest way to read one is the delivery log
after a real incident, or PagerDuty's own user URLs.
What happens when the lookup misses — an unmapped responder, or an incident with no assignee — is deliberate and worth knowing:
- The ticket is still created, unassigned and READY, so any agent can pull it.
- The doorbell is withheld unless you set
fallbackBroadcast: true, which rings every live agent in the project instead.
Set fallbackBroadcast: true unless you have mapped every responder who can be paged.
5. What lands on the ticket
| Ticket field | Comes from |
|---|---|
| Correlation key | event.data.id — the PagerDuty incident id |
| Delivery id | event.id — the per-delivery id, which is what makes retries idempotent |
| Firing / resolved | event.event_type — only incident.resolved resolves |
| Severity | urgency: high → critical, urgency: low → warning, else the priority summary, else warning |
| Title | event.data.title, else "PagerDuty incident" |
| Labels | urgency, pagerduty_service (the service summary), pagerduty_priority |
| Annotations | pagerduty_event_type, pagerduty_status |
| Links | event.data.html_url — the incident in PagerDuty |
| Routing input | event.data.assignees[0].id |
| Occurred at | event.occurred_at |
Because urgency drives severity, a PagerDuty high-urgency incident becomes an urgent ticket and
a low-urgency one a normal ticket, without any severityMap. Add one only if you want the
priority names to steer it instead.
When nothing arrives
| Delivery log reason | What it means |
|---|---|
| No rows at all | Check PagerDuty's own webhook delivery log first — it records its attempts and its response codes. |
signature_mismatch | BatonDeck holds a different secret than PagerDuty signs with — almost always a minted secret where externalSecret was needed. Re-run rotate_integration_secret with the value from PagerDuty. |
missing_signature | The request did not carry x-pagerduty-signature — usually something between PagerDuty and the endpoint stripping headers. |
400 unrecognized_payload | Not a v3 envelope. v2 webhooks and Events API payloads have a different shape and are not accepted. |
404 from the endpoint | Wrong integration id, or the integration is disabled. Both answer identically on purpose. |
