# Delivery, retries and idempotency

> Decide when to retry, drop or keep a Whisperr API request, and use $message_id so that retries never create duplicate events.

Whisperr ingestion is at-least-once: a timeout can mean that the server stored your request and the response was lost. This page says how to handle each response from the ingestion API and how to retry without duplicates. Every Whisperr SDK does this already. If you call the API directly, do the same.

## Accepted, then processed

The Whisperr API checks the shape of each request: valid JSON, known fields, sizes, `snake_case` event names and the `occurred_at` window. It then stores the request and answers `202 Accepted`. Resolving the user, applying traits and channels, and evaluating the event happen right after, normally within a few seconds.

So a `2xx` means **Whisperr has your data and will process it**, not that it has already processed it. Once you get one, the request is Whisperr’s responsibility: remove it from your queue and do not resend it.

Whisperr applies requests per app in the order it accepted them. An `identify` sent before a `track` is applied before it. That is why a visitor’s anonymous events are always attached when you later identify them. Send `identify` through the same queue as your events, not around it.

## Response handling

| Response                                | Class | What to do                                                                                                                                                    |
| --------------------------------------- | ----- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `2xx`                                   | ok    | Accepted. Remove the request from your queue.                                                                                                                 |
| `401`, `403`                            | auth  | The key is wrong, revoked, or the wrong kind. **Stop sending**, report the error, and keep the request queued. Retrying does not help until the key is fixed. |
| `429`, `5xx`, network error, timeout    | retry | Temporary. Retry with bounded exponential backoff. When retries run out, keep the request queued for a later attempt. Do not drop it.                         |
| Any other `4xx`, such as `400` or `413` | drop  | The request is malformed and will never succeed. Drop it and log it. This is a bug in your integration, not a temporary fault.                                |

Decide on the status class, not on an exact status code or on fields in the response body.

Two rules follow from the table:

- **Auth errors and exhausted retries keep the data. Malformed requests drop it.** Temporary and auth failures can recover, so the data stays queued. A `400` fails the same way every time, so a retry only uses up your rate limit.
- **A malformed event does not sink its batch.** An event with an unknown field, a bad `event_type` or an `occurred_at` outside the window is counted in the batch response’s `rejected` and skipped. The rest are accepted. Only a problem with the whole request fails the batch with `400`: invalid JSON, an unknown top-level field, or an empty or oversized `events` array. Validate events before they enter your queue anyway: a rejected event is gone.

## Rejections after acceptance

A few problems show up only while Whisperr processes a request, after the `202`. They do not come back in the response. Whisperr sets the affected event or identify aside and processes the rest of the batch normally, so your queue keeps flowing:

- **Event names that Whisperr computes** (`derived_event_code`): the `event_type` is an event Whisperr derives itself, so you cannot send it.
- **Contact changes from the publishable key** (`contact_change_requires_server_key`): giving a user a different email address than the stored one needs the server key.

Fix these at the source. Resending the same request fails the same way.

To see them, open the **Data** page of the Whisperr dashboard and check the **Not used** section. It shows the last 24 hours of rejected requests and of events Whisperr could not apply, by reason. Coding agents can read the same data with the Whisperr MCP tools `list_ingest_dead_letters` and `get_ingest_rejects` (see [Use with your AI agent](/agents/)).

### Why does Whisperr ignore bot traffic?

Whisperr drops events and identify calls that come from known crawlers, headless browsers, link previewers and AI agents. For the publishable key it checks the request’s `User-Agent` header. For every key it checks the `user_agent` or `userAgent` in the event’s `context`. The response is still `202`, so SDKs do not retry. It carries an `X-Whisperr-Filtered` header, such as `bot; class=headless_browser; count=2`. The **Data** page shows the count under **Not used → Bots ignored**.

If you test with Playwright or Puppeteer in headless mode, the events are dropped.

## Idempotency: `$message_id`

Put a per-event idempotency key in each event’s `context.$message_id`: any stable unique string up to 200 bytes. A UUID works. Whisperr stores an event once per `$message_id`, which makes at-least-once retries safe. Whisperr accepts an event without `$message_id`, but cannot deduplicate it.

The rule: **generate `$message_id` once, when the event is created, never per attempt.** Every retry of an event must send the same id. If you create a new id on retry, every timeout can become a duplicate event.

```text
create event ──▶ assign $message_id ──▶ enqueue
                                          │
                     send ◀── retry ◀─────┤  same $message_id every attempt
                       │
                      2xx ──▶ dequeue
```

## Backoff

Use bounded exponential backoff for the retry class (`429`, `5xx`, network errors). When a `429` or `503` carries `Retry-After`, wait at least that long before the next attempt, up to 60 seconds. The Node SDK, for example, retries up to 6 times, with a 10-second timeout per request. After the last attempt, keep the request and try again on your next flush instead of dropping it.

## Conformance

The `whisperr-spec` repository pins this contract with executable fixtures: `conformance/wire.json` for request bodies and `conformance/behavior.json` for the retry, drop and keep outcomes. Every official SDK runs them in CI. If you build your own client, test it against them.

## Common questions

### Does a 202 from Whisperr mean the event was processed?

No. 202 means Whisperr stored the request and will process it, normally within seconds. Remove the request from your queue and do not send it again.

### Should I retry a 400 from the Whisperr API?

No. A 400 means the request is malformed, and the same bytes fail the same way every time. Drop it, log it and fix the integration.

### Why are my events missing although the API answered 202?

Either the request was rejected during processing, or it came from a known bot or crawler user agent. Open the Data page of the Whisperr dashboard and check the Not used section, or call the Whisperr MCP tool get_received_events.

### How long should I wait after a 429 from Whisperr?

Wait the number of seconds in the Retry-After header, then retry. The SDKs cap that wait at 60 seconds.

Source: https://docs.whisperr.net/api/delivery/
