Verify / OTP
Two surfaces, both authenticated with your API key.
SMS OTP owns the whole lifecycle — it mints the code, sends it, and verifies it:
| Action | Route |
|---|---|
| Send | POST /sms/otp |
| Verify | POST /sms/otp/{otpId}/verify |
| Resend | POST /sms/otp/{otpId}/resend |
| Read session | GET /sms/otp/{otpId} |
Standalone verify checks a code the platform issued elsewhere — during signup, for example — and is keyed by ref_code, not by a session id:
| Action | Route |
|---|---|
| Verify a code | POST /otp/verify |
Which one do you want?
Starting from scratch, use POST /sms/otp. Use /otp/verify only when something else already issued the code and handed you its ref_code — a code you generated and sent over your own SMS cannot be verified there, because the lookup finds nothing.
Send an OTP
curl -X POST "$ME_BASE/sms/otp" \
-H "API-Key: $ME_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
-d '{
"mode": "auto",
"operator": "smart",
"sender_name": "YourBrand",
"recipient": "+66886578553",
"config": { "length": 6, "type": "numeric", "validity_seconds": 300 }
}'{
"id": "0f3c…",
"mode": "auto",
"recipient": "+66886578553",
"ref_code": "A1B2",
"expires_at": "2026-09-19T04:21:00Z",
"status": "sent",
"message_id": "tx-…"
}Keep the id — it names the session for verify, resend and read. Show the user ref_code. The code itself is never returned: only a peppered hash is stored, so nothing can give it back to you.
| Field | Notes |
|---|---|
mode | auto (we mint and verify) or provided (you mint; transport only). |
operator | ais, dtac, true_corp, or smart to let the SMS layer route. |
sender_name | A name approved for your organization. See below. |
recipient | E.164. |
message_template | Must contain . Omitted, the default template is used. |
message_template_id | A saved template of yours, by id. Mutually exclusive with message_template. |
config | auto only — length 4–10, type, validity_seconds 1–3600. |
otp / ref_code | provided only — your code and your reference. |
sender_name is the same contract as SMS: an unapproved name is rejected with 403, not silently substituted. List yours with GET /organization/{organizationId}/sender-names — that route takes a user token, not an API-Key; see Sender Names.
That list also carries the platform default, marked is_platform_default: true — a name any organization may send under with no approval of its own. It exists only when the deployment configures one, so read it from the list rather than assuming there is one.
Message templates
is required — a message without it bills for an SMS the user cannot act on. is optional.
// Inline
{ "message_template": "{{otp}} is your code. Ref: {{ref_code}}" }
// Or by id, from /template-message/organization/{organizationId}
{ "message_template_id": "8f1c…" }Sending both is rejected with 400. Two sources for one value leave your intent unknowable, and guessing would ship a message you never wrote.
A template id is resolved to its text before the message is rendered. So editing that template afterwards cannot rewrite a message already sent — or a resend of it, which replays the text as it was at first send.
Retrying safely
Send Idempotency-Key and a replay returns the original OTP instead of minting a new one. Naming a template by id and pasting its text count as the same request, because the key covers the resolved message.
Reusing a key with a genuinely different body is rejected — replaying the first response there would report a code as delivered that never was.
Verify an SMS OTP
curl -X POST "$ME_BASE/sms/otp/$OTP_ID/verify" \
-H "API-Key: $ME_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "otp": "884213" }'{ "id": "0f3c…", "verified": false, "status": "pending", "attempts_remaining": 2 }A wrong code is 200, not 4xx
The request was well formed; only the code was wrong. Branch on verified, not on the status code.
attempts_remaining is absent once the session locks — which is not the same as 0. A UI rendering "N attempts left" should show nothing there.
Verify a code issued elsewhere
curl -X POST "$ME_BASE/otp/verify" \
-H "API-Key: $ME_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"phone_number": "+66886578553",
"code": "884213",
"ref_code": "A1B2",
"purpose": "signup"
}'| Field | Notes |
|---|---|
phone_number | E.164. |
code | The code the user entered. |
ref_code | The reference issued when the code was created. |
email | Optional. |
purpose | Optional claim namespace, e.g. signup, welcome_credit. |
{
"is_valid": true,
"status": "verified",
"message": "OK",
"is_new": true
}is_new may be absent — that is not false
is_new answers "was this phone number claimed for this purpose for the first time?" It is omitted when there was nothing to answer: no purpose was sent, or the claim store could not be reached.
That distinction is load-bearing if you gate a reward on it:
// Correct: absent ⇒ withhold, don't pay out on an unknown.
if (res.is_new === true) grantWelcomeCredit();Treating absent as false is safe. Treating it as "not new" and then paying out on the inverse (if (!res.is_new)) pays on every error instead of none.
The API key identifies the project
The key resolves to exactly one project, and the claim is recorded against it. A key that does not resolve to a project is rejected with 401 — see Authentication.
