Verify a one-time passcode

POST https://emails.routeon.io/api/v1/otp/verify

Consume one verify attempt against a live code.

Attempt budget — once the per-code attempt budget is spent, otp_attempts_exceeded answers 410 Gone, not 429: the state is permanently terminal, waiting never makes the code work again and no Retry-After is emitted for it. Spending the last attempt also settles the code — it does not sit pending at attempts=5/5 forever. Branch on meta.details.code, not on the HTTP status. A 429 from this endpoint only ever means the request rate limit (details.code=rate_limited).

Selector semantics — supply exactly one of otp_id or to. Both selectors resolve the same row state to the same meta.details.code. A by-to lookup skips codes whose TTL has lapsed and, when purpose is supplied, is scoped to that purpose — so verifying a login code cannot burn an attempt on a concurrent password_reset code for the same address.

Code validation — code is trimmed and must be 4–10 ASCII digits. A structurally impossible value is 422 and does NOT consume an attempt.

Request body

{
    "otp_id": "00000000-0000-0000-0000-000000000000",
    "to": "user@example.com",
    "purpose": "string",
    "code": "string"
}

Responses

200 OTP verification result
410 Resource is no longer available. meta.reason=GONE and meta.details.code names the cause — on POST /otp/verify: otp_not_found (unknown id, or no code at all for the address), otp_expired (TTL elapsed, or invalidated by a newer request for the same identifier+purpose), otp_already_used (code was already consumed by a successful verify), otp_attempts_exceeded (the per-code attempt budget is spent). otp_not_found and otp_expired mean "request a fresh code"; otp_already_used and otp_attempts_exceeded mean stop retrying THIS code — otp_attempts_exceeded additionally means a new code must be requested. otp_attempts_exceeded is a permanently terminal state, so it answers 410 rather than a retry-oriented 429; branch on meta.details.code, not on the HTTP status. Both request selectors (otp_id and to) resolve to the same code for the same row state — a by-`to` verify of a consumed code returns otp_already_used, not otp_not_found.
422 Body/query/params failed validation. meta.reason=VALIDATION_ERROR, meta.validation_error[] carries {field, message} per failure ('' field = show the message as-is). Always 422 — a request that parsed but broke a rule is never 400. Sender resolution reports through meta.details.code: invalid_from_address (the `from` string is not `local@domain` or `Name <local@domain>`), from_domain_mismatch (its domain is not a verified domain of the tenant), domain_required (neither `domain_id` nor `from` was given), sender_not_configured (no route and no default profile resolves for this send) and sender_not_found (the referenced profile id is unknown to the tenant). content_moderation_rejected: POST /transactional/send and /transactional/send-batch verify the template's active version before accepting the send, and a rejected verdict fails the request. The check cannot be switched off.
429 Too many requests. meta.reason=RATE_LIMITED and meta.details.code names the source; on most routes the only source is rate_limited — the per-key/per-tenant token bucket (applies to every authenticated tenant-scoped request; Retry-After and X-RateLimit-* headers are set). otp_attempts_exceeded does not appear here: a spent attempt budget is permanently terminal, not a throttle, so POST /otp/verify answers 410 Gone for it. See the Gone response.

Example response · 200

{
    "status": "success",
    "http_code": 0,
    "payload": {
        "verified": true,
        "attempts_remaining": 0
    },
    "meta": []
}

Example response · 410

{
    "status": "error",
    "http_code": 0,
    "payload": [],
    "meta": {
        "reason": "VALIDATION_ERROR",
        "validation_error": [
            {
                "field": "string",
                "message": "string"
            }
        ],
        "details": []
    }
}

Example response · 422

{
    "status": "error",
    "http_code": 0,
    "payload": [],
    "meta": {
        "reason": "VALIDATION_ERROR",
        "validation_error": [
            {
                "field": "string",
                "message": "string"
            }
        ],
        "details": []
    }
}

Example response · 429

{
    "status": "error",
    "http_code": 0,
    "payload": [],
    "meta": {
        "reason": "VALIDATION_ERROR",
        "validation_error": [
            {
                "field": "string",
                "message": "string"
            }
        ],
        "details": []
    }
}