# 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 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 [replay](https://usebluebot.com/docs/webhooks/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](https://usebluebot.com/docs/webhooks/verify).

## 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](https://usebluebot.com/docs/api/events/list-events) still has it for 30 days.

```bash
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
  }'
```

```ts
import BlueBot from '@usebluebot/sdk';

const client = new BlueBot({ apiKey: process.env.BLUEBOT_API_KEY });

const webhook = await client.webhooks.update('wh_1Hq2LxW9cVbN3mKpRtYs0a', {
  paused: false,
});
console.log(webhook);
```

```python
import os
from bluebot import BlueBot

client = BlueBot(api_key=os.environ["BLUEBOT_API_KEY"])

webhook = client.webhooks.update("wh_1Hq2LxW9cVbN3mKpRtYs0a", paused=False)
print(webhook)
```

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](https://usebluebot.com/docs/webhooks/replay) 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.

```ts title="TypeScript"
// 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})`;
  });
}
```

```python title="Python"
# Postgres: CREATE TABLE handled_events (id text PRIMARY KEY, handled_at timestamptz NOT NULL DEFAULT now());
def handle_once(conn, event, work):
    with conn.transaction():
        seen = conn.execute("SELECT 1 FROM handled_events WHERE id = %s FOR UPDATE", (event.id,)).fetchone()
        if seen:
            return  # already handled: answer 2xx and move on
        work(conn)
        conn.execute("INSERT INTO handled_events (id) VALUES (%s)", (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}`.

```ts title="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:

```bash
curl "https://usebluebot.com/api/v1/webhooks/wh_1Hq2LxW9cVbN3mKpRtYs0a/deliveries?state=gave_up&limit=50" \
  -H "Authorization: Bearer $BLUEBOT_API_KEY"
```

```ts
import BlueBot from '@usebluebot/sdk';

const client = new BlueBot({ apiKey: process.env.BLUEBOT_API_KEY });

const page = await client.webhooks.deliveries.list('wh_1Hq2LxW9cVbN3mKpRtYs0a', {
  state: 'gave_up',
  limit: 50,
});
console.log(page.data);
```

```python
import os
from bluebot import BlueBot

client = BlueBot(api_key=os.environ["BLUEBOT_API_KEY"])

page = client.webhooks.deliveries.list(
    "wh_1Hq2LxW9cVbN3mKpRtYs0a",
    state="gave_up",
    limit=50,
)
print(page.data)
```

The answer, `200`:

```json
{
  "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 [Answer fast](https://usebluebot.com/docs/webhooks#answer-fast).                                 |
| `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 [signature](https://usebluebot.com/docs/webhooks/verify) instead. |
| `last_status: 400`                                               | Your handler refused the post, usually a signature check that failed.                 | See [When a check fails](https://usebluebot.com/docs/webhooks/verify#when-a-check-fails).                                 |
| `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_up` deliveries and replay each one. [Replay deliveries](https://usebluebot.com/docs/webhooks/replay) 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

- [Replay deliveries](https://usebluebot.com/docs/webhooks/replay): Send a delivery again, one at a time or in bulk.
- [Test webhooks](https://usebluebot.com/docs/webhooks/testing): Try your handler against signed events before you go live.
