Skip to content
Documentation menu

Documentation

How verification works

The lifecycle of a one-time passcode at otp.com, from send to approved, including statuses, attempts, resend, and expiry.

A verification is a small state machine. Understanding its states makes the API predictable: every response tells you where the OTP is, and every call moves it forward.

The lifecycle

  1. Send. POST /otp/send generates a code, delivers it over the chosen channel, and returns an otp_id with status pending. The code itself is never returned by the API. If routing picks WhatsApp in one of its link modes, the code is not sent yet: the response returns an action_url (a wa.me link) the user opens to start the chat.
  2. The user confirms. You call POST /otp/verify with the otp_id and what they typed. This is the same on every channel, WhatsApp included. The one exception is a send that came back with completion: "inbound": that OTP is approved by the user’s own WhatsApp message and has no code to verify (see WhatsApp verification). The otp.approved webhook fires on approval either way, if you prefer a push signal over the verify response.
  3. Match or miss. A correct code sets status approved. A wrong code keeps the OTP pending until attempts run out, then it becomes failed.
  4. Fallback (optional). If nothing arrived, POST /otp/resend advances to the next channel. If time runs out, the OTP becomes expired.

Statuses

GET /otp/{otp_id} returns one of:

StatusMeaning
pendingWaiting for the user to enter a correct code.
approvedVerified: a correct code was entered, or the user’s WhatsApp message approved it under completion: "inbound". This is the success state.
failedAttempts were exhausted without a correct code.
expiredThe code, or the WhatsApp link, timed out before it was used.

Attempts and expiry

A wrong code does not fail the OTP immediately: the user gets a few attempts. Only when attempts are exhausted does the status move to failed. Independently, every code has a lifetime; once it passes, the status becomes expired even if attempts remain. In both cases you start over with a fresh POST /otp/send.

Resend and fallback

When a code does not arrive, or the user never verifies it, POST /otp/resend re-issues it on the next enabled channel rather than retrying the same one. That is the whole fallback model, and it needs no channel logic on your side. WhatsApp fits that chain according to its mode: under Template it sits anywhere, like SMS; under the two link modes it works as the first channel only, since a plain resend skips it further down and a pending OTP already on a wa.me link needs an explicit resend {"channel":"…"} to move off it. Two guardrails apply:

  • A short cooldown between resends. Calling too soon returns 429.
  • A finite send budget. Once the OTP’s attempts are spent, or no enabled channel is left, resend returns 409.

See Channels & routing for how the chain is chosen.

Billing

Billing is per verification sent: one charge per code sent, at the published rate for the destination country and channel. The charge is tied to the send rather than to what happens next, so it is the same whether the OTP ends approved, failed or expired, and whether or not the code arrives.

  • One send, one charge. Where a channel needs more than one message to carry a single code, as WhatsApp does with its inbound and outbound pair, that counts as one verification.
  • Verifying is free. POST /otp/verify costs nothing, however many attempts it takes.
  • A resend is a new verification at the same rate, so it is worth factoring resends into your retry policy.
  • A rejected send is free. If the send is refused outright and nothing goes out, no charge lands.
  • The sandbox is free. Nothing sent with a otp_test_… key is charged.