Skip to content

Outbound webhooks

ProThis feature needs a Pro plan. On Free the settings are visible but locked.

A webhook endpoint is a URL of yours that Round Robin calls when something happens on a rotation. Instead of asking GET /v1/rotations/{rotationId}/on-call every minute to find out whether the shift moved, your server is told, within seconds, and the body carries the rotation’s whole state so there is usually nothing left to fetch.

An endpoint belongs to the workspace, not to a rotation: it receives events from every rotation in the workspace, including rotations created later. The only thing it filters on is the list of events it subscribes to. The test event arrives whatever that list says.

You need: admin or owner rights in the workspace, and a URL that accepts POST over HTTPS from the public internet. Anybody who is admin or owner can see the workspace’s endpoints; changing one needs the Pro plan.

Open Settings → Webhooks and choose Add endpoint.

A URL. HTTPS only, and it has to resolve to a public address: a private, loopback, link-local or cloud-metadata address is refused when you register it, and checked again on every send because DNS can be re-pointed afterwards.

A name. How the endpoint is listed for you, up to 100 characters. Your server never sees it, so name the receiving system (“Incident bridge”, “Status page”).

Which events. Pick at least one of the five. An endpoint subscribed to nothing receives nothing, a test event aside.

Then the signing secret appears, once. It starts with rr_whsec_ and it is the only thing that tells your server a request really came from us, so copy it into your secret store before you close the dialog. Nothing shows it again: if you lose it, rotate it.

Then prove the wiring without waiting for anything to happen on a rotation. Send a test event posts to the endpoint there and then, and the delivery appears in the endpoint’s delivery log with whatever your server answered. See the test event for what it sends.

A workspace can hold ten endpoints at a time. Give each receiving system its own, so one of them can be stopped without taking the others down.

An endpoint is a /v1 resource as well as a dashboard screen, so an integration that creates rotations can subscribe to them without anybody opening a browser. GET /v1/webhooks lists them, POST /v1/webhooks registers one and answers with the signing secret once, PUT /v1/webhooks/{webhookId} replaces its name, URL and subscriptions together, DELETE removes it, and /disable, /enable and /rotate do what their names say. The API reference has the shapes.

It is the same endpoint the dashboard manages, so either can change what the other registered. Two things to know before you write the integration:

  • The key needs the manage:webhook scope, and only an administrator can mint a key holding it. A rotation selector does not narrow this surface, because an endpoint names no rotation.
  • Reads work on any plan; every write answers 402 on a workspace that is not on Pro. Every write against an endpoint that already exists takes an If-Match carrying the ETag from your last read of it, like every other /v1 write. Registering one takes no precondition, because there is no prior version to match.

Every delivery is a POST with a Content-Type: application/json body and two headers:

POST /hooks/roundrobin HTTP/1.1
Content-Type: application/json
Round-Robin-Timestamp: 1789542131
Round-Robin-Signature: v1=3f2b1d9c8a7e6f5d4c3b2a1908f7e6d5c4b3a2918f7e6d5c4b3a2918f7e6d5c4

Answer with any 2xx as soon as you have the body safely queued. You have ten seconds to answer, and the contract is that you answer, not that you finish your own work first: do the work after you have replied.

The body carries these members.

Member Sent
deliveryId Always. 32 hexadecimal characters identifying this delivery. Every retry of the same delivery repeats it, so it is what you deduplicate on
eventType Always. One of the five wire strings above, or webhook.test on a test send, and what you route on
teamId Always. The Slack workspace the event happened in, which matters when the same URL is registered in two workspaces
rotationId Always, rotation.deleted included, where it is the only thing that says which rotation went
version Except on rotation.deleted. The rotation’s version counter, which rises on every write: compare two deliveries with it and discard the older state when they arrive out of order. It is not the ETag of GET /v1/rotations/{rotationId} and cannot be used as an If-Match
timestamp Always. When the event became this delivery, which stays the same across every retry. The instant of an individual attempt is in Round-Robin-Timestamp
rotation Except on rotation.deleted. The rotation exactly as GET /v1/rotations/{rotationId} answers it, read at timestamp
self Except on rotation.deleted. The /v1 resource that state came from, ready to GET with one of your API keys
cause On duty.changed, where the shape of the change is known
reason On nobody.on_call
coverResumesAt On nobody.on_call, where something says when cover resumes

A member that has no value is absent, not null. A rotation.deleted body contains no rotation key at all, rather than "rotation": null, and the same goes for every member above that is not always sent. Check for presence, and treat a missing key as the fact that there is nothing to report.

Route on eventType, and accept one you do not recognise rather than rejecting it.

The rotation object is the full public API rotation resource, which is long, so it is abbreviated in these examples with …. Its fields are the ones the API reference documents.

duty.changed:

{
"deliveryId": "b41f7c0a9e2d48c6ab13f5e7d9c02b84",
"eventType": "duty.changed",
"teamId": "T024BE7LD",
"rotationId": "664f1c9ab3d24e0f8a7b2c11",
"version": 42,
"timestamp": "2026-09-18T09:00:04+00:00",
"cause": "scheduled",
"rotation": {
"id": "664f1c9ab3d24e0f8a7b2c11",
"name": "Payments",
"enabled": true,
"onCall": {
"rotationId": "664f1c9ab3d24e0f8a7b2c11",
"onCall": [
{
"userId": "U024BE7LH",
"turn": 3,
"laneName": "Primary",
"windowName": "Rome",
"since": "2026-09-18T09:00:00+00:00",
"until": "2026-09-25T09:00:00+00:00"
}
]
},
"…": "the rest of the rotation resource"
},
"self": "https://api.roundrobinbot.eu/v1/rotations/664f1c9ab3d24e0f8a7b2c11"
}

nobody.on_call, here for a rotation whose window has closed for the evening:

{
"deliveryId": "2c8e5a41b7d94a03a6e1c2d5b8f70a93",
"eventType": "nobody.on_call",
"teamId": "T024BE7LD",
"rotationId": "664f1c9ab3d24e0f8a7b2c11",
"version": 42,
"timestamp": "2026-09-18T18:00:02+00:00",
"reason": "nobody-on-call-as-arranged",
"coverResumesAt": "2026-09-19T09:00:00+00:00",
"rotation": { "id": "664f1c9ab3d24e0f8a7b2c11", "name": "Payments", "…": "the rest of the rotation resource" },
"self": "https://api.roundrobinbot.eu/v1/rotations/664f1c9ab3d24e0f8a7b2c11"
}

rotation.created:

{
"deliveryId": "9d1a4f7b2c6e08a35d9b1c4f7a2e6d08",
"eventType": "rotation.created",
"teamId": "T024BE7LD",
"rotationId": "66a02b7de1f34c0a9b8d5e22",
"version": 1,
"timestamp": "2026-09-18T11:24:39+00:00",
"rotation": { "id": "66a02b7de1f34c0a9b8d5e22", "name": "Search", "…": "the rest of the rotation resource" },
"self": "https://api.roundrobinbot.eu/v1/rotations/66a02b7de1f34c0a9b8d5e22"
}

rotation.updated:

{
"deliveryId": "51c9e3a8d0b74f26a1e8c3d5079b2f64",
"eventType": "rotation.updated",
"teamId": "T024BE7LD",
"rotationId": "664f1c9ab3d24e0f8a7b2c11",
"version": 43,
"timestamp": "2026-09-18T14:02:57+00:00",
"rotation": { "id": "664f1c9ab3d24e0f8a7b2c11", "name": "Payments and billing", "…": "the rest of the rotation resource" },
"self": "https://api.roundrobinbot.eu/v1/rotations/664f1c9ab3d24e0f8a7b2c11"
}

rotation.deleted, which carries no version, no rotation and no self, because there is nothing left to read:

{
"deliveryId": "0a7fd2e91c4b86539d2a7f1e4c8b0d36",
"eventType": "rotation.deleted",
"teamId": "T024BE7LD",
"rotationId": "66a02b7de1f34c0a9b8d5e22",
"timestamp": "2026-09-18T16:41:08+00:00"
}

Send a test event on the endpoint posts one delivery with eventType: "webhook.test". It is a sixth event type in the log and in a body, and it is the one type no endpoint can subscribe to: asking for the send is the subscription. So write your receiver to route on eventType and to pass a type it does not know straight through, or your test send will be rejected by your own code.

The body is the same shape as the rest, with the state of a real rotation of your workspace: the one somebody touched most recently, disabled or not. It carries rotation, self and version, and no cause. That is the point of it, because a synthetic sample would prove only that your server answers 2xx to any POST.

Three refusals, all before anything is sent:

  • The endpoint is switched off. Start it again first, or the send would be dropped at the moment it was due.
  • The workspace has no rotation to describe. A test send carries the state of a real one, so create a rotation first.
  • Your workspace is not on Pro. A test send makes us call your server, so it is a write like the rest.

It is a dashboard action, and there is no /v1 call for it. Read the outcome in the delivery log rather than from the response.

cause is scheduled or manual, and it says which shape of change this was, not whether a human was involved. Two cases will surprise you if you read it as “somebody did this”:

  • An automated Slack user-group sync that takes somebody off a rotation reads manual, because it is a change to the rotation’s membership and nothing scheduled it.
  • Cover resuming after somebody edited the rotation reads scheduled, because the fact being reported is the coverage window opening.

Branch on manual by all means, and be careful about paging on it.

scheduled covers a hand-over falling due, a coverage window opening or closing, and a partner’s own schedule moving under an external rotation. manual covers a rotate by hand, a person put on duty explicitly, duty cleared, a member removed, coverage edited, and a mention acknowledged.

Where the shape of a duty change cannot be classified, cause is absent rather than guessed. Treat it as “not known”, and the safe reading is usually to do whatever you do for scheduled: the alternative is paging somebody on a change nobody made.

nobody.on_call fires whenever a change leaves nobody on duty, and a rotation going quiet exactly as it was configured to is one of those. reason is what separates the two:

  • nobody-on-call-unplanned: somebody should have been on duty and nobody is. This is the one to act on.
  • nobody-on-call-as-arranged: no coverage window covers this hour, so the rotation is quiet by its own configuration.

Route on reason, or an endpoint subscribed to this event pages somebody every evening. They are the same two values a rotation’s own duty history uses.

coverResumesAt is the next time a coverage window opens, where that is known. It is absent when nothing says, which is typically the unplanned case: an empty list with no reopening scheduled.

Every delivery carries two headers:

  • Round-Robin-Timestamp: the time we sent this attempt, as decimal Unix seconds, not milliseconds.
  • Round-Robin-Signature: one or more v1=<digest> elements, joined by a comma with no space. The digest is HMAC-SHA256 of timestamp + "." + rawBody, keyed on your signing secret, as lowercase hexadecimal.

Five rules. Skip any one of them and verification fails for a reason your own logs cannot show you.

  1. HMAC the raw request body bytes, exactly as they arrived. Never a body you parsed and serialized again: re-serializing changes key order, spacing and number formatting, so the digest will not match and nothing in the request tells you why. This is by far the most common mistake.
  2. Split Round-Robin-Signature on ,.
  3. Expect the same v1= scheme more than once. During a secret rotation both live secrets sign the same request and both signatures travel in this one header, current first. A parser that reads the header into a dictionary keyed on the scheme loses one of them and starts refusing valid requests.
  4. Accept the request if any element matches. No element says which secret produced it, and position means nothing beyond current-first.
  5. Compare in constant time, with your language’s own function for it, not ==.

Then reject a timestamp too far from your own clock. Five minutes is a good tolerance, and it is yours to choose rather than ours to enforce. A delivery retried six hours later is signed at the moment it is sent, so every attempt carries a fresh timestamp, and the timestamp is inside the HMAC, so nobody can edit it in flight.

import crypto from "node:crypto";
import express from "express";
const app = express();
const secret = process.env.ROUND_ROBIN_WEBHOOK_SECRET;
const toleranceSeconds = 300;
// express.raw, never express.json: the signature is over the bytes as they arrived.
app.post("/hooks/roundrobin", express.raw({ type: "application/json" }), (req, res) => {
const timestamp = req.get("Round-Robin-Timestamp");
const header = req.get("Round-Robin-Signature");
if (!timestamp || !header) return res.status(400).end();
const age = Math.abs(Math.floor(Date.now() / 1000) - Number(timestamp));
if (!Number.isFinite(age) || age > toleranceSeconds) return res.status(400).end();
const expected = crypto
.createHmac("sha256", secret)
.update(Buffer.concat([Buffer.from(`${timestamp}.`), req.body]))
.digest("hex");
const accepted = header.split(",").some((element) => {
if (!element.startsWith("v1=")) return false;
const candidate = Buffer.from(element.slice(3), "hex");
const mine = Buffer.from(expected, "hex");
return candidate.length === mine.length && crypto.timingSafeEqual(candidate, mine);
});
if (!accepted) return res.status(401).end();
const event = JSON.parse(req.body.toString("utf8"));
res.status(202).end();
handle(event); // after answering: you have ten seconds to reply, not to finish
});
import hashlib
import hmac
import json
import os
import time
from flask import Flask, request
app = Flask(__name__)
SECRET = os.environ["ROUND_ROBIN_WEBHOOK_SECRET"].encode()
TOLERANCE_SECONDS = 300
@app.post("/hooks/roundrobin")
def receive():
timestamp = request.headers.get("Round-Robin-Timestamp", "")
header = request.headers.get("Round-Robin-Signature", "")
if not timestamp or not header:
return "", 400
if abs(int(time.time()) - int(timestamp)) > TOLERANCE_SECONDS:
return "", 400
# request.get_data(), never request.get_json(): the signature is over the bytes as they arrived.
body = request.get_data()
expected = hmac.new(SECRET, f"{timestamp}.".encode() + body, hashlib.sha256).hexdigest()
elements = [e[len("v1="):] for e in header.split(",") if e.startswith("v1=")]
if not any(hmac.compare_digest(expected, e) for e in elements):
return "", 401
event = json.loads(body)
enqueue(event) # queue it, then answer: you have ten seconds to reply, not to finish
return "", 202

Rotate secret on the endpoint issues a new secret and shows it once. The endpoint keeps its id, its URL, its events and its delivery history.

The outgoing secret keeps signing for seven days, and during that week both signatures ride in the one Round-Robin-Signature header, current first. A receiver that accepts any matching element, as above, cuts over whenever it suits you: deploy the new secret at your own pace and nothing fails in between. When the week is up the old signature stops being sent.

Rotate whenever the secret may have leaked, and whenever somebody who held it leaves.

There is no event-type header and no delivery-id header. The two signature headers are the only ones we promise. Route on eventType and deduplicate on deliveryId, both read from the body after you have verified it.

Credentials in the URL are not honoured. A URL registered as https://user:pass@host/ has its userinfo removed, and we dial https://host/. Your server sees no basic auth, so an endpoint relying on it answers 401 to every delivery for no reason its own logs explain. Authenticate the call with the signature, or put a token in the path or the query string.

Redirects are never followed. A 3xx ends the delivery: register the final URL.

Each delivery has its own retry ladder, separate from every other delivery and every other endpoint.

A failed attempt is tried again after 5 seconds and 20 seconds. If it is still failing, the delivery is rescheduled after 1 minute, 5 minutes, 30 minutes, 2 hours and 6 hours. That spreads a delivery over roughly nine hours, so a receiver that is down for a working day still gets the event. When the ladder runs out the delivery is abandoned, and the log says so.

A round is one send and the two quick retries that follow it. The ladder is six rounds: the first send, then one round for each rescheduled send, so a rescheduled send gets the same two quick retries. The sixth round is a single send, because nothing follows it. The API publishes totalRounds, so your own code never has to hold the number six.

What counts as failing:

  • A 5xx, a 408 or a 429 is retried.
  • Any other 4xx is not retried. It says you rejected the body, and waiting does not change that.
  • A 3xx is not retried either, because following it would move a signed body to a host we never checked.
  • A connection failure, a TLS failure or a timeout is retried.

Retry-After is honoured on a 429 and on a 503. Ask for longer than the next step of the ladder would have waited and we wait what you asked for, up to six hours, which is the longest step the ladder has. Ask for less and the request is ignored, and the step’s own wait stands. Both forms of the header are read, a number of seconds and an HTTP date, and what you asked for is recorded on the attempt. An honoured Retry-After carries the delivery straight to the next round, so the rest of the current round’s quick retries are not made.

Three abandoned deliveries inside 24 hours stop the endpoint. It moves to Stopped: too many failures on Settings → Webhooks, nothing more is sent to it, and events that happen while it is stopped are not kept for it. Fix the receiver, then choose Start sending again.

An endpoint is also stopped when an admin switches it off, and left dormant when the workspace drops to Free. The row says which.

Open an endpoint on Settings → Webhooks and Deliveries is the record of what we sent it, newest first, for 30 days. Nothing inside those 30 days is sampled or thrown away.

One row is one delivery, however many attempts it took. A delivery your server refused twice and then accepted is a single row reading delivered, with 3 under Attempts. Each row carries the event, the rotation, when the delivery was first tried, and the newest attempt’s outcome, status code and duration. A delivery still climbing the ladder reads Waiting instead of an outcome, because a later attempt can still get through.

Every one of those describes the whole delivery, not the period you are looking at: the time is its real first attempt, Attempts is its real count, and the outcome is where it ended up.

Open a row for the attempt history: every attempt of that delivery, oldest first, with the exact body and headers that attempt sent under Body we sent and Headers we sent. This is the part that explains an event which arrived six hours after it happened.

Four counters sit above the filters and describe everything the filters match, not the page on screen: Attempts delivered, Attempts failed, Deliveries waiting and Typical answer. Two of them count attempts and one counts deliveries, so they are not parts of one total. Typical answer is the median time your server took over the attempts it answered at all.

Delete an endpoint and its log goes with it, because the log is read through the endpoint. Export first if somebody still needs it.

The delivery panel shows What your server answered for each attempt that got an answer: up to 512 bytes of your response body, as text. Longer answers are cut, and the panel says “This is a cut piece of a longer answer, not the whole of it.” An answer with no body at all says so, and so does a row where nothing was sent. Where you asked for a Retry-After on a 429 or a 503, the panel says how long you asked for.

Over the API the same three facts are responseBodySnippet, responseBodyTruncated and retryAfterSecondsRequested on GET /v1/webhooks/{webhookId}/deliveries/{deliveryId}/attempts. The JSON export carries them too.

A row shows the outcome of the delivery’s newest attempt. An attempt ends in one of ten.

Outcome What it means
delivered Your server answered 2xx. The delivery is done
failed Your server answered, and not with success: a 5xx, a 408 or a 429. A later attempt of the same delivery may still be delivered
dropped Your server answered in a way waiting cannot fix: any 4xx other than 408 and 429, or a redirect, which we do not follow. The delivery ends here
no_response Nothing answered: a connection failure, a TLS failure or a timeout. Retried
exhausted The ladder ran out. This event never got through, and we are not going to try again
cancelled Somebody stopped this delivery. Nothing more is sent for it, and it is not counted as a failure
endpoint_deleted The endpoint was deleted while the delivery was still being retried
endpoint_disabled The endpoint was stopped while the delivery was still being retried, including by the automatic stop above
endpoint_unsubscribed The endpoint no longer subscribes to this event type. Whether an endpoint is on and subscribed is checked at the moment of sending, not only when the delivery was raised
url_refused The URL resolved to an address we must not call: private, loopback, link-local or cloud metadata. Nothing was sent. Checked on every send, because DNS can be re-pointed after an endpoint is registered

Filter by Period, Event, Rotation and Outcome, or turn on Failures only.

The outcome filters read each delivery’s newest attempt. A delivery that failed twice and then arrived is not a failure: it does not appear under Failures only, and under Outcome it answers to delivered rather than to failed. Failures only is every outcome but delivered and cancelled. Picking a single outcome is narrower than the toggle, so the toggle is not applied when you have picked one, and the screen says so.

The period decides which deliveries you get, on what they did inside it. Ask for failures over an hour that holds only the failed attempts of a delivery that arrived twenty minutes later, and you get that delivery, with its row reading delivered. Both are true: it failed inside your dates, and delivered is where it ended up. Widen the period to see the whole ladder in one place.

Cancel this delivery on the delivery panel stops that delivery and nothing else. No further attempt is made for it, the endpoint stays on, and every event after it is sent as usual. The row then reads cancelled.

Only a delivery still on the ladder can be stopped. Cancel one that has already finished and nothing is cancelled, which is the answer rather than an error: POST /v1/webhooks/{webhookId}/deliveries/{deliveryId}/cancel answers {"cancelled": false} and writes nothing.

To stop every event rather than one, use Stop sending on the endpoint. The delivery log offers the same control as Stop calling this endpoint.

A delivery in the log can be sent to the endpoint again, and the action is called Send the current state again because that is what it does. It is a new delivery: a new deliveryId, its own attempts, its own row, and a body carrying the rotation as it stands now under the original event type. The delivery you sent it from is left exactly as it was.

So the two bodies can differ, and a difference is not a fault. Expect two in particular: the state is current rather than what it was, and cause is absent on a re-sent duty.changed.

It is refused rather than queued whenever it could not go out: the endpoint is stopped, in which case start it again first; the endpoint no longer subscribes to that event type; or the rotation has been deleted since, because current state is the whole of what this sends. A re-sent rotation.deleted always goes, because it never carried rotation state.

Export as JSON writes the log as one file under the filters on screen. The file carries the request bodies, the headers we sent and your server’s answers, so it is the one thing worth handing to whoever runs the receiving server: everything else only says that we tried. It is your own data going to a server you registered. The one person-identifying field a rotation payload can carry is the email address of an external partner’s person who has no Slack identity in the workspace, and the file never contains your signing secret.

An export carries at most 1000 attempts, which is attempts rather than deliveries. Past that it keeps the newest and says it was truncated, so narrow the period and export again rather than reading a cut file as a complete one.

GET /v1/webhooks/{webhookId}/deliveries is the same list, paged, one row per delivery.

Field What it carries
deliveryId The id every attempt of this delivery shares, and the one your receiver deduplicates on
eventType The event, as it appears in the payload
rotationId The rotation the event happened on
firstAttemptedAt When the delivery was first tried
newestOutcome How the newest attempt ended, from the table above
newestStatusCode What your server answered on that attempt. Absent when nothing answered
newestDurationMs How long that attempt took. Absent when no request was made
attemptCount How many attempts the delivery has, over its whole history
onLadder true while the delivery is still being retried
newestRound Which round of the ladder the newest attempt belongs to, counting from 1
totalRounds How many rounds the ladder has
nextAttemptDueAt When the next attempt is due. Absent when nothing more will be tried
estimatedGivesUpAt Roughly when the ladder gives up on this delivery. Absent once it is off the ladder

estimatedGivesUpAt is an estimate rather than a time to hold us to, because an honoured Retry-After on a later round pushes it later, never earlier. It assumes every round still to come waits no longer than its own default.

from and to are both required and no more than 90 days apart, and they choose which deliveries come back rather than what a row says about one. Filter with eventType, rotationId and outcome, or failuresOnly=true, all reading the newest attempt the way the screen does. Rows come oldest first; ask for sort=at|desc for the newest.

GET /v1/webhooks/{webhookId}/deliveries/{deliveryId}/attempts answers one delivery’s attempts, oldest first, in a single page, each with the bytes that attempt sent and what came back. Beside attemptNumber, outcome, statusCode, durationMs, error and at, an attempt carries where it sat on the ladder.

Field What it carries
round Which round this attempt belongs to, counting from 1
totalRounds How many rounds the ladder has
nextAttemptDueAt When the next attempt of this delivery is due. Absent when the attempt was delivered and when nothing more will be tried

round is not attemptNumber divided by three: an honoured Retry-After moves a delivery to the next round without spending the current round’s quick retries.

POST /v1/webhooks/{webhookId}/deliveries/{deliveryId}/cancel stops a delivery.

All three need the manage:webhook scope. The two reads work on any plan; the cancel is a write, so a workspace that is not on Pro gets 402. The file is a dashboard action, because over the API you already have the rows.

Two refusals to expect:

  • 'retried' is not a webhook delivery outcome. A 400, for an outcome outside the table above. It is refused rather than ignored, because an empty page would read as “we never called your server”.
  • Delivery b41f7c0a… is not in this endpoint's log. The log keeps 30 days. A 404, and the same answer for a delivery that has aged out, an id belonging to another endpoint, and a delivery about a rotation your key is not scoped to.

Outbound webhook deliveries leave Round Robin from these addresses, so a receiver behind a firewall can allow them:

  • 104.155.127.101
  • 34.78.250.34

This is a promise about outbound webhook deliveries and about nothing else. Other requests Round Robin makes do not necessarily leave from these addresses, so do not build a firewall rule for anything but your webhook endpoint on them.