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 whether the shift moved, your server is told within seconds. 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, and the only filter 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. Any admin or owner can see the workspace’s endpoints; changing one needs the Pro plan.

  1. Open Settings → Webhooks and choose Add endpoint.
  2. Enter the 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.
  3. Enter a Name, up to 100 characters. It is how the endpoint is listed for you, and your server never sees it, so name the receiving system (“Incident bridge”, “Status page”).
  4. Choose Add endpoint. The new endpoint receives all five event types.
  5. Copy the signing secret into your secret store before you close the dialog. It starts with rr_whsec_ and it is shown once: nothing shows it again. If you lose it, rotate it.

To narrow what the endpoint receives, open it. Under What it receives, open Events (it reads All 5 events on a new endpoint), untick what you do not want and choose Save. An endpoint subscribed to nothing receives nothing, apart from a test event.

Then prove the wiring without waiting for anything to happen on a rotation. Send a test event posts to the endpoint straight away, 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. Adding an eleventh is refused with This workspace holds as many endpoints as it can. Delete one you no longer use to add another. Over the API it is a 409 with the code webhook_endpoint_limit (see errors). Give each receiving system its own endpoint, so you can stop one without stopping the others.

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 endpoints, POST /v1/webhooks registers one and answers with the signing secret once, PUT /v1/webhooks/{webhookId} replaces its name, URL and subscriptions together, and DELETE removes it. /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. Before you write the integration:

  • The key needs the manage:webhook scope, and only an administrator can mint a key that holds 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 to an existing endpoint takes an If-Match with the ETag from your last read of it, like every other /v1 write. Registering a new one takes no precondition.

Every delivery is a POST with a Content-Type: application/json body and two signature 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 the body is safely queued. You have ten seconds to answer, not to finish your own work: reply first, then do the work.

The body carries these members.

Member Sent
deliveryId Always. 32 hexadecimal characters identifying this delivery. Every retry of the same delivery repeats it, so deduplicate on it
eventType Always. One of the five event types, or webhook.test on a test send. Route on it
teamId Always. The Slack workspace the event happened in, which matters when the same URL is registered in two workspaces
rotationId Always. On rotation.deleted 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 does not work as an If-Match
timestamp Always. When the event became this delivery. It stays the same across every retry; the time 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 with no value is absent, not null. A rotation.deleted body has no rotation key at all, and the same goes for every member above that is not always sent. Check for presence, and read a missing key as nothing to report.

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

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

duty.changed, here for a window open 09:00 to 18:00 that hands over weekly:

{
"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",
"coveredUntil": "2026-09-18T18:00:00+00:00",
"turnEnds": "2026-09-25T09:00:00+00:00"
}
]
},
"…": "the rest of the rotation resource"
},
"self": "https://api.roundrobinbot.eu/v1/rotations/664f1c9ab3d24e0f8a7b2c11"
}

coveredUntil is when this person stops covering today, and turnEnds is when their turn passes to the next person. See when duty ends.

nobody.on_call, here for the same rotation once its 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:

{
"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 the body, and no endpoint can subscribe to it: asking for the send is the subscription. So write your receiver to pass an unknown eventType straight through, or your own code rejects the test send.

The body has the same shape as the others and carries the state of a real rotation in your workspace: the one somebody changed most recently, enabled or not. It has rotation, self and version, and no cause.

A test send is refused, with nothing sent, in three cases. The first two arrive under Could not send the test event.

You see Do this
This endpoint is switched off. Choose Start sending again, then send a test event. Choose Start sending again, then send the test.
This workspace has no rotations. Create a rotation first. Create a rotation.
Changing an endpoint needs the Pro plan. beside a locked button Move the workspace to Pro. A test send is a write.

A test send is a dashboard action only; /v1 has no route for it. Read the outcome in the delivery log.

cause is scheduled or manual. It says which shape of change this was, not whether a person was involved. Two cases surprise anyone who reads it as “somebody did this”:

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

Branch on manual if you need to, but 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. Treat it as unknown. The safe reading is usually to do what you do for scheduled, so nobody is paged for a change nobody made.

nobody.on_call fires whenever a change leaves nobody on duty, and that includes a rotation going quiet exactly as configured. reason tells the two apart:

  • nobody-on-call-unplanned: a window is open and nobody holds it. This is the one to act on.
  • nobody-on-call-as-arranged: every window is shut, so the rotation is quiet by its own configuration.

Route on reason, or an endpoint subscribed to this event pages somebody every evening. A rotation’s duty history and the nobodyOnCallReason on the on-call response use the same two values.

coverResumesAt is the next time a coverage window opens, where that is known. It is absent when nothing says, which is typical of the unplanned case: nobody to put on duty, and no opening scheduled.

Every delivery carries two signature headers:

  • Round-Robin-Timestamp: when this attempt was sent, 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 one and verification fails, with nothing in your own logs to show why.

  1. HMAC the raw request body bytes, exactly as they arrived, never a body you parsed and serialised again. Re-serialising changes key order, spacing and number formatting, so the digest no longer matches. This is by far the most common mistake.
  2. Split Round-Robin-Signature on ,.
  3. Expect the 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.
  5. Compare in constant time, with your language’s function for it, not ==.

Then reject a timestamp too far from your own clock. Five minutes is a good tolerance; Round Robin does not enforce one. Every attempt is signed when it is sent, so a delivery retried six hours later carries a fresh timestamp. The timestamp is inside the HMAC, so nobody can change 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 old secret keeps signing for seven days. During that week both signatures travel in the one Round-Robin-Signature header, current first, so a receiver that accepts any matching element can switch to the new secret whenever you deploy it, with nothing failing in between. After the week, only the new signature is sent.

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

No event-type header and no delivery-id header. Beside Content-Type, the two signature headers are the only ones to rely on. Route on eventType and deduplicate on deliveryId, both read from the body after you have verified it.

No credentials from the URL. A URL registered as https://user:pass@host/ has its userinfo removed, and the request goes to https://host/. Your server sees no basic auth, so an endpoint relying on it answers 401 to every delivery. Authenticate the call with the signature, or put a token in the path or the query string.

No redirects. A 3xx ends the delivery, so 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 after 20 seconds. If it still fails, the delivery is sent again after 1 minute, 5 minutes, 30 minutes, 2 hours and 6 hours. That spreads one 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 shows Retries exhausted.

A round is one send and the two quick retries that follow it. The ladder has six rounds: the first send, then one round for each later send, each with its own two quick retries. The sixth round is a single send, because nothing follows it. The API publishes totalRounds, so your 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, and the redirect is not followed.
  • 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 and the next attempt waits what you asked for, up to six hours. Ask for less and the step’s own wait stands. Both forms of the header work, a number of seconds and an HTTP date, and the wait you asked for is recorded on the attempt. An honoured Retry-After moves the delivery straight to the next round, so the rest of the current round’s quick retries are skipped.

Three abandoned deliveries inside 24 hours stop the endpoint. It shows 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.

The State column says why an endpoint is not sending, and the endpoint’s own page repeats it as a heading:

State On the endpoint’s page Why Do this
Stopped: too many failures Stopped after too many failed deliveries Three deliveries were abandoned inside 24 hours Fix the receiver, then choose Start sending again
Suspended Sending is suspended Somebody chose Stop sending Choose Start sending again
Stopped: needs the Pro plan Dormant on the Free plan The workspace is on Free. Nothing is deleted Move the workspace back to Pro. The endpoint starts sending again

Open an endpoint on Settings → Webhooks. Deliveries lists every delivery to 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 shows the event, the rotation, when the delivery was first tried, and the newest attempt’s outcome, status code and duration. A delivery still on the ladder reads Waiting instead of an outcome, because a later attempt can still get through.

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

Open a row to see the delivery. While it is on the ladder, the status line reads Waiting. with Next try at and Last try at about. Once it has finished, the line shows its outcome.

Request body is the body every attempt sent. It is the same on each; only the signature changes. Below it, the attempts are listed oldest first under Rounds, or under Attempts for a delivery recorded without rounds. A round still to come reads Scheduled. Pick an attempt for its Request headers and its Response: this is how you explain an event that arrived six hours after it happened.

Four counters sit above the filters and cover everything the filters match, not only the page on screen: Attempts delivered, Attempts failed, Deliveries waiting and Typical answer. Two count attempts and one counts deliveries, so they do not add up to one total. Typical answer is the median time your server took, over the attempts it answered at all.

Deleting an endpoint deletes its log. Export it first if somebody still needs it.

Response shows up to 512 bytes of your server’s response body for each attempt that got an answer, as text. A longer answer is cut, and the panel says This is a cut piece of a longer answer, not the whole of it.

The panel says Meaning
Your server answered with an empty body. Your server answered, with no body
No request was made, so there is nothing to show. The attempt ended before a request was sent
Your server asked for a 120s wait. The next round waits that long, never less than its usual gap or more than the longest one. Your server sent Retry-After on a 429 or a 503

Over the API the same 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.

On screen In the API Meaning
Delivered delivered Your server answered 2xx. The delivery is done
Not accepted failed Your server answered a 5xx, a 408 or a 429. A later attempt of the same delivery can still get through
Refused dropped Your server answered a 4xx other than 408 and 429, or a redirect. Waiting fixes neither, so the delivery ends here
No answer no_response A connection failure, a TLS failure or a timeout. Retried
Retries exhausted exhausted The ladder ran out. The event did not get through, and nothing more is sent for it
Cancelled cancelled Somebody stopped this delivery. Nothing more is sent for it, and it does not count as a failure
Endpoint deleted endpoint_deleted The endpoint was deleted while the delivery was still being retried
Endpoint switched off endpoint_disabled The endpoint was stopped while the delivery was still being retried, by somebody or by the automatic stop above
Endpoint unsubscribed endpoint_unsubscribed The endpoint no longer subscribes to this event type. Checked when each attempt is due, not only when the event happened
URL refused url_refused The URL resolved to a private, loopback, link-local or cloud-metadata address. 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 counts as Delivered, not Not accepted. Failures only is every outcome except Delivered and Cancelled. A single picked outcome is narrower than the toggle, so the toggle is not applied while one is picked, and the screen says An outcome is picked, which is narrower, so this is not applied.

The period picks deliveries by 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, reading Delivered. Both are true: it failed inside your dates, and it ended up delivered. 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 later event is sent as usual. The row then reads Cancelled.

Only a delivery still on the ladder can be stopped. Cancelling one that has already finished cancels nothing, and that is not an error: POST /v1/webhooks/{webhookId}/deliveries/{deliveryId}/cancel answers {"cancelled": false} and changes nothing.

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

Send the current state again on a delivery creates a new delivery: a new deliveryId, its own attempts and its own row. Its body carries the rotation as it stands now, under the original event type. The delivery you sent it from stays as it was.

So the two bodies can differ, and that is expected. In particular, the state is current rather than what it was then, and cause is absent on a re-sent duty.changed.

A re-send is refused, with nothing sent, in four cases. Each arrives under Could not send the event again.

You see Do this
This endpoint is switched off. Choose Start sending again, then send the event again. Choose Start sending again, then send it again.
This endpoint is no longer subscribed to this event. Add it back under What it receives, Events. Under What it receives, open Events, tick the event again and choose Save.
The rotation has been deleted since this event was sent. Nothing to re-send. The original body is still in the log.
This delivery is no longer in the log. It may have passed the 30 day retention. The delivery has aged out of the log.

A re-sent rotation.deleted always goes, because it never carried rotation state.

Export as JSON saves the log under the filters on screen as one file. It holds the request bodies, the request headers and your server’s responses, so it is the file to hand to whoever runs the receiving server. It never contains your signing secret.

An export holds at most 1000 attempts, counted as attempts, not deliveries. Past that it keeps the newest, and the screen says This export is not the whole period. Narrow the period and export again rather than reading a cut file as complete.

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 assumes every remaining round waits its default. An honoured Retry-After on a later round pushes it later, never earlier.

from and to are both required and no more than 90 days apart. They choose which deliveries come back; they do not change what a row says about one. Filter with eventType, rotationId and outcome, or failuresOnly=true, all reading the newest attempt as 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 in a single page, oldest first, each with the bytes that attempt sent and what came back. Beside attemptNumber, outcome, statusCode, durationMs, error and at, an attempt carries its place 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, because an honoured Retry-After moves a delivery to the next round without its remaining 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. Export is a dashboard action only.

Two refusals to expect:

You see Meaning
400 'retried' is not a webhook delivery outcome. outcome names a value outside the table above. Use one of the ten
404 Delivery b41f7c0a… is not in this endpoint's log. The log keeps 30 days. The delivery has aged out, belongs to another endpoint, or is 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

These addresses apply to webhook deliveries only. Other requests Round Robin makes can leave from other addresses, so use them only in a rule for your webhook endpoint.