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"}'
{ "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 |
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 |
configured sender | |
subject |
Your verification code |
email only |
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.
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.
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.
- 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.
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.