Home
On-call

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.

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 as externalSecret.
  • 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 bare auth: { 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 with signature_mismatch, and both sides look correctly configured. externalSecret tells 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=, not v0=. Several comma-separated v1= 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 fieldComes from
Correlation keyevent.data.id — the PagerDuty incident id
Delivery idevent.id — the per-delivery id, which is what makes retries idempotent
Firing / resolvedevent.event_type — only incident.resolved resolves
Severityurgency: highcritical, urgency: lowwarning, else the priority summary, else warning
Titleevent.data.title, else "PagerDuty incident"
Labelsurgency, pagerduty_service (the service summary), pagerduty_priority
Annotationspagerduty_event_type, pagerduty_status
Linksevent.data.html_url — the incident in PagerDuty
Routing inputevent.data.assignees[0].id
Occurred atevent.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 reasonWhat it means
No rows at allCheck PagerDuty's own webhook delivery log first — it records its attempts and its response codes.
signature_mismatchBatonDeck 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_signatureThe request did not carry x-pagerduty-signature — usually something between PagerDuty and the endpoint stripping headers.
400 unrecognized_payloadNot a v3 envelope. v2 webhooks and Events API payloads have a different shape and are not accepted.
404 from the endpointWrong integration id, or the integration is disabled. Both answer identically on purpose.

Next: On-call ingestion overview · Generic signed JSON