There is no honest answer to "how long should a passcode take", because it depends on a carrier, a handset and a country, and none of those is ours to promise. Your own traffic is a different question, and it is answerable. Verify already computes delivery latency percentiles for the dashboard, and the events carry the timestamps to derive your own.

## Which timestamps does Verify give me?

Six, across two families of event. Session events track the verification; delivery events track each individual passcode, which Verify calls an attempt.

| Event                          | Timestamp      | What it marks                                            |
| ------------------------------ | -------------- | -------------------------------------------------------- |
| `verify.verification.created`  | `created_at`   | The verification exists and its first passcode is queued |
| `verify.attempt.sent`          | `sent_at`      | Bird has handed that passcode to the channel             |
| `verify.attempt.delivered`     | `delivered_at` | The channel confirms the passcode reached the recipient  |
| `verify.attempt.undelivered`   | `failed_at`    | The channel could not deliver it                         |
| `verify.verification.verified` | `verified_at`  | Someone submitted the correct code                       |
| `verify.verification.failed`   | `failed_at`    | No planned channel could deliver a passcode              |

Two traps in that table before you build anything on it.

`failed_at` appears twice and means two different things: on `verify.attempt.undelivered` it is one passcode failing, and on `verify.verification.failed` it is the whole verification resolving. Key your storage on the event type rather than on the field name.

And `verify.verification.failed` is the one people leave out. It is the terminal event for the codes that never arrived at all, so an average built only from delivered attempts and successful verifications is an average over survivors. Its `last_attempt_reason` is the actionable half: `not_billable` there means the workspace balance could not cover the send, which is a "why was my OTP slow" answer that no delivery timestamp gives you.

A resend or a channel failover creates another attempt against the same `verification_id`, each with its own delivery sequence, and **no event carries an attempt identifier**. `webhook-id` will not group them either: it identifies one delivery of one event, so an attempt's `sent` and its `delivered` carry different values. Pair them on `verification_id`, `channel` and `address` in timestamp order. The case that defeats this is a resend on the same channel, whose two `verify.attempt.sent` events differ only by timestamp.

One rule before any arithmetic: deliveries are not ordered. An event can arrive before the one that logically precedes it, especially where retries are involved, so sort by the `timestamp` inside the payload and never by arrival order.

## Why do I have to derive this myself?

Because the numbers Bird already computes are not on a surface a program can reach.

Verify's statistics endpoints do include delivery latency percentiles, and they are worth looking at. They are also all scoped to the dashboard, as is the read that returns a single verification. Of the whole Verify surface, three operations are available to a program: create a verification, check a code, and advance to the next channel. So there is no endpoint to poll for a verification's own timings. The events are the fourth route and the one that makes this page possible: all six types are public, and a program receives them through a webhook subscription created on the webhooks API rather than through a Verify operation. That is what makes deriving your own the intended path rather than a workaround.

Expect your figure and the dashboard's to disagree, and do not treat that as a bug in either. The dashboard measures each attempt from **its own creation to its resolution**, not from the verification's creation, so it is closer to your sent-to-delivered figure than to anything starting at `created_at`. Three things then narrow it: only charged, delivered attempts count; an attempt that resolved as a delivery timeout is excluded outright; and the window filters on the charge instant rather than the send. A percentile is null when nothing in scope qualified. Differencing events gives you every attempt you chose to include, on whatever interval you chose. Both are legitimate; they are answers to different questions. If you report a number internally, write down which one it is.

## Which interval should I actually trust?

Three intervals fall out of those timestamps, and they are not equally solid.

`created_at` to `sent_at` is the part that is ours: how long the verification sat before a channel accepted it. If this is slow, tell us. Rule out one thing first, though, because that window can contain channels that never sent: a rung that is restricted, unavailable or not billable is recorded and the plan advances, so a gap here can be a failover rather than queueing. The `sent_at`-less undelivered events below are how you tell the two apart.

`sent_at` to `delivered_at` is the part that looks like the answer and is the one to be careful with. Bird's own events documentation says delivery reports are indicative rather than guaranteed: carriers and mailbox providers vary in what they confirm and how quickly, and in some markets attempt events arrive minutes later or do not distinguish delivery from acceptance. So a `delivered_at` an order of magnitude apart between two countries may be telling you about two reporting conventions rather than two delivery speeds. It is also not purely the channel's. `sent_at` is stamped when Bird's own channel accept returns, and for SMS the carrier submit happens later in the SMS pipeline, so this window still holds our queueing and charging alongside the carrier's time. On Telegram it is looser again, because the attempt is marked sent before Telegram has been asked whether the number can receive a message at all, so a `sent_at` there can still be followed by a failover. Measure it against itself over time in one market rather than across markets.

`created_at` to `verified_at` is the definitive one. It is the only interval backed by a person actually receiving a code and using it. It includes however long they took to switch apps and type, so it is not a delivery measurement, but if you are asking whether your signup is slow, it is the number that describes what a user lived through.

For a phone recipient, `carrier` and `mcc_mnc` on the delivered event name the handling network, which is what lets you break any of this down by operator. Both are always null for email, WhatsApp and Telegram.

## Is a late code actually a second attempt?

Often, and the events will tell you outright.

An undelivered attempt does not end the verification when the recipient has another channel available. Verify advances to the next channel in the plan and sends again, and that new passcode gets its own `verify.attempt.sent`. So a user who reports one slow code may have been sent two, on two channels, and the second one is the one they read.

Two cases produce an attempt with no `sent_at` at all, which will skew any average that assumes every attempt has one. A channel that fails before sending emits `verify.attempt.undelivered` with `reason: "channel_unavailable"`, and one that cannot carry passcodes to the recipient's country emits `reason: "channel_restricted"`. Neither has a `verify.attempt.sent` or any later delivery report. Filter on the presence of `sent_at` before computing a send-to-delivery figure.

The `channel` field on `verify.verification.verified` closes the loop: it names whichever channel delivered the code the user actually submitted, or is null when the verification resolved without attributing one.

## What should I do when a user says nothing arrived?

Move the channel rather than resend on the same one. [`POST /v1/verify/verifications/next-channel`](/docs/api/reference/create-verification-next-channel) advances the verification to the next channel in its plan and sends a fresh code there. It is the endpoint behind an "I didn't receive my code" button, and it differs from a resend in two ways that matter here: the resend cooldown does not apply, so the send goes out immediately, and only the channel moves forward while the expiry, attempt budget and verification ID stay as they were.

Every code already sent stays valid, so a message that arrives late can still be checked. That is worth knowing before you build a UI that invalidates the first code when it sends a second.

A recipient with only one usable channel has nowhere to go, and the call says so rather than failing silently.

Two more things about this call are worth knowing before you wire it to a button. It does not draw on the recipient's hourly send cap, so offering it is cheaper than a resend. And a recipient with no verification in progress returns `404` with `E13000`, which includes someone who has already verified, so a `404` here is not evidence that they still need verifying.

## What defaults bound all of this?

Four, and the Configure page carries three of them:

- **Duration**, how long a code stays valid: 10 minutes by default, settable from 1 to 999 minutes. This is the outer bound on any latency that still matters, since a code arriving after it is useless.
- **Retry delay**, the cooldown before a new code goes to the same recipient: 60 seconds by default, settable from 0 to 3600.
- **Maximum retries**, the attempt budget before a verification fails with `attempts_exhausted`: 5 by default, settable from 1 to 10.
- **Platform caps**, which apply whatever you configure: 5 sends per address per rolling hour across starting and resending, and 10 checks per recipient address set per minute. Hitting one returns `429` with a [`Retry-After`](/docs/guides/rate-limits) header.

The channel plan, rather than the hourly cap, is what bounds channel changes: each `next-channel` call advances strictly forward, so one verification sends at most once per remaining channel.

[Verify events](/docs/guides/verify/events) has every payload in full, and [sending verifications](/docs/guides/verify/sending-verifications) covers the settings and the guardrails.