> ## Documentation Index
> Fetch the complete documentation index at: https://docs.orbit.devotel.io/llms.txt
> Use this file to discover all available pages before exploring further.

# On-Call API: rotations and escalation policies

> On-call rotation resolution and escalation-policy planning for incident alerting — PagerDuty-style primitives built on top of your existing Orbit channels.

# On-Call API

On-Call gives you the compute primitives behind incident paging — who is on call right now, and in what order an incident pages people — without locking you into a separate alerting vendor. Every endpoint is pure compute over the request body: you send a rotation or policy definition, Orbit returns the resolved answer. Nothing is paged and no state is persisted by these endpoints; when you're ready to actually notify someone, you send through the [Messaging API](/api-reference/endpoints/messaging) (SMS, voice, WhatsApp, push, email) using the targets and channels this API worked out for you.

**Base path:** `/api/v1/oncall`

**Authentication:** API key (`X-API-Key`) or session JWT.

| Method | Path                                 | Scope            | Purpose                                                      |
| ------ | ------------------------------------ | ---------------- | ------------------------------------------------------------ |
| `POST` | `/api/v1/oncall/resolve`             | `messages:read`  | Who is on call for a rotation, now or at a given instant     |
| `POST` | `/api/v1/oncall/escalation/plan`     | `messages:read`  | Flatten an escalation policy into an ordered page timeline   |
| `POST` | `/api/v1/oncall/incident/tick`       | `messages:write` | Advance an incident one scheduler step — which pages are due |
| `POST` | `/api/v1/oncall/incident/transition` | `messages:write` | Apply a lifecycle action (acknowledge / resolve / reassign)  |

## Resolve who's on call

Given a rotation — an ordered list of member identifiers and a cadence (`daily`, `weekly`, or a `custom` shift length in seconds), anchored to the ISO instant member one's first shift starts — returns the member on call at a given instant (defaults to now), the current shift window, and when the next hand-off happens. Members are opaque identifiers the engine never interprets — E.164 numbers, email addresses, push tokens, or your own user ids.

Pass an `at` in the past to answer "who was on call when the alert fired" — the window math is deterministic, so a backdated resolve returns the historically correct shift.

<RequestExample>
  <CodeGroup>
    ```bash cURL theme={null}
    curl -X POST https://api.orbit.devotel.io/api/v1/oncall/resolve \
      -H "X-API-Key: dv_live_sk_your_key_here" \
      -H "Content-Type: application/json" \
      -d '{
        "rotation": {
          "name": "Platform primary",
          "cadence": "weekly",
          "anchor": "2026-08-17T00:00:00Z",
          "members": [
            "+15551234567",
            "oncall-alerts@example.com",
            "usr_primary_03"
          ]
        },
        "at": "2026-08-20T15:00:00Z"
      }'
    ```

    ```python Python theme={null}
    import os, requests

    headers = {
        "X-API-Key": os.environ["ORBIT_API_KEY"],
        "Content-Type": "application/json",
    }
    r = requests.post(
        "https://api.orbit.devotel.io/api/v1/oncall/resolve",
        headers=headers,
        json={
            "rotation": {
                "name": "Platform primary",
                "cadence": "weekly",
                "anchor": "2026-08-17T00:00:00Z",
                "members": [
                    "+15551234567",
                    "oncall-alerts@example.com",
                    "usr_primary_03",
                ],
            },
            "at": "2026-08-20T15:00:00Z",
        },
    )
    print(r.json()["data"]["on_call"])
    ```

    ```go Go theme={null}
    package main

    import (
    	"bytes"
    	"fmt"
    	"net/http"
    	"os"
    )

    func main() {
    	body := []byte(`{
    	  "rotation": {
    	    "name": "Platform primary",
    	    "cadence": "weekly",
    	    "anchor": "2026-08-17T00:00:00Z",
    	    "members": [
    	      "+15551234567",
    	      "oncall-alerts@example.com",
    	      "usr_primary_03"
    	    ]
    	  },
    	  "at": "2026-08-20T15:00:00Z"
    	}`)
    	req, _ := http.NewRequest(
    		"POST",
    		"https://api.orbit.devotel.io/api/v1/oncall/resolve",
    		bytes.NewBuffer(body),
    	)
    	req.Header.Set("X-API-Key", os.Getenv("ORBIT_API_KEY"))
    	req.Header.Set("Content-Type", "application/json")
    	resp, _ := http.DefaultClient.Do(req)
    	fmt.Println(resp.Status)
    }
    ```
  </CodeGroup>
</RequestExample>

<ResponseExample>
  ```json 200 theme={null}
  {
    "data": {
      "rotation_name": "Platform primary",
      "at": "2026-08-20T15:00:00.000Z",
      "on_call": "oncall-alerts@example.com",
      "shift_index": 0,
      "shift_start": "2026-08-17T00:00:00.000Z",
      "shift_end": "2026-08-24T00:00:00.000Z",
      "next_on_call": "usr_primary_03",
      "next_handoff_at": "2026-08-24T00:00:00.000Z"
    },
    "meta": {
      "request_id": "req_9001",
      "timestamp": "2026-08-26T12:00:00.000Z"
    }
  }
  ```
</ResponseExample>

The response rows read back against the request:

* `at` echoes the instant you resolved at, normalized to ISO — a backdated `at` produces the same answer a live resolve at that moment would have returned.
* `shift_index` counts completed shifts since the anchor. Here the anchor (2026-08-17) is earlier than `at` (2026-08-20), and fewer than seven days have elapsed, so shift zero is still running. Once `at` passes `shift_end`, `shift_index` advances by one per cadence and membership wraps with modulo.
* `on_call` is `members[shift_index mod len(members)]` — shift zero picks member one, the E.164-to-email mix is fine because member strings are opaque.
* `shift_start` / `shift_end` give the exact window the member holds the pager; `next_on_call` is the member who takes over at `next_handoff_at`.

Resolve at an instant before the anchor and member one holds the line with `shift_index: 0` — pager-less requests early in a schedule's life still reach a human, and `next_handoff_at` equals the anchor itself.

## Plan an escalation

Given an escalation policy — an ordered list of steps, each with a target (a live rotation or a fixed member list), the channels to page, and how long to wait for an acknowledgement before escalating — returns the full page timeline an incident driver walks: which targets get paged, on which channels, at which offset from incident start, and how many rounds the policy repeats.

A step targets either a **rotation** (resolved live at each page's fire instant, so a hand-off mid-escalation still reaches the correct human) or a fixed **users** list. Mixing the two is the common shape: rotation first, a fallback human second.

<RequestExample>
  <CodeGroup>
    ```bash cURL theme={null}
    curl -X POST https://api.orbit.devotel.io/api/v1/oncall/escalation/plan \
      -H "X-API-Key: dv_live_sk_your_key_here" \
      -H "Content-Type: application/json" \
      -d '{
        "policy": {
          "name": "Sev-1 platform",
          "repeat": 1,
          "steps": [
            {
              "target": {
                "type": "rotation",
                "rotation": {
                  "name": "Platform primary",
                  "cadence": "weekly",
                  "anchor": "2026-08-24T00:00:00Z",
                  "members": ["+15551234567", "usr_primary_03"]
                }
              },
              "channels": ["push", "sms"],
              "escalate_after_seconds": 300
            },
            {
              "target": { "type": "users", "members": ["oncall-alerts@example.com"] },
              "channels": ["email"],
              "escalate_after_seconds": 300
            },
            {
              "target": { "type": "users", "members": ["+15557654321"] },
              "channels": ["voice"],
              "escalate_after_seconds": 300
            }
          ]
        },
        "start_at": "2026-08-26T09:00:00Z"
      }'
    ```

    ```python Python theme={null}
    import os, requests

    headers = {
        "X-API-Key": os.environ["ORBIT_API_KEY"],
        "Content-Type": "application/json",
    }
    policy = {
        "name": "Sev-1 platform",
        "repeat": 1,
        "steps": [
            {
                "target": {
                    "type": "rotation",
                    "rotation": {
                        "name": "Platform primary",
                        "cadence": "weekly",
                        "anchor": "2026-08-24T00:00:00Z",
                        "members": ["+15551234567", "usr_primary_03"],
                    },
                },
                "channels": ["push", "sms"],
                "escalate_after_seconds": 300,
            },
            {
                "target": {"type": "users", "members": ["oncall-alerts@example.com"]},
                "channels": ["email"],
                "escalate_after_seconds": 300,
            },
            {
                "target": {"type": "users", "members": ["+15557654321"]},
                "channels": ["voice"],
                "escalate_after_seconds": 300,
            },
        ],
    }
    r = requests.post(
        "https://api.orbit.devotel.io/api/v1/oncall/escalation/plan",
        headers=headers,
        json={"policy": policy, "start_at": "2026-08-26T09:00:00Z"},
    )
    for page in r.json()["data"]["pages"]:
        print(page["round"], page["step"], page["offset_seconds"], page["targets"])
    ```

    ```go Go theme={null}
    package main

    import (
    	"bytes"
    	"fmt"
    	"net/http"
    	"os"
    )

    func main() {
    	body := []byte(`{
    	  "policy": {
    	    "name": "Sev-1 platform",
    	    "repeat": 1,
    	    "steps": [
    	      {
    	        "target": {
    	          "type": "rotation",
    	          "rotation": {
    	            "name": "Platform primary",
    	            "cadence": "weekly",
    	            "anchor": "2026-08-24T00:00:00Z",
    	            "members": ["+15551234567", "usr_primary_03"]
    	          }
    	        },
    	        "channels": ["push", "sms"],
    	        "escalate_after_seconds": 300
    	      },
    	      {
    	        "target": { "type": "users", "members": ["oncall-alerts@example.com"] },
    	        "channels": ["email"],
    	        "escalate_after_seconds": 300
    	      },
    	      {
    	        "target": { "type": "users", "members": ["+15557654321"] },
    	        "channels": ["voice"],
    	        "escalate_after_seconds": 300
    	      }
    	    ]
    	  },
    	  "start_at": "2026-08-26T09:00:00Z"
    	}`)
    	req, _ := http.NewRequest(
    		"POST",
    		"https://api.orbit.devotel.io/api/v1/oncall/escalation/plan",
    		bytes.NewBuffer(body),
    	)
    	req.Header.Set("X-API-Key", os.Getenv("ORBIT_API_KEY"))
    	req.Header.Set("Content-Type", "application/json")
    	resp, _ := http.DefaultClient.Do(req)
    	fmt.Println(resp.Status)
    }
    ```
  </CodeGroup>
</RequestExample>

<ResponseExample>
  ```json 200 theme={null}
  {
    "data": {
      "policy_name": "Sev-1 platform",
      "start_at": "2026-08-26T09:00:00.000Z",
      "total_pages": 6,
      "pages": [
        {
          "round": 0,
          "step": 0,
          "offset_seconds": 0,
          "fire_at": "2026-08-26T09:00:00.000Z",
          "targets": ["+15551234567"],
          "channels": ["push", "sms"]
        },
        {
          "round": 0,
          "step": 1,
          "offset_seconds": 300,
          "fire_at": "2026-08-26T09:05:00.000Z",
          "targets": ["oncall-alerts@example.com"],
          "channels": ["email"]
        },
        {
          "round": 0,
          "step": 2,
          "offset_seconds": 600,
          "fire_at": "2026-08-26T09:10:00.000Z",
          "targets": ["+15557654321"],
          "channels": ["voice"]
        },
        {
          "round": 1,
          "step": 0,
          "offset_seconds": 900,
          "fire_at": "2026-08-26T09:15:00.000Z",
          "targets": ["+15551234567"],
          "channels": ["push", "sms"]
        },
        {
          "round": 1,
          "step": 1,
          "offset_seconds": 1200,
          "fire_at": "2026-08-26T09:20:00.000Z",
          "targets": ["oncall-alerts@example.com"],
          "channels": ["email"]
        },
        {
          "round": 1,
          "step": 2,
          "offset_seconds": 1500,
          "fire_at": "2026-08-26T09:25:00.000Z",
          "targets": ["+15557654321"],
          "channels": ["voice"]
        }
      ]
    },
    "meta": {
      "request_id": "req_9002",
      "timestamp": "2026-08-26T12:00:00.000Z"
    }
  }
  ```
</ResponseExample>

The flattened `pages[]` timeline, row by row:

* **Round 0, step 0** — fires immediately at offset 0. The rotation target resolves live: `anchor` 2026-08-24 is two days before `start_at`, so shift zero is still running and member one (`+15551234567`) is the target.
* **Round 0, step 1** — fires 300 seconds in, once step 0's acknowledgement window elapses. A `users` target pages a fixed member list — here an email address over the email channel.
* **Round 0, step 2** — the fallback human gets a voice call at offset 600.
* **Rounds 1** — `repeat: 1` loops the whole policy one extra time, so the same three steps fire again at offsets 900 / 1200 / 1500 if nobody has acknowledged by then. `total_pages` counts six rows: three steps × two rounds.

Every step's offset is the cumulative sum of the `escalate_after_seconds` values before it, and rotation targets re-resolve at each page's `fire_at` — a hand-off that lands mid-escalation pages whoever holds the pager at that instant.

## Drive an incident

`incident/tick` and `incident/transition` let you drive the full incident lifecycle against your own state store (you own persistence; these endpoints are stateless compute over the incident snapshot you pass in).

### Tick — which pages are due right now

Pass the incident snapshot and optionally the instant to evaluate (defaults to now). The response tells you which pages became due, the new `pages_fired` high-water mark to persist on your side, when to wake up and tick again (`next_tick_at`), and whether the incident is still paging, acknowledged, resolved, or has exhausted its escalation policy.

<RequestExample>
  <CodeGroup>
    ```bash cURL theme={null}
    curl -X POST https://api.orbit.devotel.io/api/v1/oncall/incident/tick \
      -H "X-API-Key: dv_live_sk_your_key_here" \
      -H "Content-Type: application/json" \
      -d '{
        "incident": {
          "id": "inc_123",
          "started_at": "2026-08-26T09:00:00Z",
          "status": "open",
          "pages_fired": 0,
          "policy": {
            "name": "Sev-1 platform",
            "repeat": 1,
            "steps": [
              {
                "target": { "type": "users", "members": ["+15551234567"] },
                "channels": ["push", "sms"],
                "escalate_after_seconds": 300
              },
              {
                "target": { "type": "users", "members": ["+15557654321"] },
                "channels": ["voice"],
                "escalate_after_seconds": 300
              }
            ]
          }
        },
        "at": "2026-08-26T09:05:30Z"
      }'
    ```

    ```python Python theme={null}
    import os, requests

    incident = {
        "id": "inc_123",
        "started_at": "2026-08-26T09:00:00Z",
        "status": "open",
        "pages_fired": 0,  # high-water mark you persisted after the last tick
        "policy": {
            "name": "Sev-1 platform",
            "repeat": 1,
            "steps": [
                {
                    "target": {"type": "users", "members": ["+15551234567"]},
                    "channels": ["push", "sms"],
                    "escalate_after_seconds": 300,
                },
                {
                    "target": {"type": "users", "members": ["+15557654321"]},
                    "channels": ["voice"],
                    "escalate_after_seconds": 300,
                },
            ],
        },
    }
    r = requests.post(
        "https://api.orbit.devotel.io/api/v1/oncall/incident/tick",
        headers={
            "X-API-Key": os.environ["ORBIT_API_KEY"],
            "Content-Type": "application/json",
        },
        json={"incident": incident, "at": "2026-08-26T09:05:30Z"},
    )
    for page in r.json()["data"]["due_pages"]:
        print(page["step"], page["fire_at"], page["targets"], page["channels"])
    ```

    ```go Go theme={null}
    package main

    import (
    	"bytes"
    	"fmt"
    	"net/http"
    	"os"
    )

    func main() {
    	body := []byte(`{
    	  "incident": {
    	    "id": "inc_123",
    	    "started_at": "2026-08-26T09:00:00Z",
    	    "status": "open",
    	    "pages_fired": 0,
    	    "policy": {
    	      "name": "Sev-1 platform",
    	      "repeat": 1,
    	      "steps": [
    	        {
    	          "target": { "type": "users", "members": ["+15551234567"] },
    	          "channels": ["push", "sms"],
    	          "escalate_after_seconds": 300
    	        },
    	        {
    	          "target": { "type": "users", "members": ["+15557654321"] },
    	          "channels": ["voice"],
    	          "escalate_after_seconds": 300
    	        }
    	      ]
    	    }
    	  },
    	  "at": "2026-08-26T09:05:30Z"
    	}`)
    	req, _ := http.NewRequest(
    		"POST",
    		"https://api.orbit.devotel.io/api/v1/oncall/incident/tick",
    		bytes.NewBuffer(body),
    	)
    	req.Header.Set("X-API-Key", os.Getenv("ORBIT_API_KEY"))
    	req.Header.Set("Content-Type", "application/json")
    	resp, _ := http.DefaultClient.Do(req)
    	fmt.Println(resp.Status)
    }
    ```
  </CodeGroup>
</RequestExample>

<ResponseExample>
  ```json 200 theme={null}
  {
    "data": {
      "incident_id": "inc_123",
      "at": "2026-08-26T09:05:30.000Z",
      "status": "paging",
      "exhausted": false,
      "pages_fired": 2,
      "total_pages": 4,
      "next_tick_at": "2026-08-26T09:10:00.000Z",
      "due_pages": [
        {
          "round": 0,
          "step": 0,
          "offset_seconds": 0,
          "fire_at": "2026-08-26T09:00:00.000Z",
          "targets": ["+15551234567"],
          "channels": ["push", "sms"]
        },
        {
          "round": 0,
          "step": 1,
          "offset_seconds": 300,
          "fire_at": "2026-08-26T09:05:00.000Z",
          "targets": ["+15557654321"],
          "channels": ["voice"]
        }
      ]
    },
    "meta": {
      "request_id": "req_9003",
      "timestamp": "2026-08-26T12:00:00.000Z"
    }
  }
  ```
</ResponseExample>

Read the rows against the incident snapshot you sent:

* `due_pages` holds every page whose `fire_at` has passed and that your persisted `pages_fired` mark had not covered — here pages 0 and 1 both became due because the first tick ran 5.5 minutes after the incident opened.
* `pages_fired: 2` is the new mark; persist it so the next tick resumes exactly where this one left off. `total_pages: 4` counts the two steps across two rounds (`repeat: 1`).
* `status: "paging"` with `exhausted: false` because two of four pages remain.
* `next_tick_at` is the only row your scheduler needs: wake up at 09:10 and tick again. It is the fire instant of the next unfired page — round 1, step 0.

Persist `pages_fired: 2` between ticks and a tick that lands before the next page's fire instant returns an empty `due_pages` and a `pages_fired` unchanged — nothing is paged twice under replay:

<RequestExample>
  <CodeGroup>
    ```bash cURL theme={null}
    curl -X POST https://api.orbit.devotel.io/api/v1/oncall/incident/tick \
      -H "X-API-Key: dv_live_sk_your_key_here" \
      -H "Content-Type: application/json" \
      -d '{
        "incident": {
          "id": "inc_123",
          "started_at": "2026-08-26T09:00:00Z",
          "status": "open",
          "pages_fired": 2,
          "policy": {
            "name": "Sev-1 platform",
            "repeat": 1,
            "steps": [
              {
                "target": { "type": "users", "members": ["+15551234567"] },
                "channels": ["push", "sms"],
                "escalate_after_seconds": 300
              },
              {
                "target": { "type": "users", "members": ["+15557654321"] },
                "channels": ["voice"],
                "escalate_after_seconds": 300
              }
            ]
          }
        },
        "at": "2026-08-26T09:07:00Z"
      }'
    ```

    ```python Python theme={null}
    import os, requests

    incident = {
        "id": "inc_123",
        "started_at": "2026-08-26T09:00:00Z",
        "status": "open",
        "pages_fired": 2,  # mark persisted from the previous tick
        "policy": {
            "name": "Sev-1 platform",
            "repeat": 1,
            "steps": [
                {
                    "target": {"type": "users", "members": ["+15551234567"]},
                    "channels": ["push", "sms"],
                    "escalate_after_seconds": 300,
                },
                {
                    "target": {"type": "users", "members": ["+15557654321"]},
                    "channels": ["voice"],
                    "escalate_after_seconds": 300,
                },
            ],
        },
    }
    r = requests.post(
        "https://api.orbit.devotel.io/api/v1/oncall/incident/tick",
        headers={
            "X-API-Key": os.environ["ORBIT_API_KEY"],
            "Content-Type": "application/json",
        },
        json={"incident": incident, "at": "2026-08-26T09:07:00Z"},
    )
    data = r.json()["data"]
    print(data["due_pages"])  # [] — nothing new is due yet
    ```

    ```go Go theme={null}
    package main

    import (
    	"bytes"
    	"encoding/json"
    	"fmt"
    	"net/http"
    	"os"
    )

    func main() {
    	body := []byte(`{
    	  "incident": {
    	    "id": "inc_123",
    	    "started_at": "2026-08-26T09:00:00Z",
    	    "status": "open",
    	    "pages_fired": 2,
    	    "policy": {
    	      "name": "Sev-1 platform",
    	      "repeat": 1,
    	      "steps": [
    	        {
    	          "target": { "type": "users", "members": ["+15551234567"] },
    	          "channels": ["push", "sms"],
    	          "escalate_after_seconds": 300
    	        },
    	        {
    	          "target": { "type": "users", "members": ["+15557654321"] },
    	          "channels": ["voice"],
    	          "escalate_after_seconds": 300
    	        }
    	      ]
    	    }
    	  },
    	  "at": "2026-08-26T09:07:00Z"
    	}`)
    	req, _ := http.NewRequest(
    		"POST",
    		"https://api.orbit.devotel.io/api/v1/oncall/incident/tick",
    		bytes.NewBuffer(body),
    	)
    	req.Header.Set("X-API-Key", os.Getenv("ORBIT_API_KEY"))
    	req.Header.Set("Content-Type", "application/json")
    	resp, _ := http.DefaultClient.Do(req)
    	var out struct {
    		Data struct {
    			DuePages []json.RawMessage `json:"due_pages"`
    		} `json:"data"`
    	}
    	json.NewDecoder(resp.Body).Decode(&out)
    	fmt.Println(len(out.Data.DuePages)) // 0 — nothing new is due yet
    }
    ```
  </CodeGroup>
</RequestExample>

<ResponseExample>
  ```json 200 theme={null}
  {
    "data": {
      "incident_id": "inc_123",
      "at": "2026-08-26T09:07:00.000Z",
      "status": "paging",
      "exhausted": false,
      "pages_fired": 2,
      "total_pages": 4,
      "next_tick_at": "2026-08-26T09:10:00.000Z",
      "due_pages": []
    },
    "meta": {
      "request_id": "req_9004",
      "timestamp": "2026-08-26T12:00:00.000Z"
    }
  }
  ```
</ResponseExample>

Once every page across every round has fired with no acknowledgement, the tick returns `status: "exhausted"`, `exhausted: true`, and `next_tick_at: null` — hand the incident to a human rather than keep a scheduler awake.

### Transition — acknowledge, resolve, or reassign

Apply a lifecycle action. `ack` is valid only from `open` and halts the escalation; `resolve` closes the incident from `open` or `acknowledged`; `reassign` swaps in a new policy and restarts its timeline (`status: "open"`, `pages_fired: 0`, `started_at` reset to the action instant).

The sequence below acknowledges, then resolves. Pass the snapshot you persisted — after step one that means `status: "acknowledged"` plus `acknowledged_at`:

<RequestExample>
  <CodeGroup>
    ```bash Step 1 — acknowledge (bash) theme={null}
    curl -X POST https://api.orbit.devotel.io/api/v1/oncall/incident/transition \
      -H "X-API-Key: dv_live_sk_your_key_here" \
      -H "X-Request-Id: transition-ack" \
      -H "Content-Type: application/json" \
      -d '{
        "incident": {
          "id": "inc_123",
          "started_at": "2026-08-26T09:00:00Z",
          "status": "open",
          "pages_fired": 2,
          "policy": {
            "name": "Sev-1 platform",
            "repeat": 1,
            "steps": [
              {
                "target": { "type": "users", "members": ["+15551234567"] },
                "channels": ["push", "sms"],
                "escalate_after_seconds": 300
              }
            ]
          }
        },
        "action": { "type": "ack", "by": "+15551234567" },
        "at": "2026-08-26T09:05:30Z"
      }'
    ```

    ```python Step 2 — resolve (python) theme={null}
    import os, requests

    acknowledged = {
        "id": "inc_123",
        "started_at": "2026-08-26T09:00:00Z",
        "status": "acknowledged",
        "acknowledged_at": "2026-08-26T09:05:30.000Z",
        "acknowledged_by": "+15551234567",
        "pages_fired": 2,
        "policy": {
            "name": "Sev-1 platform",
            "repeat": 1,
            "steps": [
                {
                    "target": {"type": "users", "members": ["+15551234567"]},
                    "channels": ["push", "sms"],
                    "escalate_after_seconds": 300,
                }
            ],
        },
    }
    r = requests.post(
        "https://api.orbit.devotel.io/api/v1/oncall/incident/transition",
        headers={
            "X-API-Key": os.environ["ORBIT_API_KEY"],
            "X-Request-Id": "transition-resolve",
            "Content-Type": "application/json",
        },
        json={
            "incident": acknowledged,
            "action": {"type": "resolve", "at": "2026-08-26T09:12:00Z"},
        },
    )
    print(r.json()["data"]["incident"]["status"])  # resolved
    ```

    ```go Step 3 — re-ack an acknowledged incident (go) theme={null}
    package main

    import (
    	"bytes"
    	"fmt"
    	"net/http"
    	"os"
    )

    func main() {
    	// The acknowledged snapshot from step one, acked a second time.
    	body := []byte(`{
    	  "incident": {
    	    "id": "inc_123",
    	    "started_at": "2026-08-26T09:00:00Z",
    	    "status": "acknowledged",
    	    "acknowledged_at": "2026-08-26T09:05:30.000Z",
    	    "pages_fired": 2,
    	    "policy": {
    	      "name": "Sev-1 platform",
    	      "repeat": 1,
    	      "steps": [
    	        {
    	          "target": { "type": "users", "members": ["+15551234567"] },
    	          "channels": ["push", "sms"],
    	          "escalate_after_seconds": 300
    	        }
    	      ]
    	    }
    	  },
    	  "action": { "type": "ack", "by": "+15551234567" }
    	}`)
    	req, _ := http.NewRequest(
    		"POST",
    		"https://api.orbit.devotel.io/api/v1/oncall/incident/transition",
    		bytes.NewBuffer(body),
    	)
    	req.Header.Set("X-API-Key", os.Getenv("ORBIT_API_KEY"))
    	req.Header.Set("X-Request-Id", "transition-reack")
    	req.Header.Set("Content-Type", "application/json")
    	resp, _ := http.DefaultClient.Do(req)
    	fmt.Println(resp.Status) // 409 — ack from acknowledged is rejected
    }
    ```
  </CodeGroup>
</RequestExample>

<ResponseExample>
  ```json 200 — ack succeeds theme={null}
  {
    "data": {
      "incident": {
        "id": "inc_123",
        "started_at": "2026-08-26T09:00:00Z",
        "status": "acknowledged",
        "pages_fired": 2,
        "acknowledged_at": "2026-08-26T09:05:30.000Z",
        "acknowledged_by": "+15551234567",
        "policy": {
          "name": "Sev-1 platform",
          "repeat": 1,
          "steps": [
            {
              "target": { "type": "users", "members": ["+15551234567"] },
              "channels": ["push", "sms"],
              "escalate_after_seconds": 300
            }
          ]
        }
      }
    },
    "meta": {
      "request_id": "transition-ack",
      "timestamp": "2026-08-26T12:00:00.000Z"
    }
  }
  ```
</ResponseExample>

<ResponseExample>
  ```json 200 — resolve succeeds theme={null}
  {
    "data": {
      "incident": {
        "id": "inc_123",
        "started_at": "2026-08-26T09:00:00Z",
        "status": "resolved",
        "pages_fired": 2,
        "acknowledged_at": "2026-08-26T09:05:30.000Z",
        "acknowledged_by": "+15551234567",
        "resolved_at": "2026-08-26T09:12:00.000Z",
        "policy": {
          "name": "Sev-1 platform",
          "repeat": 1,
          "steps": [
            {
              "target": { "type": "users", "members": ["+15551234567"] },
              "channels": ["push", "sms"],
              "escalate_after_seconds": 300
            }
          ]
        }
      }
    },
    "meta": {
      "request_id": "transition-resolve",
      "timestamp": "2026-08-26T12:00:00.000Z"
    }
  }
  ```
</ResponseExample>

Terminal-state enforcement rejects a transition the current status does not allow. Step three re-acks an already-acknowledged incident and gets a 409:

<ResponseExample>
  ```json 409 — re-ack rejected theme={null}
  {
    "error": {
      "code": "incident_transition_invalid",
      "message": "cannot acknowledge a acknowledged incident",
      "status": 409
    },
    "meta": {
      "request_id": "transition-reack",
      "timestamp": "2026-08-26T12:00:00.000Z"
    }
  }
  ```
</ResponseExample>

The same contract applies at the resolve boundary: acking a resolved incident returns "cannot acknowledge a resolved incident" and resolving one returns "incident is already resolved". Once a transition halts the incident, further ticks return `due_pages: []` and `next_tick_at: null`, and `reassign` is the only path back to paging — it swaps the policy, resets `pages_fired` to 0, and restarts the timeline at the `started_at` you pass.

<Note>
  The `meta.request_id` echoes the `X-Request-Id` header when you set one — send a stable id per step of your incident driver so the ack → resolve → rejected re-ack chain reads as one line in your logs.
</Note>

## Recipe — resolve, then page the on-call member

The typical loop: resolve the rotation, then send the page through Messaging. Four steps, none of which require Orbit to hold any state — your service owns the rotation definition and the incident snapshot.

<Steps>
  <Step title="Resolve the on-call member">
    Call `POST /api/v1/oncall/resolve` with your rotation definition. Read `on_call` from the response — that is the target to page (here, an E.164 number).
  </Step>

  <Step title="Send the page through Messaging">
    Pass the resolved member as `to` in a `POST /api/v1/messages/sms` send. Every send exits through the same Messaging pipeline as the rest of your traffic, so your sender ids, quiet-hours policy, and delivery webhooks all apply.

    ```bash cURL theme={null}
    curl -X POST "https://api.orbit.devotel.io/api/v1/messages/sms" \
      -H "X-API-Key: dv_live_sk_your_key_here" \
      -H "Content-Type: application/json" \
      -d '{
        "to": "+15551234567",
        "body": "Sev-1: checkout error rate above threshold. Ack in the incident console."
      }'
    ```

    ```python Python theme={null}
    import os, requests

    rotation = {
        "members": ["+15551234567", "usr_primary_03"],
        "cadence": "weekly",
        "anchor": "2026-08-24T00:00:00Z",
    }
    headers = {
        "X-API-Key": os.environ["ORBIT_API_KEY"],
        "Content-Type": "application/json",
    }
    resolved = requests.post(
        "https://api.orbit.devotel.io/api/v1/oncall/resolve",
        headers=headers,
        json={"rotation": rotation},
    ).json()["data"]
    requests.post(
        "https://api.orbit.devotel.io/api/v1/messages/sms",
        headers=headers,
        json={
            "to": resolved["on_call"],
            "body": "Sev-1: checkout error rate above threshold. "
                    "Ack in the incident console.",
        },
    )
    ```

    See [Send an SMS](/api-reference/endpoints/messaging) for the full request and response contract.
  </Step>

  <Step title="Tick on a schedule you control">
    While the incident stays open, call `POST /api/v1/oncall/incident/tick` whenever `next_tick_at` rolls around, persist the returned `pages_fired` mark on your side, and send each entry in `due_pages` through Messaging on the channels it names.
  </Step>

  <Step title="Stop the clock with a transition">
    When a human acks or resolves in your console, call `POST /api/v1/oncall/incident/transition` and store the returned incident snapshot. A halted incident returns `due_pages: []` and `next_tick_at: null` on the next tick, so your scheduler stands down.
  </Step>
</Steps>

<Note>
  These endpoints never persist anything and never send anything themselves — they are stateless compute over the request body. Rotation definitions, incident snapshots, and the `pages_fired` high-water mark live in **your** state store; every actual page exits through Messaging, where delivery status arrives on your configured webhooks.
</Note>

## See also

* [On-call alerting guide](/guides/oncall-alerting) — the end-to-end walkthrough from rotation to resolved incident
* [Messaging API](/api-reference/endpoints/messaging) — the send surface that actually pages a resolved target
* [Webhooks](/webhooks/overview) — subscribe to delivery status for a page you sent
