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
- Send.
POST /otp/sendgenerates a code, delivers it over the chosen channel, and returns anotp_idwith statuspending. 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 anaction_url(awa.melink) the user opens to start the chat. - The user confirms. You call
POST /otp/verifywith theotp_idand what they typed. This is the same on every channel, WhatsApp included. The one exception is a send that came back withcompletion: "inbound": that OTP is approved by the user’s own WhatsApp message and has no code to verify (see WhatsApp verification). Theotp.approvedwebhook fires on approval either way, if you prefer a push signal over the verify response. - Match or miss. A correct code sets status
approved. A wrong code keeps the OTPpendinguntil attempts run out, then it becomesfailed. - Fallback (optional). If nothing arrived,
POST /otp/resendadvances to the next channel. If time runs out, the OTP becomesexpired.
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, or the user’s WhatsApp message approved it under completion: "inbound". 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 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/verifycosts 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.