Skip to Content
APIOTP messaging

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.send scope, 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, not 0501234567.

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
FieldTypeRequiredMeaning
tostringyesThe recipient’s WhatsApp number, E.164 (+966501234567).
typestringnologin (default), pickup or delivery. Sets how long the code is valid — see Message types.
langstringnoen (default) or ar. The language of the WhatsApp message.
codestringnoSend 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:

FieldMeaning
idThe message id, otp_…. Keep it: you need it to check the code and to read the status.
typeThe type you sent (login when you left it out).
statussent — WhatsApp accepted it. failed — see error and the table below.
errorOnly when status is failed.
expiresAtWhen the code stops being accepted: 10 minutes after sending for login, 90 minutes for pickup and delivery. The WhatsApp message says the same.
balanceYour 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

typeForValid for
loginSign-in and account verification. The default.10 minutes
pickupHanding over an order at a counter or locker.90 minutes
deliveryConfirming 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

errorWhat it meansCharged?
rejected, rejected:<code>, invalid_input, not_configuredWhatsApp refused the message on the spot. Nothing was sent.No — the credit is already back in balance.
unconfirmedWe 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 failedWhatsApp 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 say queued for this reason), and the code it carries does not change;
  • if the resend is accepted, the message goes on as any other: sent, then delivered or read — still one credit;
  • if the resend fails too, or the code ran out first, the message is failed and 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
FieldTypeRequiredMeaning
idstringyesThe id returned by the send.
codestringyesThe 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 happenedvalidstatus
Right code, in timetrueverified
Wrong code, attempts leftfalsepending
5th wrong attempt, or any after itfalselocked
After expiresAt (10 or 90 minutes)falseexpired
The message never went outfalsefailed

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:

statusMeaning
queuedAccepted by Novex, not yet accepted by WhatsApp — or waiting for an automatic resend.
sentAccepted by WhatsApp.
deliveredArrived on the user’s phone.
readOpened by the user (only if they have read receipts on).
failedNot delivered. error says why — most often, the number is not on WhatsApp.
undeliveredWhatsApp 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:

  • send returns a real id with status: "delivered" straight away, and the message reads testMode: true.
  • verify accepts the code 000000 for a test message — and only that code, even if you sent your own code: a test message never answers not_verifiable.
  • A test code is not single-use, never locks and never expires: 000000 verifies 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.
  • balance does not change.
  • Every type is accepted, including one whose live WhatsApp message is not available yet (409 template_pending on 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.

StatusCodeWhat happened
400invalid_phoneto is not an E.164 number.
400IDEMPOTENCY_KEY_REQUIREDA send without an Idempotency-Key.
401UNAUTHENTICATEDNo key, or a revoked or unknown key.
402insufficient_creditsYour balance is 0. Nothing was sent. Top up, then retry with a new Idempotency-Key.
403FORBIDDENThe key does not carry otp.send, or your Novex account is not active.
404NOT_FOUNDNo such message for your account.
409not_verifiableverify on a message sent with your own code.
409template_pendingsend 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.
422UNPROCESSABLE_ENTITYThe 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.
429RATE_LIMITEDToo many requests — honour Retry-After. See the limits below.

The idempotency codes on Errors apply to send as to any other write.

Limits

WhatLimit
Sends and checks, per key30 per minute, together
Message and balance reads600 per minute, per key
Codes to one phone number5 per 15 minutes
Code lifetime10 minutes (login), 90 minutes (pickup, delivery)
Checks per code5 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.
Last updated on