Dependency-free Node.js code for an Indian DLT-compliant SMS gateway. It covers native fetch with a real timeout, a client module that never double-sends, an Express OTP API with rate limits, a delivery-report route, bulk sends and Unicode segment counts. Every example uses the real AvanceZone API and was run on Node.js 24.
To send an SMS from Node.js, call fetch() with a POST to the gateway's send endpoint. Send a JSON body with the sender ID, recipient and message, and put your API key in an Authorization: Bearer header. Add AbortSignal.timeout(), because fetch's own limits are measured in minutes. In India the text must also match a DLT template approved for that sender ID. With AvanceZone the endpoint is /api/v2/sms/send, and a successful call returns a message_id for delivery tracking.
fetch and AbortSignal.timeout(). On Node 20.6+, node --env-file=.env app.mjs loads your key without the dotenv package.SMS_API_KEY. Never ship it to browser code: anyone could read it there and send SMS on your balance.AVNZON. Promotional headers are usually six digits. Since May 2025 handsets show a category suffix, so customers see something like VM-AVNZON-T. You still send AVNZON.The examples are ES modules (.mjs, or "type": "module" in package.json), because the first one uses top-level await.
// send-sms.mjs (ES module): SMS_API_KEY=xxxx node send-sms.mjs
const res = await fetch('https://whitelabled.securesmsc.com/api/v2/sms/send', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.SMS_API_KEY}`,
'Content-Type': 'application/json',
Accept: 'application/json',
},
body: JSON.stringify({
sender_id: 'AVNZON',
recipient: '919876543210', // 91 + 10-digit mobile
message: 'Your OTP for login is 482913. It is valid for 10 minutes. Do not share it with anyone. -AVNZON',
route: 'transactional',
}),
signal: AbortSignal.timeout(15_000), // give up after 15 s
});
console.log(res.status, await res.json());Unlike axios, fetch does not reject on HTTP errors. A 401 or 422 resolves normally, so always check res.ok or the success field. It rejects only when no response arrives: DNS failure, refused connection, reset or timeout.
{
"success": true,
"message": "SMS queued successfully",
"data": {
"message_id": 1849302,
"queue_id": 1849302,
"status": "queued",
"estimated_delivery": "2026-10-29T10:42:05.000000Z"
}
}| Outcome | Meaning | Retry? |
|---|---|---|
| 200 | Accepted and queued (not yet delivered) | Store data.message_id |
401 Invalid API key | Key missing, wrong or revoked | No |
400 Invalid sender ID | Header not approved on your account | No |
422 Validation failed / TEMPLATE_MISMATCH | Bad field, or text does not match a DLT template | No |
| 429, 502, 503 | Rate limit or gateway briefly unavailable | Yes, with back-off |
500, 504, timeout, ECONNRESET | The message may already be queued | Not automatically. Check first |
ENOTFOUND, ECONNREFUSED, UND_ERR_CONNECT_TIMEOUT | Never reached the gateway | Yes |
The table above turns into about sixty lines. The module retries only when a retry cannot cause a duplicate. Every other failure becomes one of two error types, so the caller can decide what to do:
// sms-client.mjs
const BASE_URL = 'https://whitelabled.securesmsc.com/api/v2';
// fetch() rejects with a TypeError whose `cause.code` says what went wrong.
// Only these codes prove the request never reached the gateway.
const NEVER_SENT = new Set(['ENOTFOUND', 'EAI_AGAIN', 'ECONNREFUSED', 'UND_ERR_CONNECT_TIMEOUT']);
// Statuses returned before the gateway processed anything. 500 and 504 are NOT here:
// they can arrive after a message was queued, so a send is never retried on them.
const RETRY_STATUS = new Set([429, 502, 503]);
export class SmsError extends Error {
constructor(status, body, retryable = false) {
super(`HTTP ${status}: ${body.error ?? 'error'}: ${body.message ?? ''}`);
this.name = 'SmsError';
this.status = status; // 0 = could not connect
this.errorCode = body.error_code ?? null; // e.g. 'TEMPLATE_MISMATCH'
this.details = body.errors ?? body.details ?? null;
this.retryable = retryable; // true = provably not sent, try later
}
}
export class SmsUncertainError extends Error {
constructor(cause) {
super(`Request may have been sent: ${cause.message}`, { cause });
this.name = 'SmsUncertainError';
}
}
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
export function createSmsClient({ apiKey, baseUrl = BASE_URL, timeoutMs = 15_000, maxAttempts = 3 }) {
async function request(method, path, body) {
for (let attempt = 1; ; attempt++) {
let res;
try {
res = await fetch(baseUrl + path, {
method,
headers: {
Authorization: `Bearer ${apiKey}`,
Accept: 'application/json',
...(body && { 'Content-Type': 'application/json' }),
},
body: body && JSON.stringify(body),
signal: AbortSignal.timeout(timeoutMs),
});
} catch (err) {
// TimeoutError or a reset connection: the gateway may already have the message.
if (!NEVER_SENT.has(err.cause?.code)) throw new SmsUncertainError(err);
if (attempt >= maxAttempts) throw new SmsError(0, { error: 'Connection failed', message: err.cause.code }, true);
await sleep(2 ** attempt * 250);
continue;
}
const data = await res.json().catch(() => ({ error: `Non-JSON response (${res.status})` }));
if (res.ok && data.success) return data;
const safe = RETRY_STATUS.has(res.status) || (method === 'GET' && res.status >= 500);
if (!safe && res.status >= 500) throw new SmsUncertainError(new Error(`HTTP ${res.status} on ${path}`));
const error = new SmsError(res.status, data, safe);
if (!safe || attempt >= maxAttempts) throw error;
const retryAfter = Number(res.headers.get('retry-after'));
await sleep(retryAfter > 0 ? retryAfter * 1000 : 2 ** attempt * 250);
}
}
return {
send: (to, message, senderId, extra = {}) =>
request('POST', '/sms/send', { sender_id: senderId, recipient: to, message, ...extra })
.then((r) => r.data), // { message_id, status, ... }
sendBulk: (recipients, message, senderId, extra = {}) =>
request('POST', '/sms/send-bulk', { sender_id: senderId, recipients, message, ...extra }),
status: (messageId) =>
request('GET', `/sms/status/${encodeURIComponent(messageId)}`).then((r) => r.data.status),
balance: () => request('GET', '/balance').then((r) => r.data),
};
}We checked the error codes against Node.js 24 before relying on them. An unknown host rejects with cause.code === 'ENOTFOUND', and a closed port with 'ECONNREFUSED'. An unroutable address gives 'UND_ERR_CONNECT_TIMEOUT' after undici's 10-second connect limit. The abort signal produces a TimeoutError with no cause, so it falls through to SmsUncertainError, which is what you want.
Keep timeoutMs above 10 seconds. undici's connect timeout is 10 seconds. With a longer abort timeout, a hung connection fails as UND_ERR_CONNECT_TIMEOUT (safe to retry), not as a TimeoutError (unsafe). Make the overall timeout shorter than that and you lose the distinction.
Usage: const sms = createSmsClient({ apiKey: process.env.SMS_API_KEY }); then await sms.send('919876543210', text, 'AVNZON', { dlt_template_id: '1107…' }). Create one client at start-up and share it.
The gateway compares every message with the DLT template approved for its header. Only the variables may change, and since TRAI's direction of 18 November 2025 each variable is tagged by type at registration: number, text, URL, app link, call-back number or email. Existing templates got 60 days to be updated, after which messages that fail tag validation are rejected.
{#numeric#}, {#alphanumeric#}, {#url#}, {#urlott#}, {#cbn#} and {#email#}. Vi and BSNL use {#num#}, {#alp#}, {#urg#}, {#uro#}, {#cbn#} and {#eml#}. See our variable tagging guide.{#var#} allows 30. Check your operator's portal, because the sources do not all agree.// templates.mjs
const PLACEHOLDER = /\{#(\w+)#\}/g; // {#var#}, {#numeric#}, {#num#}, {#url#} ...
const MAX_LEN = { var: 30, url: 120, urlott: 120, urg: 120, uro: 120 }; // others: 40
const DIGITS_ONLY = new Set(['numeric', 'num', 'number']);
export const TPL_OTP =
'Your OTP for login is {#numeric#}. It is valid for 10 minutes. Do not share it with anyone. -AVNZON';
export function fillTemplate(template, ...values) {
const slots = template.match(PLACEHOLDER) ?? [];
if (slots.length !== values.length) {
throw new Error(`template has ${slots.length} variables, got ${values.length}`);
}
let i = 0;
return template.replace(PLACEHOLDER, (_, tag) => { // a function: "$&" in values stays literal
const value = String(values[i++]);
const limit = MAX_LEN[tag] ?? 40;
if (value.length > limit) throw new Error(`{#${tag}#} value is ${value.length} chars; limit is ${limit}`);
if (DIGITS_ONLY.has(tag) && !/^\d+$/.test(value)) throw new Error(`{#${tag}#} must be digits only`);
return value;
});
}The helper throws an error instead of trimming, because a cut-off URL is a broken URL. If the gateway still answers TEMPLATE_MISMATCH, compare your constant with the portal character by character. Our DLT error codes page lists the usual causes.
A public "send me a code" endpoint is the most abused route in most apps. Bots use it to pump SMS to number ranges they profit from, and they brute-force codes. Rate limiting and hashing are part of the feature:
// otp-server.mjs (ES module): npm install express
import express from 'express';
import { createHmac, randomInt, timingSafeEqual } from 'node:crypto';
import { createSmsClient } from './sms-client.mjs';
import { fillTemplate, TPL_OTP } from './templates.mjs';
const sms = createSmsClient({ apiKey: process.env.SMS_API_KEY });
const PEPPER = process.env.OTP_PEPPER; // server-side secret, never stored with codes
const OTP_TTL_MS = 10 * 60_000;
const MAX_GUESSES = 5;
// In-memory state works for ONE process. With several instances or restarts,
// keep both maps in Redis (INCR + EXPIRE for counters, SET ... EX for codes).
const counters = new Map(); // key -> { count, resetAt }
const codes = new Map(); // phone -> { digest, expiresAt, guesses }
function allow(key, limit, windowMs) {
const now = Date.now();
const entry = counters.get(key);
if (!entry || entry.resetAt <= now) {
counters.set(key, { count: 1, resetAt: now + windowMs });
return true;
}
entry.count += 1;
return entry.count <= limit;
}
function normalisePhone(input) {
let digits = String(input ?? '').replace(/\D/g, '');
if (digits.length === 11 && digits.startsWith('0')) digits = digits.slice(1);
if (digits.length === 10) digits = `91${digits}`;
return /^91[6-9]\d{9}$/.test(digits) ? digits : null; // Indian mobiles start 6-9
}
const digest = (phone, code) => createHmac('sha256', PEPPER).update(`${phone}:${code}`).digest();
const app = express();
app.set('trust proxy', 1); // one reverse proxy in front: req.ip = client
app.use(express.json());
app.post('/otp/request', async (req, res) => {
const phone = normalisePhone(req.body?.phone);
if (!phone) return res.status(422).json({ error: 'Enter a valid Indian mobile number' });
// Rate-limit BEFORE generating or sending anything.
if (!allow(`n:${phone}`, 3, 10 * 60_000) || !allow(`ip:${req.ip}`, 20, 60 * 60_000)) {
return res.status(429).json({ error: 'Too many requests. Try again later.' });
}
const code = String(randomInt(0, 1_000_000)).padStart(6, '0'); // CSPRNG, not Math.random()
codes.set(phone, { digest: digest(phone, code), expiresAt: Date.now() + OTP_TTL_MS, guesses: 0 });
try {
await sms.send(phone, fillTemplate(TPL_OTP, code), 'AVNZON');
} catch (err) {
console.error('OTP send failed:', err.name, err.message); // never log the code
return res.status(502).json({ error: 'Could not send the code. Try again shortly.' });
}
res.json({ sent: true });
});
app.post('/otp/verify', (req, res) => {
const phone = normalisePhone(req.body?.phone);
const code = String(req.body?.code ?? '');
const entry = phone && codes.get(phone);
if (!entry || entry.expiresAt < Date.now() || entry.guesses >= MAX_GUESSES || !/^\d{6}$/.test(code)) {
return res.status(400).json({ verified: false });
}
entry.guesses += 1;
if (!timingSafeEqual(entry.digest, digest(phone, code))) {
return res.status(400).json({ verified: false });
}
codes.delete(phone); // single use
res.json({ verified: true }); // start the session / issue a token here
});
// Drop expired entries so the maps do not grow forever.
setInterval(() => {
const now = Date.now();
for (const [k, v] of counters) if (v.resetAt <= now) counters.delete(k);
for (const [k, v] of codes) if (v.expiresAt <= now) codes.delete(k);
}, 60_000).unref();
app.listen(3000, () => console.log('OTP service on :3000'));We ran this against a mock gateway before publishing. The fourth request for the same number within 10 minutes got a 429. A correct code verified once and failed on reuse. After five wrong guesses even the right code was refused. The design choices:
crypto.randomInt() draws from the OS CSPRNG. Math.random() is predictable enough to matter for login codes.timingSafeEqual compares two 32-byte digests in constant time.SmsUncertainError. If the SMS did go out, the user can still use it.trust proxy must match your setup. Without it, req.ip behind Nginx is the proxy's address, and every user shares one IP limit. Set it too loosely and clients can spoof X-Forwarded-For.For several instances, express-rate-limit with a Redis store replaces the counters map. Keep the per-number limit, though, because per-IP limits alone do not stop a botnet. For login traffic at scale, our OTP SMS service uses priority transactional routes.
Polling GET /api/v2/sms/status/{id} is fine for a support screen. For production volume, register a delivery-report URL in the dashboard and let the gateway call you. The payload includes the message ID and its final status. Look at a real callback before you map fields, and keep the route itself boring:
// dlr-webhook.mjs: add to the Express app from the OTP example
import { timingSafeEqual } from 'node:crypto';
const DLR_TOKEN = Buffer.from(process.env.DLR_TOKEN ?? ''); // long random string
function tokenOk(supplied) {
const given = Buffer.from(String(supplied ?? ''));
return DLR_TOKEN.length > 0 && given.length === DLR_TOKEN.length && timingSafeEqual(given, DLR_TOKEN);
}
// Register https://your-app.example/webhooks/sms-status?token=<DLR_TOKEN> in the dashboard.
// JSON bodies are parsed by app.use(express.json()); this also accepts form posts.
app.post('/webhooks/sms-status', express.urlencoded({ extended: false }), async (req, res) => {
if (!tokenOk(req.query.token)) return res.sendStatus(403);
try {
await saveRawReport(req.body); // insert the raw payload; parse it in a worker
res.sendStatus(200); // answer quickly; do the real work later
} catch (err) {
console.error('DLR store failed:', err.message);
res.sendStatus(500); // tell the sender the report was not stored
}
});Store first and parse later. Then a payload you did not expect never causes lost reports. Make the update keyed on the message ID, so a report that arrives twice, or after a later one, does not move a message backwards from delivered to sent.
/sms/send-bulk takes up to 1,000 recipients per call. De-duplicate, chunk and send the chunks one after another:
// bulk.mjs
export async function sendToMany(sms, numbers, message, senderId = 'AVNZON', extra = {}) {
const unique = [...new Set(numbers)]; // de-duplicate first
const failed = [];
for (let i = 0; i < unique.length; i += 1000) { // the API takes up to 1,000 per call
const batch = unique.slice(i, i + 1000);
try {
await sms.sendBulk(batch, message, senderId, extra);
} catch (err) {
console.error(`batch starting at ${i} failed:`, err.name, err.message);
failed.push(batch); // investigate, then resend only these
}
}
return failed;
}Resist Promise.all() over fifty batches. It turns one campaign into a burst of parallel requests that mostly earns 429s. Run campaigns from a worker (BullMQ, a cron script), not from an HTTP handler. Promotional messages need route: 'promotional' and a promotional header, and TRAI allows them only from 9 AM to 9 PM. For one-off campaigns without code, use the bulk SMS panel.
One non-GSM character, such as a Tamil letter, an emoji, ₹ or a curly quote, turns the whole message into UCS-2 (70 characters, 67 per part). Pass unicode: true for those and count before sending. JavaScript makes the UCS-2 half easy:
// sms-parts.mjs
const GSM7_BASIC = new Set(
'@£$¥èéùìòÇ\nØø\rÅåΔ_ΦΓΛΩΠΨΣΘΞÆæßÉ !"#¤%&\'()*+,-./0123456789:;<=>?' +
'¡ABCDEFGHIJKLMNOPQRSTUVWXYZÄÖÑܧ¿abcdefghijklmnopqrstuvwxyzäöñüà'
);
const GSM7_EXTENDED = new Set('^{}\\[~]|€\f'); // each costs 2 characters
export function smsParts(text) {
const chars = [...text]; // iterate by code point
if (chars.every((ch) => GSM7_BASIC.has(ch) || GSM7_EXTENDED.has(ch))) {
const length = chars.reduce((n, ch) => n + (GSM7_EXTENDED.has(ch) ? 2 : 1), 0);
return { encoding: 'GSM-7', length, parts: length <= 160 ? 1 : Math.ceil(length / 153) };
}
// JS strings are UTF-16, so .length is exactly the unit UCS-2 SMS counts (emoji = 2)
const length = text.length;
return { encoding: 'UCS-2', length, parts: length <= 70 ? 1 : Math.ceil(length / 67) };
}
console.log(smsParts('Your OTP for login is 482913. It is valid for 10 minutes. Do not share it with anyone. -AVNZON'));
// { encoding: 'GSM-7', length: 94, parts: 1 }
console.log(smsParts('உங்கள் OTP 482913. 10 நிமிடங்கள் செல்லுபடியாகும். -AVNZON'));
// { encoding: 'UCS-2', length: 57, parts: 1 }
console.log(smsParts('Price drop: ₹1,499 on all {winter} jackets this week. -AVNZON'));
// { encoding: 'UCS-2', length: 61, parts: 1 }
console.log(smsParts('Use code {SAVE10} at checkout. -AVNZON'.repeat(5)));
// { encoding: 'GSM-7', length: 200, parts: 2 }The comments show what node sms-parts.mjs printed for us. The third example is a common trap: the braces alone would cost 2 each in GSM-7, but the ₹ sign moves the whole message to UCS-2. Tamil also runs longer than it looks, because vowel signs are separate code points: 'செல்லுபடியாகும்'.length is 15.
The module is plain JavaScript, so TypeScript projects can import it as is. Add a declaration file next to it for typed results. Because the module is .mjs, the declaration file is .d.mts:
// sms-client.d.mts
export interface QueuedSms {
message_id: number;
queue_id: number;
status: 'queued';
estimated_delivery: string;
}
export type DeliveryStatus = 'queued' | 'sent' | 'delivered' | 'failed';
export interface SendOptions {
route?: 'transactional' | 'promotional';
dlt_template_id?: string;
dlt_entity_id?: string;
unicode?: boolean;
flash?: boolean;
schedule_at?: string; // ISO 8601, in the future
}
export interface SmsClient {
send(to: string, message: string, senderId: string, extra?: SendOptions): Promise<QueuedSms>;
sendBulk(recipients: string[], message: string, senderId: string, extra?: SendOptions): Promise<unknown>;
status(messageId: number | string): Promise<DeliveryStatus>;
balance(): Promise<{ balance: number; currency: 'INR'; last_updated: string }>;
}
export function createSmsClient(opts: {
apiKey: string; baseUrl?: string; timeoutMs?: number; maxAttempts?: number;
}): SmsClient;For fetch types in a TypeScript project, use @types/node 18 or later. In NestJS, register createSmsClient(...) as a provider with useFactory and inject it where you need it.
| Symptom | Likely cause | Fix |
|---|---|---|
ReferenceError: fetch is not defined | Node.js older than 18 | Upgrade Node. Current LTS releases all have fetch |
await is only valid in async functions | Top-level await in a CommonJS file | Rename to .mjs or set "type": "module" |
401 Invalid API key | process.env.SMS_API_KEY is undefined in production | Set it in PM2, systemd or the container, or use --env-file |
TypeError: fetch failed, cause ENOTFOUND | DNS failure or an egress proxy that Node is not using | Check DNS from the server; configure the proxy for undici |
TimeoutError: The operation was aborted due to timeout | No response within timeoutMs | Do not resend blindly; check status or the dashboard |
TEMPLATE_MISMATCH | Text differs from the approved template, or a tagged variable failed validation | Use fillTemplate(); see DLT error codes |
UNABLE_TO_GET_ISSUER_CERT_LOCALLY | A corporate proxy or missing CA certificate | Set NODE_EXTRA_CA_CERTS; never NODE_TLS_REJECT_UNAUTHORIZED=0 |
| All users hit the IP limit at once | req.ip is your proxy's address | Set app.set('trust proxy', 1) for one proxy hop |
Tamil arrives as ???? | Sent without unicode: true | Check smsParts() and pass unicode: true for UCS-2 |
Other stacks: PHP, Laravel, Python and Java / Spring Boot. All are listed in our developer guides.
Open an account and we help with DLT registration and template approval, so your OTP template is approved before your endpoint goes live.