OTP messaging
The OTP API sends one-time passcodes — login codes, and pickup or delivery codes — to your users over WhatsApp, using WhatsApp’s official authentication messages with a one-tap Copy code button. You make one call to send a code and one call to check it. Novex handles the WhatsApp sender, the message template and the delivery reports.
It carries verification codes only. The message text is fixed by WhatsApp and cannot carry marketing, links or free text.
Before you start
- Keys. OTP keys are issued by Novex when you buy a credit package — you receive a live key and a test key. Each is shown once; store both in your secret store. Every request carries the key as a bearer token, exactly as on the rest of the API.
- Scope. OTP keys carry the
otp.sendscope, which covers every call on this page. - Credits. A code sent with the live key uses one credit, and the credit
comes back if the code fails or is never confirmed delivered (see
When a send says
failed). Checking a code, reading a status and reading your balance are free. Credits are prepaid and never expire. - Base URL.
https://nov3x.com/api/public/v1 - Phone numbers are in E.164 form: a
+, the country code, then the number, with no spaces or leading zero —+966501234567, not0501234567.
Quick start
Send a code. Novex generates a 6-digit code and sends it:
curl -sS -X POST https://nov3x.com/api/public/v1/otp/send \
-H "Authorization: Bearer $NOVEX_OTP_KEY" \
-H "Idempotency-Key: 3b0e8f52-6f0e-4d7c-9a51-2c1f0d4b7a11" \
-H "Content-Type: application/json" \
-d '{"to":"+966501234567","lang":"ar"}'{
"data": {
"id": "otp_5f0c9a1e7b3d2c4a8e6f1b0d",
"type": "login",
"status": "sent",
"expiresAt": "2026-10-01T09:10:00.000Z",
"balance": 999
}
}Your user receives the code on WhatsApp and types it into your app. Check it:
curl -sS -X POST https://nov3x.com/api/public/v1/otp/verify \
-H "Authorization: Bearer $NOVEX_OTP_KEY" \
-H "Content-Type: application/json" \
-d '{"id":"otp_5f0c9a1e7b3d2c4a8e6f1b0d","code":"482913"}'{ "data": { "valid": true, "status": "verified" } }The same flow in Node.js (18 or later — fetch and crypto.randomUUID are
built in):
const BASE = 'https://nov3x.com/api/public/v1'
const headers = {
Authorization: `Bearer ${process.env.NOVEX_OTP_KEY}`,
'Content-Type': 'application/json',
}
// 1. Send. Keep the Idempotency-Key with the attempt, so a retry reuses it.
export async function sendCode(to, lang = 'en') {
const res = await fetch(`${BASE}/otp/send`, {
method: 'POST',
headers: { ...headers, 'Idempotency-Key': crypto.randomUUID() },
body: JSON.stringify({ to, lang }),
})
const body = await res.json()
if (res.status !== 202) throw new Error(`${res.status} ${body.error?.code}`)
if (body.data.status === 'failed') throw new Error(`not sent: ${body.data.error}`)
return body.data.id // store this against your user's login attempt
}
// 2. Check what the user typed.
export async function checkCode(id, code) {
const res = await fetch(`${BASE}/otp/verify`, {
method: 'POST',
headers,
body: JSON.stringify({ id, code }),
})
const body = await res.json()
if (!res.ok) throw new Error(`${res.status} ${body.error?.code}`)
return body.data.valid === true
}Send a code
POST /api/public/v1/otp/send| Field | Type | Required | Meaning |
|---|---|---|---|
to | string | yes | The recipient’s WhatsApp number, E.164 (+966501234567). |
type | string | no | login (default), pickup or delivery. Sets how long the code is valid — see Message types. |
lang | string | no | en (default) or ar. The language of the WhatsApp message. |
code | string | no | Send your own code: 4 to 8 digits. Leave it out and Novex generates a 6-digit code — only a Novex-generated code can be checked here. |
The response is 202 Accepted, inside the usual data envelope:
| Field | Meaning |
|---|---|
id | The message id, otp_…. Keep it: you need it to check the code and to read the status. |
type | The type you sent (login when you left it out). |
status | sent — WhatsApp accepted it. failed — see error and the table below. |
error | Only when status is failed. |
expiresAt | When the code stops being accepted: 10 minutes after sending for login, 90 minutes for pickup and delivery. The WhatsApp message says the same. |
balance | Your credits after this send. |
After sent, follow delivery by reading the message.
Idempotency-Key is required. A send without one is refused with
400 IDEMPOTENCY_KEY_REQUIRED. A send
spends a credit and puts a message on someone’s phone, so a retry must not do
either twice. Generate a UUID before the first attempt and reuse it for every
retry of that attempt: a retry with the same key and the same body returns
the original response — the same id — and is neither charged nor sent again.
A new key is a new code. The full rules are on
Idempotency.
Message types
type | For | Valid for |
|---|---|---|
login | Sign-in and account verification. The default. | 10 minutes |
pickup | Handing over an order at a counter or locker. | 90 minutes |
delivery | Confirming a delivery at the door. | 90 minutes |
Every type uses one credit per code (refunded if the code is not
delivered), and every type is checked the same
way. The WhatsApp message text is fixed by WhatsApp for every type; what
changes is the expiry line it shows, which always matches expiresAt. Ninety
minutes is the longest expiry WhatsApp allows. A login message also carries
WhatsApp’s “do not share this code” line; pickup and delivery messages do
not, because that code is meant to be handed to the courier or the counter.
A type whose WhatsApp message is not yet available on your account is refused
with 409 template_pending before anything is sent or charged. That
response is stored against its Idempotency-Key like any other, so retry with
a new key. The test key is never refused this way, so you can
build against every type straight away.
When a send says failed
error | What it means | Charged? |
|---|---|---|
rejected, rejected:<code>, invalid_input, not_configured | WhatsApp refused the message on the spot. Nothing was sent. | No — the credit is already back in balance. |
unconfirmed | We could not get WhatsApp’s answer (a network failure between Novex and WhatsApp). The message may still have been delivered, and its code still verifies if the user types it. | Until a receipt is due — with no delivery report by WhatsApp’s delivery window plus 5 minutes, the message becomes undelivered and the credit comes back. |
none — the send said sent; the message later reads failed | WhatsApp accepted the message, then reported it failed before delivery (for example, the number is not on WhatsApp). | No — the credit comes back automatically once the report arrives. |
An unconfirmed message reads failed in its status until
it is swept to undelivered (below). Let the user type the code if it
arrives; if it does not, send a new one with a new Idempotency-Key —
that is a new message and uses another credit.
A short pause on WhatsApp’s side
Some WhatsApp errors mean “not now” rather than “never”: 131042 (a short
pause on Novex’s WhatsApp account), 131000 (something went wrong at
WhatsApp), 130429 (rate limit) and 131016 (service unavailable). When a
send hits one of them — on the spot, or in a later failure report — Novex sends
the same code once more about 90 seconds later, as long as the code
still has at least a minute to live. You do nothing and are charged once:
- while it waits, the message reads
queued(a send response can sayqueuedfor this reason), and the code it carries does not change; - if the resend is accepted, the message goes on as any other:
sent, thendeliveredorread— still one credit; - if the resend fails too, or the code ran out first, the message is
failedand the credit comes back, once.
Every other error is final at once and is never resent — for example 131026
(the number cannot receive the message).
A failure on Novex’s own WhatsApp account is never yours to fix, so it carries
no reason: the message reads failed with error rejected (or no error,
when WhatsApp reported it later), and the credit comes back.
Never confirmed delivered
WhatsApp keeps trying to deliver a code for a fixed window — 10 minutes
for login, 15 minutes for pickup and delivery. A live message that
has no delivered or read report 5 minutes after that window closes
(15 minutes after the send for login, 20 for the others) moves to the
status undelivered, and its credit comes back automatically, once. This
covers messages still queued or sent and unconfirmed sends.
undelivered is final: a delivery report that arrives later does not change
the status and does not charge the credit again. The code itself keeps its
own expiry — if the message did reach the phone, the code still verifies.
A message that WhatsApp accepts (sent) and later reports as failed before
delivery — for example, a number that is not on WhatsApp — gets its credit
back automatically. The credit shows in
GET /otp/balance once WhatsApp’s report reaches us.
The balance and status in the send response describe the moment of the
send, and an Idempotency-Key retry returns that same original response, so
neither changes when the credit comes back: read the message for its status
and GET /otp/balance for your current credits. The code on such a message is
not marked failed — verify still answers pending until it expires — so
treat the message’s status as the signal and send a new code with a new
Idempotency-Key.
Check a code
POST /api/public/v1/otp/verify| Field | Type | Required | Meaning |
|---|---|---|---|
id | string | yes | The id returned by the send. |
code | string | yes | The code your user typed, as entered. |
The response is 200 whether or not the code is right — read valid.
status is the state of the code, not of the WhatsApp message:
{ "data": { "valid": false, "status": "pending" } }| What happened | valid | status |
|---|---|---|
| Right code, in time | true | verified |
| Wrong code, attempts left | false | pending |
| 5th wrong attempt, or any after it | false | locked |
After expiresAt (10 or 90 minutes) | false | expired |
| The message never went out | false | failed |
A code is single-use: once verified, checking it again returns
valid: false. A locked code is never accepted again, even with the right
digits. A code you supplied yourself (code on send) cannot be checked here —
you generated it, so you check it — and verify answers 409 not_verifiable.
Send a new code when a code has expired or locked; do not keep checking the old one. We never store a code in readable form — only a one-way hash of a code we generated — so nobody at Novex can read a code back, and neither can you.
Read a message
GET /api/public/v1/otp/messages/{id}{
"data": {
"id": "otp_5f0c9a1e7b3d2c4a8e6f1b0d",
"type": "login",
"to": "+9665******67",
"lang": "ar",
"status": "delivered",
"verification": "verified",
"testMode": false,
"createdAt": "2026-10-01T09:00:00.000Z",
"updatedAt": "2026-10-01T09:00:41.000Z",
"expiresAt": "2026-10-01T09:10:00.000Z"
}
}verification is the code’s state, as on verify. status is
the WhatsApp message’s, and it moves forward only (failed and undelivered
are final) — except that a message waiting for its
automatic resend goes back to queued:
status | Meaning |
|---|---|
queued | Accepted by Novex, not yet accepted by WhatsApp — or waiting for an automatic resend. |
sent | Accepted by WhatsApp. |
delivered | Arrived on the user’s phone. |
read | Opened by the user (only if they have read receipts on). |
failed | Not delivered. error says why — most often, the number is not on WhatsApp. |
undelivered | WhatsApp never confirmed delivery in time (details). Final; the credit came back. |
The number is masked in every response. You already know it; we do not echo personal data back where it is not needed.
Read your balance
GET /api/public/v1/otp/balance{ "data": { "balance": 1000, "currency": "credits" } }Credits never expire. To top up, contact us and we add the new package to the same balance.
Test key
The test key takes the same requests and returns the same shapes, and it never sends a WhatsApp message and never uses a credit:
sendreturns a realidwithstatus: "delivered"straight away, and the message readstestMode: true.verifyaccepts the code000000for a test message — and only that code, even if you sent your owncode: a test message never answersnot_verifiable.- A test code is not single-use, never locks and never expires:
000000verifies every time, and wrong codes can be tried without limit. Test your single-use and lock-out handling with the live key. - The per-number limit does not apply to test sends; the per-key limit does.
balancedoes not change.- Every
typeis accepted, including one whose live WhatsApp message is not available yet (409 template_pendingon the live key).
Build and test your integration against the test key, then swap in the live
key. Keep them apart — the test key accepts 000000, so a test key in
production would let anyone in. A test key begins nvx_test_ (one created
before 3 October 2026 begins nvx_live_; the key list still marks it Test).
Errors
Errors use the standard envelope:
{ "error": { "code": "…", "message": "…" } }. Match on code.
| Status | Code | What happened |
|---|---|---|
| 400 | invalid_phone | to is not an E.164 number. |
| 400 | IDEMPOTENCY_KEY_REQUIRED | A send without an Idempotency-Key. |
| 401 | UNAUTHENTICATED | No key, or a revoked or unknown key. |
| 402 | insufficient_credits | Your balance is 0. Nothing was sent. Top up, then retry with a new Idempotency-Key. |
| 403 | FORBIDDEN | The key does not carry otp.send, or your Novex account is not active. |
| 404 | NOT_FOUND | No such message for your account. |
| 409 | not_verifiable | verify on a message sent with your own code. |
| 409 | template_pending | send with a type whose WhatsApp message is not available yet. Nothing was sent or charged; error.details.type names it. Retry later with a new Idempotency-Key. |
| 422 | UNPROCESSABLE_ENTITY | The body is not valid JSON, or a field is wrong: lang not en/ar, type not one of the three, code not 4–8 digits, a missing id. error.details names the field. |
| 429 | RATE_LIMITED | Too many requests — honour Retry-After. See the limits below. |
The idempotency codes on Errors apply to send as to
any other write.
Limits
| What | Limit |
|---|---|
| Sends and checks, per key | 30 per minute, together |
| Message and balance reads | 600 per minute, per key |
| Codes to one phone number | 5 per 15 minutes |
| Code lifetime | 10 minutes (login), 90 minutes (pickup, delivery) |
| Checks per code | 5 wrong attempts, then locked |
The per-number limit protects your users and your credits: a script hammering your sign-up page with one victim’s number is stopped after five codes.
Good to know
- The sender is Novex. Codes arrive from the Novex WhatsApp business account, and WhatsApp’s fixed message text does not name your app. Tell your users on the screen where they enter the code that it arrives on WhatsApp from Novex.
- The user needs WhatsApp on that number. A number without WhatsApp ends
as
failed. - Codes only. Promotional or marketing content is not permitted, and the fixed message format makes it impossible.
- Support: ahmed@nov3x.com.