Verify

How do I measure how long my OTPs take to arrive?

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.

EventTimestampWhat it marks
verify.verification.createdcreated_atThe verification exists and its first passcode is queued
verify.attempt.sentsent_atBird has handed that passcode to the channel
verify.attempt.delivereddelivered_atThe channel confirms the passcode reached the recipient
verify.attempt.undeliveredfailed_atThe channel could not deliver it
verify.verification.verifiedverified_atSomeone submitted the correct code
verify.verification.failedfailed_atNo 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 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 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 has every payload in full, and sending verifications covers the settings and the guardrails.

Bouw op hetzelfde netwerk.

Een test-API-key is direct beschikbaar. Productietoegang wordt ontgrendeld zodra u een betaalmethode toevoegt en een afzender verifieert.

Begin met één kanaal.
Voeg de rest toe wanneer je er klaar voor bent.

Een test-API-key is direct beschikbaar. Productietoegang wordt ontgrendeld zodra je een betaalmethode toevoegt en een afzender verifieert.

Gebruik je Claude Code, Cursor of Codex? Kopieer een setup-prompt en je agent installeert de Bird CLI en skills voor je. Kies de jouwe:

Cursor