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

  1. 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.
  2. 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.
  3. Hash-only storage. sha256, never the code. A database read must not hand over a working credential.
  4. An atomic attempt cap. The claim is one statement with the cap in the WHERE and the increment in a CASE, 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.