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, the code is not sent yet: the response returns an action_url (a wa.me link) the user opens to receive the code over WhatsApp.
  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: on WhatsApp the user opens action_url, receives the code over chat, and enters it like any other. The otp.approved webhook fires on approval 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:

Status Meaning
pending Waiting for the user to enter a correct code.
approved Verified: a correct code was entered. This is the success state.
failed Attempts were exhausted without a correct code.
expired The 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 only takes part when it is first in your order, since the user has to open a wa.me link to start the chat; a resend never advances into WhatsApp, and it is skipped if it sits further down the chain. Two guardrails apply:

  • A short cooldown between resends. Calling too soon returns 429.
  • A finite fallback chain. When there is no further channel, resend returns 409.

See Channels & routing for how the chain is chosen.

Billing

Billing is per verification sent, at the published rate for the destination country and channel. The charge is tied to the send rather than to the final status, so it is the same whether the OTP ends approved, expired or failed.

  • 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.
  • Nothing is charged for a send that never reaches a delivery provider, or for anything sent with a otp_test_… sandbox key.