Skip to the page

Webhooks

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), 4xx or 5xx.
  • 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 tryBlueBot waitsSo the next try is about
1st1 minute1 minute after the event
2nd5 minutes6 minutes after
3rd30 minutes36 minutes after
4th2 hours2.5 hours after
5th6 hours8.5 hours after
6th12 hours20.5 hours after
7th24 hours44.5 hours after
8th24 hours68.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 replay 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 Verify signatures.

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 event feed still has it for 30 days.

PATCH/v1/webhooks/wh_1Hq2LxW9cVbN3mKpRtYs0aChange a webhook →
curl -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 replayed 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:

  1. 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.
  2. 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, then sent, delivered and read. failed, unconfirmed, expired and canceled are ends of the road, and sent, delivered and read only 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. data is the record as it was when the event happened. When you need the state right now, read it: GET/v1/messages/{id}.
Keep the furthest state
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:

GET/v1/webhooks/wh_1Hq2LxW9cVbN3mKpRtYs0a/deliveriesList deliveries →
curl "https://usebluebot.com/api/v1/webhooks/wh_1Hq2LxW9cVbN3mKpRtYs0a/deliveries?state=gave_up&limit=50" \
  -H "Authorization: Bearer $BLUEBOT_API_KEY"
Response200
{
  "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
}
FieldWhat it is
statepending (trying, or waiting to try again), delivered, gave_up after the last retry, or dropped (let go without delivering, such as after a 410).
attemptsHow many tries so far.
last_statusThe HTTP status of the last answer, or null when there was none.
last_errorWhy the last try got no answer at all: a timeout, a refused connection, a name that doesn't resolve.
last_duration_msHow long the last try took. Close to 10,000 means your handler is too slow; see Answer fast.
last_response_bodyThe first 2 KB of what your server answered last, or null when it answered nothing.
next_attempt_atWhen it's tried next, while pending.
testtrue for an example sent with Send a test event.
replay_ofFor 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 gotIt meansDo next
last_status: 401 or 403Your 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 signature instead.
last_status: 400Your handler refused the post, usually a signature check that failed.See When a check fails.
last_status: 404 or 405Nothing answers POST at that path.Check the address and that the route takes POST.
last_status: 301, 302 or 308Your 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 504Your handler or its host failed.Read your own logs at the time of the attempt.
last_error says no answer within 10 secondsYour handler is too slow.Answer first and do the work afterwards.
last_error says the name doesn't resolve, or a private networkThe 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_up deliveries and replay each one. Replay deliveries has a script.
  • Read the event feed. GET/v1/events has every event from the last 30 days, with since to start where you stopped. Handle each one through the same code your webhook uses, and the ids you already have are skipped.

Next