Verify (OTP)
One-time passcodes over SMS or email, with the anti-abuse half already built.
Anyone can send a six-digit code. What this exists for is the part customers do not want to own: stopping an attacker turning a public "send me a code" endpoint into an SMS-pumping ATM against their bill.
POST /api/v1/verifications
POST /api/v1/verifications/{id}/check
Scope: write:verifications.
Start a verification
curl -X POST https://www.sendoka.com/api/v1/verifications \
-H "Authorization: Bearer $SENDOKA_API_KEY" \
-H "Content-Type: application/json" \
-d '{"channel":"sms","to":"+14155550142","from":"+14155550100"}'
{ "id": "ver_...", "status": "pending", "channel": "sms", "expires_at": "2026-08-18T12:10:00.000Z" }
| field | default | notes |
|---|---|---|
channel |
— | sms or email |
to |
— | E.164 or an email address, at most 320 characters |
template |
Your verification code is {{code}} |
{{code}} is the only variable |
code_length |
6 | 4–10 |
ttl_seconds |
600 | 60–3600 |
max_attempts |
5 | 1–10 |
from |
— | required. A phone number / sender ID registered to your org (SMS), or an address on a domain you have verified (email). The same senders /v1/sms and /v1/emails accept — a verification is your message to your user, so it goes out under your sender. Your org's sandbox sender counts, under the same rule as on /v1/emails: with a live key it only reaches your org members' verified addresses (403 SANDBOX_RECIPIENT_NOT_ALLOWED otherwise), so real users need your own domain. |
subject |
Your verification code |
email only |
dlt_template_id |
— | SMS only. Required for +91 destinations. See India (DLT). |
The destination is not echoed back. The caller already knows it, and echoing it turns any log or proxy that captures responses into a store of end users' phone numbers.
Send an Idempotency-Key. A verification is a billed send, and a retry after a timeout used to text the user a second code — for a verification id the caller never saw. A retry under the same key now replays the recorded 201 (same id), and one that arrives while the first is still sending gets 409 IDEMPOTENCY_IN_FLIGHT. Only the 201 is stored: a refusal, or a 502 SEND_FAILED, frees the key so the retry really sends. Both SDKs attach a key automatically. See idempotency.md.
One key per code request — never one per user or per destination. A replay sends nothing and returns the first verification for 24h: same id, status: "pending" and the original expires_at, even after that code has expired. A key such as otp-${userId} turns every later "resend code" into a silent replay, and the user never gets a new code. Mint a fresh key each time the user asks for a code, and reuse it only to retry that one request.
India (DLT)
An Indian carrier scrubs every message against a template registered on the DLT
registry. A +91 send with no registration attached is accepted by the provider
and dropped on the way to the handset — you get a message id for a code nobody
received, which is the worst possible outcome for an OTP.
So a +91 verification takes the same dlt_template_id /v1/sms takes:
curl -X POST https://www.sendoka.com/api/v1/verifications \
-H "Authorization: Bearer $SENDOKA_API_KEY" \
-H "Content-Type: application/json" \
-d '{"channel":"sms","to":"+919812345678","from":"SENDOKA",
"template":"{{code}} is your verification code",
"dlt_template_id":"dlt_..."}'
The id must name a template of yours in approved status that the DLT provider
has issued provider-side ids for. The entity PEID and the provider's template id
travel with the send as the carrier's DestinationCountryParameters; the dlt_
id itself is recorded on the verification row, so a dropped-OTP support case can
start from the registration the send actually used.
Omitting it refuses the send before anything is written or any ceiling is
spent, with the same codes /v1/sms answers:
| code | meaning |
|---|---|
DLT_TEMPLATE_REQUIRED |
+91 destination, no dlt_template_id |
DLT_TEMPLATE_NOT_FOUND |
the id is not a template of yours |
DLT_TEMPLATE_NOT_APPROVED |
registered, but not yet approved |
DLT_TEMPLATE_NOT_PROVISIONED |
approved locally, but the provider has issued no entity/template ids — the carrier has nothing to scrub against |
All four are 422. The body your template renders has to match the registered
DLT content, variables included; the registry rejects paraphrases.
Set DLT_ENFORCE=false (dev only) and the gate steps aside entirely. Every
other destination ignores the field — email has no destination country, and a
non-+91 number resolves no registration, so nothing is stored on the row.
Check a code
curl -X POST https://www.sendoka.com/api/v1/verifications/ver_.../check \
-H "Authorization: Bearer $SENDOKA_API_KEY" \
-H "Content-Type: application/json" \
-d '{"code":"123456"}'
{ "id": "ver_...", "status": "approved" }
{ "id": "ver_...", "status": "denied", "reason": "invalid" }
reason is one of invalid, expired, not_found, max_attempts. Codes are single-use.
A 503 is ours, not the user's. It means the attempt could not be recorded — the code they are holding still works, and showing them "wrong code" would be a lie about whose fault it is. Retry.
A check takes no Idempotency-Key, so the SDKs do not resend one through a timeout or a 5xx on your behalf: a check whose first attempt did land would spend a second attempt, or answer not_found for the code it had just approved. On a 503, have the user submit the code again.
What is enforced
- Destination allowlisting. The premium NANP ranges and the satellite/network prefixes (+870, +878, +881/+8816, +882, +883) that pumping rings target are refused outright — before anything is written or sent.
- Two hourly ceilings, both spent up front: 5 per destination and 1,000 per org. Spending them after committing is what leaves a row nothing can satisfy. Test-key calls are counted in ceilings of their own, the same size: a test key asking for codes to a real user's number cannot use up that number's live allowance and lock the user out.
- Hash-only storage. sha256, never the code. A database read must not hand over a working credential.
- An atomic attempt cap. The claim is one statement with the cap in the
WHEREand the increment in aCASE, so a correct code spends no attempt and concurrent guesses cannot each pass a check they read as under the cap. Read-modify-write is not a counter — N racing guesses all read the same value and all write value+1, leaving the real bound on a ~1e6 space at the attacker's connection count.
Test mode
A sok_test_* key creates the verification without contacting a provider. An OTP flow is the thing most worth exercising repeatedly in CI, and doing that against a real handset costs money and a phone.
To assert against the code in a test, read it from your own test double — it is never returned by the API in any environment.
Each test verification is one of test mode's daily allowance of messages (rate limits), charged after the hourly ceilings above: a request those refuse spends none of it.
A test key checks only verifications a test key created. Every check spends one
of the verification's attempts and a correct one consumes it, so a live
verification is not_found to a test key, as an id it cannot see: a leaked test
key cannot lock a real user out of their code. A live key can check either.
Billing and retention
Metered on the channel it sent through, so a verification costs what the SMS or email it sent costs.
Rows are reaped after 2 days — shorter than the 7 days the signup sweeps use, because these are other people's end users. A code lives ten minutes and no support case needs the row a week later.