API Reference

One HTTP POST sends an OTP to any Vietnamese mobile.
Base URL: https://nexoroute.dev/api/v1

01Quickstart

1. Sign in at nexoroute.dev/auth/sign-in — first signup credits your wallet with 5 free test OTPs.

2. Generate an API key at /dashboard/api-keys — the plaintext is shown once, format sk_live_….

3. Send your first OTP:

bash
curl -X POST https://nexoroute.dev/api/v1/send-otp \
  -H "Authorization: Bearer sk_live_..." \
  -H "Content-Type: application/json" \
  -d '{"phone":"+84981234567","code":"123456"}'

The phone receives a voice call reading the code. Status updates are delivered to your registered webhook URL (see Outbound webhooks) or via GET /deliveries/:id.

02Authentication

Every request must include an Authorization header with your API key:

http
Authorization: Bearer sk_live_8f2e…1c4d

Keys are scoped to a single account. Generate, list and revoke them at /dashboard/api-keys. Keys are SHA-256 hashed before storage — there is no way to recover a lost key.

Treat keys as production secrets. Never commit them to source control or paste them into a chat. If a key leaks, revoke it immediately and generate a new one.

03Send OTP

POST/api/v1/send-otp

Dispatches a numeric OTP code via Vietnamese carrier voice call.

Request body

phone
string · required
E.164 phone number. Must start with +84 and be a valid Vietnamese mobile.
code
string · required
4–8 digits. Letters or symbols return 400 invalid_code.

Response — 202 Accepted

json
{
  "ok": true,
  "id": "8f2e1c4d-...",
  "carrier": "viettel",
  "status": "sent",
  "cost": 0.022,
  "balance": 9.978
}

Status starts as sent. The final state (delivered or failed) lands in your webhook within ~25-60s.

04Check delivery

GET/api/v1/deliveries/{id}

Polls the current state of a single delivery. Useful when you don't use webhooks or as a fallback.

bash
curl https://nexoroute.dev/api/v1/deliveries/8f2e1c4d-... \
  -H "Authorization: Bearer sk_live_..."
json
{
  "ok": true,
  "id": "8f2e1c4d-...",
  "phone": "+84981234567",
  "carrier": "viettel",
  "status": "delivered",
  "cost": 0.022,
  "error_code": null,
  "carrier_status": "success",
  "created_at": "2026-05-25T10:00:00Z"
}

Returns 404 not_foundif the id doesn't belong to your account.

05Wallet balance

GET/api/v1/balance
json
{
  "ok": true,
  "currency": "USD",
  "balance": 9.978,
  "bonus_credit": 2.000,
  "free_credit": 0.066,
  "total": 12.044
}

Spend order is free_creditbonus_credit balance. Bonus and free credit are spend-only (non-withdrawable); real cash balance is refundable on account closure.

06Outbound webhooks

Register HTTPS endpoints at /dashboard/webhooks. When an OTP reaches a terminal state we POST a signed event to every active URL.

Request

http
POST https://your-app/webhooks/nexo
Content-Type: application/json
User-Agent: NEXO-ROUTE/1.0
X-NEXO-Signature: sha256=<hex>
X-NEXO-Event-Type: otp.delivered
X-NEXO-Delivery-Id: <uuid>

{
  "type": "otp.delivered",
  "id": "8f2e1c4d-...",
  "phone": "+84981234567",
  "carrier": "viettel",
  "status": "delivered",
  "cost": 0.022,
  "error_code": null,
  "created_at": "2026-05-25T10:00:00Z",
  "completed_at": "2026-05-25T10:00:25Z"
}

Verify the signature

js
import crypto from 'node:crypto';

// Done ONCE on startup — your plaintext secret never leaves your server.
const HASHED_SECRET = crypto
  .createHash('sha256')
  .update(process.env.NEXO_WEBHOOK_SECRET, 'utf8')
  .digest('hex');

export function verify(rawBody, signatureHeader) {
  const provided = signatureHeader.replace(/^sha256=/, '');
  const expected = crypto
    .createHmac('sha256', HASHED_SECRET)
    .update(rawBody, 'utf8')
    .digest('hex');
  return crypto.timingSafeEqual(
    Buffer.from(provided, 'hex'),
    Buffer.from(expected, 'hex')
  );
}
Respond HTTP 2xx within 5 seconds. We send once — no automatic retry. Failed deliveries surface on the dashboard for manual replay.

07IP allowlist

Each API key may optionally restrict source IPs. Empty allowlist = any IP accepted (default). Configure per key at /dashboard/api-keys → click the IP allowlist pill.

Single IPv4
exact match
203.0.113.5
IPv4 CIDR
range match
10.0.0.0/8, 192.168.1.0/24
Multiple
any-of
Add up to 50 entries per key. A request matching any one is allowed.

We resolve the client IP via Cloudflare-Connecting-IP, X-Forwarded-For, then the connection peer — in that order. Disallowed requests return 403 ip_not_allowed and include the detected IP in the response so you can debug.

08Referral program

Every account gets a permanent referral link at /dashboard/referrals. Anyone who lands on the site via your link gets attributed to you on signup (90-day cookie window).

Link format

bash
https://nexoroute.dev/en?ref=<your-code>

How attribution works

Land via ref link
step 1
Visitor lands on any page with ?ref=<code> → 90-day cookie set.
Sign up
step 2
On their first signup, the database trigger reads the cookie + creates a permanent referrals row linking you ↔ them.
Earn commission
step 3
Every OTP they successfully deliver accrues 10% of the OTP price to your pending balance — counted on the full OTP cost, even if they paid with bonus credit.
Payout
step 4
Processed monthly to the wallet you nominate in the dashboard. No cap, no expiry while the referred account exists.
Self-referral (signing up via your own link) is silently rejected. Bot signups + abuse trigger commission revocation per the Terms.

09Error codes

200 / 202
success
Request accepted.
400 invalid_phone
client
Phone is not valid E.164 Vietnamese (+84…).
400 invalid_code
client
Code missing, non-numeric, or outside 4–8 digits.
401 missing_auth
client
No Authorization header.
401 invalid_key
client
Key was revoked or never existed.
402 insufficient_balance
client
Wallet ran out. Response includes deposit_url to the top-up page.
403 ip_not_allowed
client
Caller IP not on this key's allowlist. Response includes detected IP.
429 no_route · rate_limited
upstream
Carrier 30s per-phone rate limit. Response includes retry_after_seconds.
500 internal
server
Our problem. Logged + alerted automatically.
502 no_route
upstream
Upstream carrier rejected dispatch. Wallet refunded automatically.

10Rate limits

Per phone number
upstream
1 OTP per 30 seconds per recipient phone. Enforced by upstream carrier.
Per API key
soft
No hard cap. Suspicious bursts trigger automated review.
API key creation
account
Max 10 active keys per account at any time.
Webhook endpoints
account
Max 5 active webhook URLs per account.

11Examples

Node.js (fetch)

js
const res = await fetch('https://nexoroute.dev/api/v1/send-otp', {
  method: 'POST',
  headers: {
    'Authorization': `Bearer ${process.env.NEXO_KEY}`,
    'Content-Type': 'application/json'
  },
  body: JSON.stringify({ phone: '+84981234567', code: '482917' })
});
const data = await res.json();
if (!data.ok) throw new Error(data.error);
console.log('Queued', data.id, 'via', data.carrier);

Python (httpx)

python
import os, httpx

r = httpx.post(
    'https://nexoroute.dev/api/v1/send-otp',
    headers={'Authorization': f'Bearer {os.environ["NEXO_KEY"]}'},
    json={'phone': '+84981234567', 'code': '482917'},
    timeout=10.0,
)
r.raise_for_status()
data = r.json()
print('Queued', data['id'], 'via', data['carrier'])

Shell (curl)

bash
curl -X POST https://nexoroute.dev/api/v1/send-otp \
  -H "Authorization: Bearer $NEXO_KEY" \
  -H "Content-Type: application/json" \
  -d '{"phone":"+84981234567","code":"482917"}'

Found a typo or missing detail? [email protected]

NEXO ROUTE · OTP gateway from Vietnam · 2026