On-call and the escalation-policy model
Incident alerting comes down to three primitives, the same ones PagerDuty-style tools are built on:- Rotation — an ordered list of responders and a shift cadence. Answers “who is on call right now.”
- Escalation policy — an ordered list of steps saying who to page, on which channels, and how long to wait for an acknowledgement before moving on. Answers “who gets paged next if nobody responds.”
- Incident — one occurrence of something that needs a human, traveling
open → acknowledged → resolved, or toexhaustedwhen nobody ever responds.
Rotation — who holds the pager
A rotation is a fixed-length shift schedule anchored to an absolute instant (anchor). On-call rotates through members in their list order, one member per shift, wrapping with modulo.
The anchor is the single design decision that keeps the math honest. A rotation defined as “every Monday at 09:00 local” has to reason about timezones and daylight-saving gaps and repeats; a rotation anchored to an absolute ISO instant never does. Each hand-off is exactly shift_length_seconds after the previous one, and timezones enter the picture exactly once — when you pick the anchor for the first hand-off. From then on the arithmetic is plain seconds, so it stays correct across DST changes no matter which timezone the anchor encodes.
cadence is daily (24-hour shifts), weekly (7-day shifts), or custom, where you carry your own shift_length_seconds — 43,200 for 12-hour halves, 9,000 for a school-nights shift, whatever matches your staffing. resolve answers three facts: the member currently on call, the exact shift_start / shift_end window they hold, and next_on_call with next_handoff_at.
- Before the anchor, the rotation has not started, but a pager-less query still needs a human — member one is treated as on call for the notional shift that ends at the anchor, and
next_handoff_atequals the anchor itself. - A backdated
atreturns the historically correct shift. “Who was on call when the alert fired” is a legitimate question, and the window math is deterministic enough to answer it.
Escalation policy — the flattened page timeline
An escalation policy is an ordered list of steps. Each step names:- a target — either a live rotation or a fixed member list (
users), - the channels to page (push, SMS, voice, email, …), and
escalate_after_seconds— how long to wait for an acknowledgement before the next step fires.
planEscalation flattens this tree into an absolute-offset timeline: step 0 fires at offset 0, step 1 after step 0’s wait window, and so on, with each offset the cumulative sum of the windows before it. repeat loops the whole policy that many additional times when nobody acknowledges — the PagerDuty “repeat the policy N times” knob. The result is the schedule an incident driver walks: fire page P at fire_at, and if no acknowledgement lands before the next fire_at, fire the next page.
Rotation targets are resolved at each page’s fire instant, not once at incident start. A hand-off landing mid-escalation pages whoever holds the pager at that moment, which is the behavior you actually want when an incident outlives a rotation boundary. The common shape mixes the two target kinds: rotation first (whoever currently holds the pager), a fixed fallback human second.
POST /api/v1/oncall/escalation/plan returns exactly this timeline — every page with its round, step, offset_seconds, fire_at, targets, and channels, plus total_pages across all rounds.
Incident lifecycle — open, acknowledged, resolved, exhausted
An incident is the snapshot you keep: its policy, thestarted_at that anchors the timeline, the current status, and a pages_fired high-water mark. Two operations drive it:
tick— given the snapshot and a “now”, returns which pages became due, the new high-water mark, and when to wake up next (next_tick_at).transition— applies an operator action:ack,resolve, orreassign.
409 incident_transition_invalid instead of silently corrupting your state. A halted (acknowledged or resolved) incident ticks back due_pages: [] and next_tick_at: null, so your scheduler stands down. reassign is the only path back to paging — it swaps the policy, resets the mark to zero, and restarts the timeline; that is how you bump a Sev-2 incident onto a broader Sev-1 policy.
When every page across every round has fired with no acknowledgement, tick reports status: "exhausted" and hands the incident back to you — treat that as its own alert (page a fallback channel, open a ticket) rather than keep a scheduler awake on a null next-tick.
Pure compute, caller-owned state
Every On-Call endpoint is pure compute over the request body. It never stores your rotation, never stores your incident, and never sends anything. The caller owns persistence end to end: you pass the full definition in, you persist the returned snapshot, and the next call carries it back. That posture buys three concrete properties:- Idempotency by construction — the flattened timeline is strictly increasing in fire time, so the only progress you must remember is a single monotonic integer,
pages_fired. No set of “already fired” pages to reconcile, and replaying a tick with a stale snapshot returns the same due-pages, never a double page. - Determinism — the same snapshot and the same
atalways return the same answer. You can test a rotation before it goes live, and you can reconstruct “who was on call then” from the definition alone. - Zero lock-in on the state layer — keep incidents in your own database, or in memory for a quick script; the API imposes no schema of its own on your side.
Where to go next
- On-call alerting guide — the step-by-step walk from rotation definition to a resolved incident and the actual page send.
- On-Call API reference — full endpoint, scope, and field-level reference.
- Messages API — the send surface that reaches a resolved target.
- Webhooks — delivery status for the pages you sent.