Since 1998 5000+ Clients 99.9% Uptime TRAI Certified
🟩 DEVELOPER GUIDE · NODE.JS

Send SMS & OTP in Node.js (India)

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.

📅 Updated 29 Oct 2026⏱️ 13 min read🧪 Node.js 18+ (ES modules), Express 4 or 5

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.

1. Before you write code

  • Node.js 18 or later. That gives you global fetch and AbortSignal.timeout(). On Node 20.6+, node --env-file=.env app.mjs loads your key without the dotenv package.
  • An API key from your SMS gateway account, kept in SMS_API_KEY. Never ship it to browser code: anyone could read it there and send SMS on your balance.
  • An approved sender ID. Transactional and service headers are six letters, such as 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.
  • Approved DLT templates for each message. See our DLT registration guide if you are starting from zero.

The examples are ES modules (.mjs, or "type": "module" in package.json), because the first one uses top-level await.

2. Send one SMS with fetch

Node.jssend-sms.mjs
// 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.

3. Read the response

JSONHTTP 200
{
  "success": true,
  "message": "SMS queued successfully",
  "data": {
    "message_id": 1849302,
    "queue_id": 1849302,
    "status": "queued",
    "estimated_delivery": "2026-10-29T10:42:05.000000Z"
  }
}
OutcomeMeaningRetry?
200Accepted and queued (not yet delivered)Store data.message_id
401 Invalid API keyKey missing, wrong or revokedNo
400 Invalid sender IDHeader not approved on your accountNo
422 Validation failed / TEMPLATE_MISMATCHBad field, or text does not match a DLT templateNo
429, 502, 503Rate limit or gateway briefly unavailableYes, with back-off
500, 504, timeout, ECONNRESETThe message may already be queuedNot automatically. Check first
ENOTFOUND, ECONNREFUSED, UND_ERR_CONNECT_TIMEOUTNever reached the gatewayYes

4. A small client module

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:

Node.jssms-client.mjs
// 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.

5. DLT templates in JavaScript

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.

  • Tag names depend on the operator portal. Airtel uses {#numeric#}, {#alphanumeric#}, {#url#}, {#urlott#}, {#cbn#} and {#email#}. Vi and BSNL use {#num#}, {#alp#}, {#urg#}, {#uro#}, {#cbn#} and {#eml#}. See our variable tagging guide.
  • Each type has a length limit. Tagged variables generally allow 40 characters and URL tags 120. The legacy {#var#} allows 30. Check your operator's portal, because the sources do not all agree.
  • Links and call-back numbers must be whitelisted on DLT. Non-whitelisted URLs have been blocked since 1 October 2024.
Node.jstemplates.mjs
// 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.

6. An Express OTP endpoint with rate limiting

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:

Node.jsotp-server.mjs
// 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.
  • An HMAC with a server-side pepper means a dump of your Redis or database does not reveal live codes. timingSafeEqual compares two 32-byte digests in constant time.
  • The code is stored before the send and kept if the send throws 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.

7. A delivery-report webhook route

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:

Node.jsdlr-webhook.mjs
// 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.

8. Bulk sends

/sms/send-bulk takes up to 1,000 recipients per call. De-duplicate, chunk and send the chunks one after another:

Node.jsbulk.mjs
// 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.

9. Unicode, Tamil and segment counting

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:

Node.jssms-parts.mjs
// 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.

10. TypeScript

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:

TypeScriptsms-client.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.

11. Troubleshooting Node.js SMS code

SymptomLikely causeFix
ReferenceError: fetch is not definedNode.js older than 18Upgrade Node. Current LTS releases all have fetch
await is only valid in async functionsTop-level await in a CommonJS fileRename to .mjs or set "type": "module"
401 Invalid API keyprocess.env.SMS_API_KEY is undefined in productionSet it in PM2, systemd or the container, or use --env-file
TypeError: fetch failed, cause ENOTFOUNDDNS failure or an egress proxy that Node is not usingCheck DNS from the server; configure the proxy for undici
TimeoutError: The operation was aborted due to timeoutNo response within timeoutMsDo not resend blindly; check status or the dashboard
TEMPLATE_MISMATCHText differs from the approved template, or a tagged variable failed validationUse fillTemplate(); see DLT error codes
UNABLE_TO_GET_ISSUER_CERT_LOCALLYA corporate proxy or missing CA certificateSet NODE_EXTRA_CA_CERTS; never NODE_TLS_REJECT_UNAUTHORIZED=0
All users hit the IP limit at oncereq.ip is your proxy's addressSet app.set('trust proxy', 1) for one proxy hop
Tamil arrives as ????Sent without unicode: trueCheck smsParts() and pass unicode: true for UCS-2

Other stacks: PHP, Laravel, Python and Java / Spring Boot. All are listed in our developer guides.

FAQ

Sending SMS and OTP from Node.js: FAQ

Do I need axios or an SDK to send SMS from Node.js?
No. Node.js 18 and later ship a global fetch, and sending an SMS is one authenticated POST with a JSON body. The client module on this page has no dependencies. Express is only needed for the OTP endpoint.
How do I send OTP SMS in Node.js securely?
Generate the code with crypto.randomInt(), never Math.random(). Store only an HMAC of it with a 10-minute expiry, compare with crypto.timingSafeEqual(), and cap guesses at about five. Rate-limit requests per phone number and per IP before sending anything, and keep that state in Redis once you run more than one process. Our OTP SMS service covers the delivery side.
How do I set a timeout on fetch in Node.js?
Pass signal: AbortSignal.timeout(15000). When it fires, fetch rejects with a TimeoutError. For an SMS send, treat that as "may have been sent" and do not retry automatically, because the gateway may already have queued the message.
Which errors are safe to retry when sending SMS?
Errors where the request provably never arrived: DNS failure (ENOTFOUND, EAI_AGAIN), connection refused (ECONNREFUSED) and connect timeout (UND_ERR_CONNECT_TIMEOUT). HTTP 429, 502 and 503 are also safe. Do not retry a send after a timeout, a reset connection, a 500 or a 504.
How many characters fit in one SMS?
A GSM-7 SMS (plain English) holds 160 characters, or 153 per part when split. A Unicode SMS, which any Tamil or Hindi letter, emoji or ₹ triggers, holds 70, or 67 per part. JavaScript strings are UTF-16, so text.length already counts the units a Unicode SMS is billed on.
Does this work with TypeScript and NestJS?
Yes. Add the type declarations shown on this page and wrap the client in a NestJS provider. The HTTP logic does not change. The same API is shown in Python, PHP and Java.
READY TO INTEGRATE?

Get an API Key and Send Your First SMS from Node.js

Open an account and we help with DLT registration and template approval, so your OTP template is approved before your endpoint goes live.