Retries and the deliveries log
What counts as a delivered webhook, when BlueBot tries a failed one again, why the same event can arrive twice or out of order, and how to read every attempt in the deliveries log.
Your server will be down sometimes: a deploy, an expired certificate, a database that won't answer. BlueBot keeps every event it couldn't deliver and tries again for 72 hours, so a short outage on your side loses nothing. In exchange, your handler has to cope with an event arriving more than once and not always in order. This page covers both.
What counts as delivered
A delivery succeeds when your address answers with any 2xx status within 10 seconds. Everything else is a failure, and BlueBot tries again:
- Any other status:
3xx(BlueBot never follows a redirect),4xxor5xx. - No answer within 10 seconds.
- A connection that's refused or reset, a certificate that doesn't check out, or a name that doesn't resolve.
- A name that resolves to a private, loopback or reserved address, which BlueBot never posts to.
One status is different: 410 Gone. It tells BlueBot the address will never take events again, and BlueBot deletes the webhook rather than retrying. Use it only when you mean it.
The retry schedule
After a failed try, BlueBot waits, then tries the same delivery again:
| After the failed try | BlueBot waits | So the next try is about |
|---|---|---|
| 1st | 1 minute | 1 minute after the event |
| 2nd | 5 minutes | 6 minutes after |
| 3rd | 30 minutes | 36 minutes after |
| 4th | 2 hours | 2.5 hours after |
| 5th | 6 hours | 8.5 hours after |
| 6th | 12 hours | 20.5 hours after |
| 7th | 24 hours | 44.5 hours after |
| 8th | 24 hours | 68.5 hours after |
That's nine tries in all. A delivery that has failed every one of them by the time it's 72 hours old ends as gave_up. It stays in the deliveries log for 30 days, and you can it any time in those 30 days.
Each try is signed when it's made, with a fresh webhook-timestamp, so a retry two days later still passes the 5-minute check in .
When a webhook keeps failing
While its deliveries fail, a webhook's status is failing, and failing_since says since when. When it has gone 72 hours with no 2xx at all, BlueBot pauses it (status: "paused", pause_reason: "failing") and emails you once.
A paused webhook isn't posted to, but BlueBot keeps recording the events it would have received. Once your address works again, resume it in Developers → Webhooks or with the API, and BlueBot sends everything that waited from the last 72 hours. Anything older is marked gave_up, and the still has it for 30 days.
/v1/webhooks/wh_1Hq2LxW9cVbN3mKpRtYs0acurl -X PATCH "https://usebluebot.com/api/v1/webhooks/wh_1Hq2LxW9cVbN3mKpRtYs0a" \
-H "Authorization: Bearer $BLUEBOT_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"paused": false
}'Changing a failing webhook's url clears failing_since, since the old failures were about the old address.
Getting the same event twice
Webhooks are delivered at least once, not exactly once. You'll see an event again when:
- Your handler did the work but answered after 10 seconds, or the connection dropped before BlueBot read your answer. BlueBot counts that as a failure and retries.
- You or a teammate a delivery.
Every copy of an event carries the same webhook-id header, which is the event's id in the body. Record the ids you've handled and skip one you've seen. Two rules make this safe:
- Record the id when you finish, not when you start, ideally in the same database transaction as the work. Then an event whose handling failed halfway isn't skipped when it comes again.
- Keep ids at least as long as BlueBot might resend. Retries stop after 72 hours, but a replay can come any time in the 30 days deliveries are kept. Thirty days covers both.
// Postgres: CREATE TABLE handled_events (id text PRIMARY KEY, handled_at timestamptz NOT NULL DEFAULT now());
import { sql } from './db';
export async function handleOnce(event: { id: string }, work: (tx: typeof sql) => Promise<void>) {
await sql.begin(async (tx) => {
const [seen] = await tx`SELECT 1 FROM handled_events WHERE id = ${event.id} FOR UPDATE`;
if (seen) return; // already handled: answer 2xx and move on
await work(tx);
await tx`INSERT INTO handled_events (id) VALUES (${event.id})`;
});
}Answer 2xx for a duplicate. An error tells BlueBot to try again, which only sends it a third time.
Order isn't guaranteed
Events usually arrive in the order they happened, but a retry can arrive after a later event. Say message.sent for a text to Maria fails once because your server was restarting, and a second later message.delivered goes through. You receive delivered, then, a minute later, sent.
Don't let an older event undo a newer one:
- Messages only move forward. A text you sent goes
scheduled,queued,sending, thensent,deliveredandread.failed,unconfirmed,expiredandcanceledare ends of the road, andsent,deliveredandreadonly move forward among themselves. Keep the state you have unless the event's is further along. - Compare times. Every event has a
timestamp, and a message has a time for each state (sent_at,delivered_at,read_at,failed_at). Ignore an event older than what you stored. - Ask when it matters.
datais the record as it was when the event happened. When you need the state right now, read it:/v1/messages/{id}.
const RANK = { scheduled: 0, queued: 1, sending: 2, sent: 3, delivered: 4, read: 5 } as const;
function isNewer(stored: keyof typeof RANK, incoming: string): boolean {
if (!(incoming in RANK)) return true; // failed, unconfirmed, expired, canceled: always take it
return RANK[incoming as keyof typeof RANK] > RANK[stored];
}The deliveries log
Every attempt to deliver an event to a webhook is a delivery, kept for 30 days. Open a webhook in Developers → Webhooks to see its deliveries with their status, the time each try took and the next try, or list them with the API. Filter by state to find the ones that need you:
/v1/webhooks/wh_1Hq2LxW9cVbN3mKpRtYs0a/deliveriescurl "https://usebluebot.com/api/v1/webhooks/wh_1Hq2LxW9cVbN3mKpRtYs0a/deliveries?state=gave_up&limit=50" \
-H "Authorization: Bearer $BLUEBOT_API_KEY"{
"object": "list",
"data": [
{
"id": "dlv_1Hq2LxW9cVbN3mKpRtYs0a",
"object": "webhook_delivery",
"webhook_id": "wh_1Hq2LxW9cVbN3mKpRtYs0a",
"event_id": "evt_1Hq2LxW9cVbN3mKpRtYs0a",
"event_type": "message.received",
"test": false,
"state": "gave_up",
"attempts": 9,
"last_status": 502,
"last_error": null,
"last_duration_ms": 214,
"next_attempt_at": null,
"delivered_at": null,
"replay_of": null,
"created_at": "2026-11-10T14:02:11.000Z"
}
],
"has_more": false,
"next_cursor": null
}| Field | What it is |
|---|---|
state | pending (trying, or waiting to try again), delivered, gave_up after the last retry, or dropped (let go without delivering, such as after a 410). |
attempts | How many tries so far. |
last_status | The HTTP status of the last answer, or null when there was none. |
last_error | Why the last try got no answer at all: a timeout, a refused connection, a name that doesn't resolve. |
last_duration_ms | How long the last try took. Close to 10,000 means your handler is too slow; see . |
last_response_body | The first 2 KB of what your server answered last, or null when it answered nothing. |
next_attempt_at | When it's tried next, while pending. |
test | true for an example sent with Send a test event. |
replay_of | For a replay, the delivery it repeats. |
BlueBot keeps the status of each answer, how long it took and the first 2 KB of what your server answered, so you can see why a delivery failed. A delivery goes with its event after 30 days.
| You got | It means | Do next |
|---|---|---|
last_status: 401 or 403 | Your server refused BlueBot, often an auth layer or firewall in front of the handler. | Let the path through without a session, and rely on the instead. |
last_status: 400 | Your handler refused the post, usually a signature check that failed. | See . |
last_status: 404 or 405 | Nothing answers POST at that path. | Check the address and that the route takes POST. |
last_status: 301, 302 or 308 | Your server redirected, and BlueBot doesn't follow redirects. | Use the final address, often the same one with https:// or a trailing slash. |
last_status: 500 to 504 | Your handler or its host failed. | Read your own logs at the time of the attempt. |
last_error says no answer within 10 seconds | Your handler is too slow. | Answer first and do the work afterwards. |
last_error says the name doesn't resolve, or a private network | The DNS name is wrong, or points at an internal address. | Use a public name that resolves to a public address. |
Catch up after an outage
When your server was down long enough for deliveries to give up, you have two ways back:
- Replay what gave up. List the webhook's
gave_updeliveries and replay each one. has a script. - Read the event feed.
/v1/eventshas every event from the last 30 days, withsinceto start where you stopped. Handle each one through the same code your webhook uses, and the ids you already have are skipped.