Guide to PagerDuty Webhooks: Features and Best Practices
PagerDuty webhooks tell your systems what is happening to an incident as it happens: it is triggered, someone acknowledges it, it escalates, a responder is added, a note is written, it resolves. If you are building on PagerDuty, webhooks are how incident state reaches your status page, your chat tooling, your ticket tracker, or your own services without polling the REST API.
This guide covers how PagerDuty V3 webhooks work, how to verify a signature header that can hold more than one signature, the incident and service event types, the retry and disablement rules, and the best practices for production.
What are PagerDuty webhooks?
A PagerDuty webhook is an HTTP POST to an endpoint you own, sent when an incident or a service changes. You create a webhook subscription, choose the event types it should deliver, and scope it with a filter to one service, one team, or the whole account.
This guide is about V3 webhook subscriptions, the current generation. V1 webhook extensions stopped working in October 2022. V2 extensions still function but are out of support, carry a messages[] payload with event names like incident.trigger, and are not signed with X-PagerDuty-Signature. The Events API is a different thing again: it is how you send alerts to PagerDuty.
Every V3 delivery is signed with HMAC-SHA256 over the raw request body, and the hex digest is sent in the X-PagerDuty-Signature header with a v1= prefix. Each request carries exactly one event.
PagerDuty webhook features
| Feature | Details |
|---|---|
| Configuration | POST /webhook_ on the REST API, with a delivery URL, a list of event types and a filter |
| Signature header | X-PagerDuty-Signature, holding one or more comma-separated v1=<hex> entries |
| Scheme | HMAC-SHA256, lowercase hex, over the raw body |
| Secret | Returned once as delivery_method.secret in the create response, and used as-is as UTF-8 |
| Replay window | None. No timestamp or nonce is signed |
| Delivery id | X-Webhook-Id header, repeated across retries of the same webhook |
| Response deadline | 5 seconds, or 16 seconds for webhooks generated by Custom Incident Actions |
| Retries | Up to 48 hours on timeouts, connection failures, 5xx and 429. Any other 4xx is permanent |
| Disablement | 3 consecutive dropped webhooks disable the subscription for 24 hours |
| Ordering | Guaranteed per subscription and incident |
| Payload size | Guaranteed up to 55 KB, best effort up to 256 KB, dropped above that |
| Regions | US (api.pagerduty.com) and EU (api.eu.pagerduty.com), with the same signing scheme |
| SDK | Only the Go client ships a verifier. @pagerduty/pdjs and pdpyras do not |
There is no verification handshake. PagerDuty sends no challenge when you create a subscription, and the secret reaches you in the API response.
Common events
PagerDuty documents 31 V3 event types. Route on event.event_type, and use event.data.type to pick the shape of event.data.
| Family | Events | data.type |
|---|---|---|
| Incident lifecycle | incident.triggered, incident.acknowledged, incident.unacknowledged, incident.resolved, incident.reopened | incident |
| Incident assignment | incident.escalated, incident.delegated, incident.reassigned | incident |
| Incident changes | incident.priority_, incident.service_updated, incident.incident_ | incident |
| Notes and updates | incident.annotated, incident.status_ | incident_note, incident_status_update |
| Responders and roles | incident.responder.added, incident.responder.replied, incident.role.assigned | incident_responder, incident_role_assignment |
| Incident fields | incident.conference_, incident.custom_ | incident_, incident_field_values |
| Tasks | incident.task.created, incident.task.updated, incident.task.completed | incident_task |
| Automation | incident.action_, incident.action_, incident.action_, incident.workflow.started, incident.workflow.completed | incident_, incident_ |
| Service | service.created, service.updated, service.deleted, service.custom_ | service, service_field_values |
| Scribe | scribe.summary.created | scribe_summary |
A few names are easy to get wrong. incident.service_updated uses an underscore and means the incident moved to a different service, which is a separate event from service.updated. A note added to an incident arrives as incident.annotated. incident.role.assigned also fires when a role is unassigned. incident.escalated means the incident moved to another user at the same escalation level, incident.delegated means it moved to another escalation policy, and incident.reassigned means it moved to another user.
scribe.summary.created is only delivered to subscriptions filtered with account_reference. A subscription scoped to a service or team never receives it.
PagerDuty says more event types may be added and also ships early access events, so a handler needs a default branch that accepts what it does not recognise.
The envelope wraps a single event object:
{
"event": {
"id": "5ac64822-4adc-4fda-ade0-410becf0de4f",
"event_type": "incident.priority_updated",
"resource_type": "incident",
"occurred_at": "2020-10-02T18:45:22.169Z",
"agent": {
"id": "PLH1HKV",
"summary": "Tenex Engineer",
"type": "user_reference"
},
"client": { "name": "PagerDuty" },
"data": {
"id": "PGR0VU2",
"type": "incident",
"number": 2,
"status": "triggered",
"title": "A little bump in the road",
"service": { "id": "PF9KMXH", "summary": "API Service", "type": "service_reference" },
"priority": { "id": "PSO75BM", "summary": "P1", "type": "priority_reference" },
"urgency": "high"
}
}
}
event.agent and event.client can both be null, which usually means automation made the change instead of a person. data.priority is null when no priority is set.
Setting up PagerDuty webhooks
Create a subscription with the REST API. EU accounts use https://api.eu.pagerduty.com.
curl -X POST https://api.pagerduty.com/webhook_subscriptions \
-H 'Authorization: Token token=YOUR_API_TOKEN' \
-H 'Content-Type: application/json' \
-d '{
"webhook_subscription": {
"type": "webhook_subscription",
"delivery_method": {
"type": "http_delivery_method",
"url": "https://your-app.example.com/webhooks/pagerduty"
},
"description": "Incident webhooks",
"events": ["incident.triggered", "incident.acknowledged", "incident.resolved"],
"filter": { "type": "service_reference", "id": "P393ZNQ" }
}
}'
The filter decides which incidents produce webhooks: service_reference for one service, team_reference for one team, or account_reference for everything. Start with a single service, because an account-wide filter on a busy account is a lot of traffic.
The signing secret comes back in the response as delivery_method.secret. Store it immediately. Later reads of the subscription redact it, so a lost secret means creating a replacement subscription and deleting the old one. It is not your REST API token and not an Events API routing key.
To test without waiting for a real incident, ask PagerDuty to ping the subscription:
curl -X POST https://api.pagerduty.com/webhook_subscriptions/PWHSUB1/ping \
-H 'Authorization: Token token=YOUR_API_TOKEN'
That delivers a pagey.ping event through the subscription's normal delivery method. It is not one of the 31 subscribable types and its data looks like nothing else, so it should fall through your default branch. PagerDuty does not document whether the ping is signed, so check your own logs before relying on it to test verification.
Securing PagerDuty webhooks
Compute HMAC-SHA256 over the raw request body with the subscription secret as the key, then look for that digest among the v1= entries in X-PagerDuty-Signature. The header is a list because PagerDuty signs the body once per active secret while a secret is being rotated:
X-PagerDuty-Signature: v1=f03de6f61df6e454f3620c4d6aca17ad072d3f8bbb2760eac3b2ad391b5e8073,v1=130dcacb53a94d983a37cf2acba98e805a1c37185309ba56fdcccbcf00d6dd8b
Accept the delivery if any entry matches.
const crypto = require('crypto');
function verifyPagerDutySignature(rawBody, signatureHeader, secret) {
if (!signatureHeader || !secret) return false; // fail closed
// HMAC over the RAW bytes. Keep the raw digest and compare decoded bytes.
const expected = crypto.createHmac('sha256', secret).update(rawBody).digest();
return signatureHeader.split(',').some((entry) => {
const part = entry.trim();
// Skip unknown versions instead of failing, so a future v2= entry
// sitting next to a valid v1= does not break the receiver.
if (!part.startsWith('v1=')) return false;
const candidate = Buffer.from(part.slice(3), 'hex');
// Length first: timingSafeEqual throws on a mismatch, and an uncaught
// throw becomes a 500 that PagerDuty retries for 48 hours.
return candidate.length === expected.length && crypto.timingSafeEqual(candidate, expected);
});
}
The same check in Python:
import hashlib
import hmac
def verify_pagerduty_signature(raw_body: bytes, signature_header: str | None, secret: str | None) -> bool:
"""raw_body is the RAW, unparsed request body."""
if not signature_header or not secret:
return False # fail closed
expected = hmac.new(secret.encode("utf-8"), raw_body, hashlib.sha256).hexdigest().encode("ascii")
matched = False
for entry in signature_header.split(","):
part = entry.strip()
if not part.startswith("v1="):
continue # skip unknown versions
candidate = part[3:].lower().encode("utf-8")
# compare_digest on bytes: str arguments raise TypeError on non-ASCII
# input, and a forged header can carry anything.
if hmac.compare_digest(candidate, expected):
matched = True
return matched
Nothing in the signed content identifies when the webhook was sent, so there is no replay window to enforce. Adding a tolerance check would mean inventing a field PagerDuty does not send. Replay protection comes from deduplicating on X-Webhook-Id.
PagerDuty offers other layers on top of the signature. It recommends mutual TLS, where your server requests PagerDuty's client certificate and checks that it chains to the DigiCert Global Root G2 with a subject of webhooks.pagerduty.com or webhooks.eu.pagerduty.com. A subscription can also carry an OAuth 2.0 bearer token or static custom headers, and PagerDuty publishes webhook IP safelists per region. Of all these layers, only the signature protects the body from modification.
Make PagerDuty webhooks production-ready. Hookdeck Event Gateway verifies
X-PagerDuty-Signatureon arrival, including multi-signature headers during a rotation, acknowledges inside the 5 second budget, and durably queues every incident event.
PagerDuty webhook limitations and pain points
The signature header is a list
The Problem: A verifier that compares the whole header against one digest works for months and then rejects every delivery when a secret rotation begins.
Why It Happens: During a rotation PagerDuty signs the same body once per active secret and joins the results with commas. There is no customer-facing rotate endpoint, so you do not choose when that happens.
Workarounds:
- Split the header on commas and accept a match on any
v1=entry. - Skip entries with a prefix you do not recognise. That is how a future signature version can roll out without breaking you.
- Add a test with a two-entry header where only the second entry is valid.
How Hookdeck Can Help: Hookdeck's PagerDuty source checks every entry in the header and delivers the event when one matches, so a rotation does not interrupt verification.
The status code you return decides whether PagerDuty retries
The Problem: A handler that returns 500 for a bad signature gets the same forged request back for two days. A handler that returns 400 for a transient database error loses the event permanently.
Why It Happens: PagerDuty retries for up to 48 hours on timeouts, connection failures, 5xx responses and 429. Every other 4xx is treated as permanent and the webhook is dropped with no further attempts.
Workarounds:
- Return 4xx only for requests you never want to see again: a missing or malformed signature header, or a signature mismatch.
- Return 5xx or 429 only when a retry could succeed.
- Wrap the handler so an unexpected exception during verification cannot become a 500.
How Hookdeck Can Help: Hookdeck returns 200 to every PagerDuty request. A request that fails signature verification is recorded in Hookdeck and not delivered to your destination, and verified events reach your service under Hookdeck's own retry policy, so the status codes your service returns no longer decide whether PagerDuty keeps or drops an event.
Three dropped webhooks disable the subscription for a day
The Problem: A short outage on your side turns into a 24 hour gap in incident data.
Why It Happens: After 3 consecutive dropped webhooks PagerDuty disables the subscription for 24 hours and discards whatever it had queued. While a webhook is being retried, later webhooks for the same subscription and resource are held behind it, so one stuck delivery also stalls every update to that incident.
Workarounds:
- Verify, enqueue, and return
202 Acceptedinside the 5 second budget. PagerDuty recommends this pattern itself. - Watch for subscriptions tagged "Needs Attention" on the webhooks dashboard and re-enable them there or with
POST /webhook_.subscriptions/{id}/enable - After a disablement, reconcile incident state from the REST API, because the discarded webhooks are not sent again.
How Hookdeck Can Help: Hookdeck accepts each delivery whatever state your service is in, so an outage on your side does not register with PagerDuty as dropped webhooks. Events wait in Hookdeck's queue and are delivered when your endpoint recovers.
Large payloads are trimmed, then dropped
The Problem: Incident details go missing from some webhooks, or a webhook for a large incident never arrives.
Why It Happens: Delivery and ordering are guaranteed up to 55 KB. Above that PagerDuty removes event details from the first log entry's channel object and sets flags such as details_omitted to true. Between 55 KB and 256 KB delivery is best effort. Anything over 256 KB is always dropped.
Workarounds:
- PagerDuty's documentation describes the
details_omitted,cef_anddetails.details_ omitted body_omittedflags on log entries, but none of its V3 example payloads include log entries. Treat a missing flag as normal, and fetch the full incident from the REST API when one is set or when you are in doubt. - If you cap request body size at your edge, set the cap to at least 256 KB.
How Hookdeck Can Help: Hookdeck stores each delivery exactly as PagerDuty sent it, so you can see in the request history whether details were omitted before your handler ran.
The secret is shown once
The Problem: Nobody saved delivery_method.secret when the subscription was created, and there is no way to read it back.
Why It Happens: PagerDuty returns the secret only in the create response. Reading the subscription later returns it redacted.
Workarounds:
- Write the secret to your secret manager in the same script that creates the subscription.
- If it is lost, create a replacement subscription, deploy its secret, then delete the original.
How Hookdeck Can Help: The secret is stored once on the Hookdeck source and verification happens there, so each downstream service does not need its own copy.
Best practices
Verify against the raw body, before you parse
PagerDuty's documentation says verification "requires the unaltered raw body of the request". Capture the bytes first, verify, then parse. In Express that means express.raw({ type: '*/*' }) on the webhook route. Decode the body as UTF-8, because incident titles often contain non-ASCII characters.
Accept a match on any v1= entry
Treat X-PagerDuty-Signature as a list from the start, even though it will usually hold one entry. Skip entries that are not valid hex or carry another version prefix.
Acknowledge with 202 and process asynchronously
Return 202 Accepted once the signature checks out and hand the event to a queue. The budget is 5 seconds, and a timeout starts a retry sequence that blocks later events for the same incident. See why to process webhooks asynchronously.
Deduplicate on X-Webhook-Id
Delivery is at least once. The X-Webhook-Id header is unique to a webhook and repeated on each retry of it, which makes it the idempotency key. Keep processed ids for more than 48 hours to cover the full retry window. See our guide to webhook idempotency.
Keep a default branch
Unknown values of event.event_type should be logged and acknowledged. pagey.ping, early access events and newly added types all arrive this way, and a 5xx on any of them starts 48 hours of retries.
Guard against null agents
Read event.agent and event.client defensively. event.agent.id throws on events made by automation, and the documented service.updated example has both set to null.
Subscribe narrowly
List only the event types you handle and filter to a service or team where you can. A second subscription is cheap, while an account-wide firehose your handler mostly ignores still has to be verified, acknowledged and deduplicated.
Conclusion
PagerDuty signs V3 webhooks with HMAC-SHA256 over the raw body and sends the hex digest as a v1= entry in X-PagerDuty-Signature, a header that holds several entries while a secret is rotated. No timestamp is signed, so deduplication on X-Webhook-Id is the replay protection. Answer within 5 seconds, return 4xx only for requests you never want retried, and remember that three dropped webhooks cost you a day of incident data.
Hookdeck Event Gateway verifies the PagerDuty signature, acknowledges inside the 5 second budget, and durably queues every incident event, so an outage on your side does not disable the subscription.
Get started with Hookdeck for free and handle PagerDuty webhooks reliably in minutes.