Send OTP codes
Goal: email a one-time passcode (OTP) to a user, find the email that carried it, and verify what the user submits — handling expiry, resend cooldowns, daily caps and the attempt budget correctly.
Prerequisites: a verified sending domain (see Verify a sending domain); credits on your account; an API key with the templates:write, otp:send, otp:verify and messages:read scopes; Templates for message classes.
Steps
1. Prepare an active OTP template (once)
OTP mail uses a template with message_class: "critical" that renders the code as {{custom.otp_code}} and, optionally, its lifetime in minutes as {{custom.ttl_minutes}}:
curl -X POST https://emails.routeon.io/api/v1/templates
-H "Authorization: Bearer <API_KEY>"
-H "Content-Type: application/json"
-d '{
"name": "Login code",
"format": "html",
"source": "<html><body><p>Your code is <strong>{{custom.otp_code}}</strong>.</p><p>It expires in {{custom.ttl_minutes}} minutes.</p></body></html>",
"subject": "Your login code",
"message_class": "critical"
}'
201 Created returns the template with status: "draft" and current_version: 1. Verify and activate it exactly as for a transactional template — POST /templates/{id}/verify with {"version": 1} until the verdict is approved, then PATCH /templates/{id} with {"status": "active"} — see Send a transactional email, step 1.
The platform fills custom.otp_code and custom.ttl_minutes itself; any values you pass for them are ignored. ttl_minutes is ttl_seconds divided by 60, rounded up, and at least 1.
2. Request a code
curl -X POST https://emails.routeon.io/api/v1/otp/request
-H "Authorization: Bearer <API_KEY>"
-H "Content-Type: application/json"
-H "Idempotency-Key: login-7f3a-request-1"
-d '{
"domain_id": "<DOMAIN_ID>",
"to": "user@example.com",
"template_id": "<OTP_TEMPLATE_ID>",
"purpose": "login",
"code_length": 6,
"ttl_seconds": 300,
"tags": ["otp", "login"],
"metadata": { "session": "<SESSION_REF>" }
}'
Expected response (202 Accepted):
{
"status": "success",
"http_code": 202,
"payload": {
"otp_id": "<OTP_ID>",
"expires_at": "<EXPIRES_AT_ISO8601_TIMESTAMP>"
},
"meta": {}
}
- The code itself is generated by the platform and never returned by the API; it only travels in the email.
purpose(default"default") is trimmed and lower-cased and must then match^[a-z0-9_.:-]{1,64}$. Use one value per flow (login,password_reset) — the rules below are counted per recipient and purpose.code_lengthis 6–10 digits (default 6);ttl_secondsis 30–3600 (default 300).from,reply_to,tagsandmetadatawork as in Send a transactional email; withoutdomain_id, the domain offromis used.
Limits applied to every request:
| Rule | Behaviour |
|---|---|
| One pending code per recipient and purpose | A new request replaces the previous code, which then answers 410 otp_expired on verify. |
| Resend cooldown | 60 seconds per recipient and purpose: a request inside it is 409 otp_resend_cooldown with meta.details.retry_after_seconds. |
| Daily cap per recipient | 20 codes per recipient per UTC day across all purposes (default): 429 otp_recipient_daily_cap, with retry_after_seconds counting down to the next UTC midnight. Nothing is charged. |
If the address is on your suppression list (after a bounce, a complaint or a manual entry), the request is still accepted, but the email is not sent: its message ends as suppressed and the credit is refunded. Check the message (next step) when a user reports that no code arrived.
3. Find the email that carried the code
The response has an otp_id, not a message id. To follow the email itself, list messages for the recipient:
curl "https://emails.routeon.io/api/v1/messages?email=user@example.com&per_page=5"
-H "Authorization: Bearer <API_KEY>"
OTP emails carry "source_type": "otp". You can also filter by one of your tags (?tag=login) or subscribe to email.* webhooks, whose payload includes source_type, tags and metadata — see Receive webhooks.
4. Verify the submitted code
Identify the code by exactly one of otp_id or to:
curl -X POST https://emails.routeon.io/api/v1/otp/verify
-H "Authorization: Bearer <API_KEY>"
-H "Content-Type: application/json"
-d '{
"otp_id": "<OTP_ID>",
"code": "483920"
}'
Expected response on a correct code (200 OK):
{
"status": "success",
"http_code": 200,
"payload": { "verified": true, "attempts_remaining": 0 },
"meta": {}
}
A code allows 5 verify attempts. A wrong code is also 200, with verified: false and the remaining count; keep prompting while attempts_remaining is above 0. A correct code is consumed immediately, so attempts_remaining is always 0 when verified is true. A code that is not 4–10 digits is 422 and does not use up an attempt.
When you verify by to, also pass the purpose you requested with; otherwise the newest live code for that address across all purposes is checked, and an attempt can be spent on another flow’s code.
Verify
POST /otp/verify answers verified: true for the right code. To confirm that the email went out, check its message from step 3 — sent or delivered means it left the platform; suppressed or failed means the user never received it.
Common failures
| Symptom | Cause | Fix |
|---|---|---|
422 template_not_active |
Template missing, not active, or edited back to draft |
Verify and activate it (step 1) |
422 template_class_mismatch |
The template’s message_class is not critical |
Use a critical template |
422 invalid_variable |
A value in variables does not fit the template’s declared variables |
Fix the value; meta.validation_error names it |
422 invalid_purpose |
purpose does not match ^[a-z0-9_.:-]{1,64}$ after lower-casing |
Use letters, digits, _ . : - only |
403 domain_not_verified |
domain_id is not a verified domain of your account |
Finish Verify a sending domain |
402 INSUFFICIENT_CREDITS / CREDIT_PROTECTION |
Not enough credits | Top up; meta.details shows the shortfall |
409 otp_resend_cooldown |
A code for this recipient and purpose was sent less than 60 seconds ago | Wait meta.details.retry_after_seconds |
409 otp_request_in_progress |
A concurrent request for the same recipient and purpose won | Retry; nothing was charged |
409 otp_reserve_not_applied |
The credit reservation was not applied — often an Idempotency-Key already used for another charge |
Nothing was charged or sent; retry, with a new key if you reused one |
429 otp_recipient_daily_cap |
20 codes to this address today (UTC) | Wait until retry_after_seconds elapses |
Message suppressed, no email received |
The address is suppressed | Check GET /suppressions; remove the entry only with a new lawful basis |
410 otp_expired |
TTL elapsed, or a newer request replaced this code | Request a new code |
410 otp_not_found |
Unknown otp_id, or no code for this to |
Request a new code |
410 otp_already_used |
The code was already verified successfully | Do not retry this code |
410 otp_attempts_exceeded |
All 5 attempts are spent — permanent, not a rate limit | Request a new code |
422 on /otp/verify |
Both otp_id and to sent, or code is not 4–10 digits |
Send exactly one selector and a digits-only code |
429 RATE_LIMITED |
Request rate limit exceeded (distinct from otp_attempts_exceeded) |
Wait for Retry-After seconds; branch on meta.details.code |