The Round Robin API
The public API reads and changes duty under /v1. Use it to connect an integration to Round Robin, or to submit a decision for a signed-in Slack member.
You need: the credential the operation asks for. Most operations take a team API key. Participant actions take a Round Robin access token for a signed-in Slack member.
Make your first call
Section titled “Make your first call”Create an integration key under Settings → API keys. Call GET /v1/keys/self with the key in the bearer header to check its workspace, scopes and rotation access.
The production API is https://api.roundrobinbot.eu. Use credentials issued for that environment. See authentication.
Find a rotation
Section titled “Find a rotation”GET /v1/rotations lists the rotations the key can reach, a page at a time. Narrow it with q (part of the name), enabled or mode.
To find the rotation behind a short code such as PAYMENTS, pass code. It matches the whole code, in any case, so at most one rotation comes back:
GET /v1/rotations?code=paymentsAn empty data means no rotation the key can reach has that code. A code that is empty or blank is ignored, and the whole list comes back.
Ask who is on duty
Section titled “Ask who is on duty”Call GET /v1/rotations/{rotationId}/on-call. Each entry in onCall is one person on duty, with the lane and window they hold, when their cover stops and when their turn ends. When onCall is empty, nobodyOnCallReason says why. nextOnCall says who takes each lane next, in each window. Add expand=users for the directory details of each person named.
GET /v1/rotations/{rotationId} carries the same answer under onCall, so a rotation read needs no second call.
When a response returns an ETag, keep it for supported conditional reads or writes. See conventions.
When duty ends
Section titled “When duty ends”An entry carries two end times, because they answer two questions.
| Field | What it says | Null when |
|---|---|---|
coveredUntil |
When this person stops covering. That is the close of the window they are in, or the end of their turn if it comes first. An absence or a direct assignment can bring it earlier. Read it to say until when somebody can be reached. | The window is open around the clock and the turn has no end. On a holder in cells, also while they are not covering. |
turnEnds |
When their turn ends: the next hand-over, when the duty passes to somebody else. A window closing does not move it. Read it for a reminder before a shift ends, or to say when the next person takes over. | Nothing hands the duty over, as in a window with the Manual schedule type. |
Both are UTC, and both are exclusive: at that instant the cover, or the turn, has already ended.
The two differ when the window closes before the hand-over. Take a window open 09:00 to 17:00 with a hand-over at 21:00. During the day, the entry reads:
{ "userId": "U024BE7LH", "coveredUntil": "2026-10-05T17:00:00+00:00", "turnEnds": "2026-10-05T21:00:00+00:00"}At 17:00 the window closes and the person leaves onCall. Nobody in that window is on duty until it opens again. If every other window is shut as well, nobodyOnCallReason reads nobody-on-call-as-arranged.
When nobody is on duty
Section titled “When nobody is on duty”Read nobodyOnCallReason to tell a quiet hour from a gap that needs a person.
| Value | What it means | What to do |
|---|---|---|
null |
Somebody is on duty. | Nothing. |
nobody-on-call-unplanned |
A window is open and nobody holds it. | Put somebody on duty in that window. |
nobody-on-call-as-arranged |
Every window is shut, as on nights and weekends. | Nothing. Cover comes back when a window opens: each window’s nextCoverage.startUtc says when. |
null means at least one person is on duty somewhere in the rotation. It stays null when one lane has nobody while another lane is covered, so it does not report one empty lane in one window: read cells for that.
An empty onCall with a null reason is a partner responder with no Slack account. Read externalAssignments or unmappedExternalDuty to see who it is.
When the partner schedule has no answer
Section titled “When the partner schedule has no answer”A rotation linked to PagerDuty or Jira Service Management always carries externalAssignments. When Round Robin has no usable answer from the provider, externalAssignments.noAnswerReason says why. Nobody is on duty in the rotation then: onCall and externalAssignments.current are empty.
| Value | What it means | What to do |
|---|---|---|
null |
The provider’s schedule answers. | Read externalAssignments.current. |
switchedOff |
The rotation is switched off (enabled is false). |
Enable the rotation. |
bindingMissing |
The rotation has no link to a provider schedule. | Link the schedule again in the dashboard. |
notReadYet |
Round Robin has not read the schedule since it was linked. | Ask again in a minute. If it stays, check the provider connection in the dashboard. |
snapshotNotCurrent |
The last read of the schedule does not cover asOfUtc. |
Check the provider connection in the dashboard, and that the schedule still exists at the provider. |
nobodyOnCallReason reads nobody-on-call-as-arranged for switchedOff, and nobody-on-call-unplanned for the other three. The dashboard and Slack show the same four as Rotation disabled, Schedule not linked, Schedule not read yet and Schedule data out of date: see When the provider has no answer.
A stopped lane in a window counts for neither value.
The same two values are the reason on the nobody.on_call webhook and on a gap in a rotation’s history.
Who is next
Section titled “Who is next”nextOnCall has one entry for each running lane in each window: the person who takes it next. Entries are ordered by since, earliest first. A tie goes in window order, then lane order.
Each entry names the lane and window with cellId, laneId, laneName, windowId and windowName, like an onCall entry, and the person with userId and turn.
| Field | What it says | Null when |
|---|---|---|
since |
When the person goes on duty there, in UTC: the window’s next hand-over, or the moment a shut window opens where the same person is coming back. | The window has the Manual schedule type. |
turnEnds |
When their turn ends, in UTC. | The window has no hand-over after that. |
comingBack |
true when the person already holding it resumes as a shut window opens, false when a new person is dealt. Read since as a hand-over only where this is false. |
Never. |
Absences are not taken into account. Somebody who is away at the hand-over is skipped then, so the person who takes over can differ from the entry.
In a window with the Manual schedule type, an entry names who a rotate of the whole rotation would put on duty. A lane that rotate would leave as it is, because nobody on its list is free, has no entry. It is listed in seatsWithNobodyFree instead, with its cellId, laneId, laneName, windowId and windowName. The person on duty keeps it. See Who a rotate puts on duty.
nextOnCall is empty when:
- the rotation has Random order on, because the person is drawn at the hand-over;
- the rotation is switched off (
enabledisfalse); - the rotation follows a partner schedule. Read
externalAssignments.nextChangeinstead.
Webhook bodies leave out nextOnCall and seatsWithNobodyFree. Call this endpoint when you need them.
What you can read
Section titled “What you can read”| Task | Endpoint |
|---|---|
| Find rotations | GET /v1/rotations |
| Read a rotation | GET /v1/rotations/{rotationId} |
| Read projected duty | GET /v1/rotations/{rotationId}/schedule |
| Read one person’s shifts | GET /v1/users/{userId}/shifts |
| Read direct assignments | GET /v1/rotations/{rotationId}/duty-arrangements |
| Read cover requests | GET /v1/rotations/{rotationId}/cover-requests |
| Read swap requests | GET /v1/rotations/{rotationId}/swap-requests |
| Find duty for a swap | GET /v1/rotations/{rotationId}/swap-requests/candidates |
Supply the range and pagination parameters each operation requires. Read every page before concluding that somebody has no duty.
What you can change
Section titled “What you can change”| Task | Access |
|---|---|
| Rotate or set on duty | Team API key with write:duty |
| Create, change or restore a direct assignment | Team API key with write:duty |
| Create or configure rotations and coverage | Team API key with write:rotation |
| Change who is on a rotation’s lists | Team API key with write:rotation |
| Manage rotation availability | Team API key with write:rotation |
| Propose or respond to cover and swaps | Authenticated member with the required participant permission |
POST /v1/rotations/{rotationId}/rotate rotates the whole rotation, as Rotate now does: see Who a rotate puts on duty. A rotate that moves nobody answers 200 with the current state and the same ETag you sent, so you can keep using it. A stale If-Match still gets 412.
Read or preview the current state before submitting. Most writes to an existing rotation require If-Match; duty actions also use an operation identifier for retries. See conventions and participant duty operations.
For direct assignments, use the duty-arrangements/preview operation before creating a replacement. Read {arrangementId}/restore-preview before ending one. Ending it hands the duty back by the current schedule and availability, so the original person does not always return. Each arrangement’s phase and changesAtUtc, and the actionable flag on each lane in a window, are described in Read where a request stands.
Read the past
Section titled “Read the past”GET /v1/rotations/{rotationId}/history is the record of who actually held duty. For duty still to come, read schedule.
A row with no user is not always a gap. A gap nobody covered carries reason: nobody-on-call-unplanned or nobody-on-call-as-arranged, with the same meaning as on the /on-call response.
Read one person’s shifts
Section titled “Read one person’s shifts”GET /v1/users/{userId}/shifts lists a Slack member’s duty across the rotations the key can read, recorded and projected together. It needs read:oncall. Request a range of at most 31 days, and read every page.
Each row is one interval. source says which kind it is: history for recorded duty, projected for duty to come. Each rotation in rotations has its own asOfUtc, the instant that separates the two.
A turn on a window with hours is several rows, one per opening. Two fields join them back into turns:
| Field | What it says |
|---|---|
turn |
The person’s position in the list that lane and window deal from. It is a position, not a counter: in a three-person weekly rotation, turn 0 comes round again three weeks later. |
onDutySinceIsDutyShift |
true when the row begins at the start of the person’s turn. The rows after it begin because cover opened. It is false on a row that begins at the rotation’s asOfUtc, so a turn already under way claims no start. |
To rebuild turns, group rows by cellId, userId and turn, and start a new group at each row where onDutySinceIsDutyShift is true.
Each row also says where it stands at its rotation’s asOfUtc, so you never compare its times with your own clock:
| Field | What it says |
|---|---|
phase |
upcoming, running or ended. A row with no end never ends. A history row is always ended. |
changesAtUtc |
When phase next changes: the start while upcoming, the end while running. Absent when it will not change. |
Check each rotation’s completion and limitationCodes before concluding a person has no duty. The codes are listed in conventions.
Read who changed what
Section titled “Read who changed what”GET /v1/rotations/{rotationId}/activity lists recorded changes to a rotation and who made each one. Narrow it with its date range.
Lanes, windows and lists
Section titled “Lanes, windows and lists”A lane is one person on duty at a time. A coverage window sets the hours and time zone. One lane in one window is the smallest thing one person is on duty for. GET /v1/rotations/{rotationId} lists each of them as an entry in cells, with its laneId and windowId.
An entry somebody holds carries a holder, with the same coveredUntil and turnEnds as an onCall entry. The holder keeps it while the window is shut, with waiting set to true and coveredUntil null. They are not on duty then. They come back when the window opens, unless their turn ends first, and the window’s nextCoverage.startUtc says when that is. So read onCall to know who is on duty now, and cells to know who holds each lane in each window.
An entry with stopped set to true is defined but not running, because the workspace is not on a paid plan. It holds nobody.
Each entry deals from one of the rotation’s lists. lists holds every list, with its listId, its name and its turn order, and each entry in cells carries the listId it deals from. Schedule entries carry it too. name is null when the list could not be read. To change who is on a list, see change who is on a list.
A rotation that follows a provider
Section titled “A rotation that follows a provider”A rotation linked to a PagerDuty or Jira Service Management schedule takes its hours from the provider, and publishes none of its own:
- each window has an empty
coverage,alwaysOpenset tofalse, and a nullname; - each lane has a null
name; onCallentries carry nolaneNameorwindowName.
Read externalAssignments for who the provider has assigned and when that changes. See external rotations.
Change the grid
Section titled “Change the grid”PUT /v1/rotations/{rotationId}/coverage replaces the rotation’s lanes, windows and cells with the ones you send. It removes any lane or window the body leaves out, along with whoever held it. So read the rotation first, make your change to what it returned, and send the whole grid back with its ETag in If-Match.
Every lane and window needs an id. For one you are adding, use any string of your own, and use the same string in its entries in cells.
To add a window, send one entry in cells for each lane in it. Or send the window with a listId and no entries in cells, and every lane gets an entry in the window, each dealing from that list. In windows:
{ "id": "tokyo", "timeZone": "Asia/Tokyo", "listId": "68b0c3e1f2a4d5b6c7e8f901" }listId works only on a window you are adding. Any entries in cells you do send for that window are kept as you sent them.
Every listId, in cells or on a window, must be one of this rotation’s own lists. A list another lane or window of the rotation already deals from is accepted too.
On an enabled rotation, a lane in a window that the replacement leaves empty is dealt at once, so a lane you add has somebody on it when the response comes back. A switched-off rotation (enabled is false) accepts the same replacement and puts nobody on duty: every lane stays empty in every window until the rotation is switched on, and then fills the way switching it back on describes.
| Refusal | title |
What to do |
|---|---|---|
400 invalid_request |
The grid is not dense |
Send one entry in cells for every lane in every window, or a listId on each window you add. The detail gives the count expected and the count sent. |
400 invalid_request |
A window the rotation already has takes no listId |
Remove listId from that window. It keeps the lists it has. |
400 invalid_request |
A lane in a window names a list that is not this rotation's |
Use a listId from the rotation’s lists or from its cells. |
400 invalid_request |
A lane in a window names two different lists or A window names two different lists |
The entry in cells, or the window, carries both listId and rosterId, with different values. Send listId alone. |
400 invalid_request |
A window's hours are not valid or A window's cadence is out of range |
Correct the window the detail names. |
402 payment_required |
Send a smaller grid, or check the workspace plan. A workspace that changed plan keeps editing the grid it already has. | |
409 external_rotation_is_read_only |
The rotation follows a PagerDuty or Jira Service Management schedule. Change the schedule at the provider. | |
412 precondition_failed |
The rotation changed since you read it. Read it again and send its new ETag in If-Match. |
Change who is on a list
Section titled “Change who is on a list”PATCH /v1/rotations/{rotationId}/lists/{listId} changes one of the rotation’s lists. Take the listId from the rotation’s lists. It needs a key with write:rotation.
People are written per list. PATCH /v1/rotations/{rotationId} has no member fields, and a userIds sent to it is ignored, like any property an endpoint does not take.
Send any of these, and send the rotation’s ETag in If-Match:
| Field | What it does |
|---|---|
name |
Renames the list. |
userIds |
Replaces the people named on the list. People who stay keep their place in the turn order, and newcomers join after them in the order you send. Nobody may be named twice. People on the list through a user group are not affected. |
userGroupIds |
Replaces the Slack user groups the list draws on. [] takes the list off every group. |
A field you leave out keeps what it had, and the rotation’s other lists stay as they are. The response is the rotation, with a new ETag.
PATCH /v1/rotations/{rotationId}/lists/68b0c3e1f2a4d5b6c7e8f901If-Match: "…"Content-Type: application/json
{ "userIds": ["U024BE7LH", "U061F7AUR"] }| Refusal | title |
What to do |
|---|---|---|
400 invalid_request |
Nothing to change |
Send at least one of name, userIds and userGroupIds. |
400 invalid_request |
Invalid request |
Check userIds for a person named twice, and remove the repeat. Nothing was changed. |
402 payment_required |
This workspace's plan has no room for more people in its rotations |
Name fewer people, take somebody off another rotation, or upgrade the workspace. Nothing was changed, and your ETag is still current. |
403 scope_denied |
Use a key with write:rotation. |
|
404 |
The rotation has no list with that listId. Read the rotation again and use an id from its lists. |
|
409 rotationMemberDoesNotExist |
Somebody named could not be found |
Somebody in userIds is not in the workspace. The detail lists the IDs that were not found: remove or correct them. Nothing was changed. |
409 external_rotation_is_read_only |
An external rotation is read-only here |
The rotation follows a PagerDuty or Jira Service Management schedule. Change who is on it at the provider. |
412 precondition_failed |
The rotation changed since you read it. Read it again and send its new ETag in If-Match. |
|
428 precondition_required |
Send If-Match with the rotation’s ETag. |
Say who is away
Section titled “Say who is away”POST /v1/rotations/{rotationId}/availability records an absence for a member. Check the person, the dates and appliesTo before you send it.
To see what a proposed absence would change first, use the availability impact preview. It saves nothing. Direct assignments, accepted cover and agreed swaps stay in place after an absence: review them separately.
Versions
Section titled “Versions”Build only on the /v1 routes in the API reference. Other routes serve the dashboard and are not part of the public API.
Next steps
Section titled “Next steps”- Authentication: select and send a credential.
- Scopes and access: restrict an integration key.
- Participant duty operations: preview and submit member decisions.
- API reference: operation parameters and response schemas.
