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_length is 6–10 digits (default 6); ttl_seconds is 30–3600 (default 300).
  • from, reply_to, tags and metadata work as in Send a transactional email; without domain_id, the domain of from is 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