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:
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:
Authorization: Bearer sk_live_8f2e…1c4dKeys 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.
03Send 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
{
"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
Polls the current state of a single delivery. Useful when you don't use webhooks or as a fallback.
curl https://nexoroute.dev/api/v1/deliveries/8f2e1c4d-... \
-H "Authorization: Bearer sk_live_..."{
"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
{
"ok": true,
"currency": "USD",
"balance": 9.978,
"bonus_credit": 2.000,
"free_credit": 0.066,
"total": 12.044
}Spend order is free_credit → bonus_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
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
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')
);
}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
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. |
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)
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)
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)
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"}'