{"id":2607,"date":"2026-08-05T16:12:42","date_gmt":"2026-08-05T10:42:42","guid":{"rendered":"https:\/\/www.smsgatewaycenter.com\/blog\/?p=2607"},"modified":"2026-08-05T16:12:44","modified_gmt":"2026-08-05T10:42:44","slug":"sms-api-nodejs-integration-tutorial","status":"publish","type":"post","link":"https:\/\/www.smsgatewaycenter.com\/blog\/sms-api-nodejs-integration-tutorial\/","title":{"rendered":"SMS API in Node.js: Full Integration Tutorial (Production Ready, 2026)"},"content":{"rendered":"\n<p class=\"wp-block-paragraph\">A complete Node.js SMS API integration tutorial that goes past the axios snippet: native fetch with AbortController timeouts, keep-alive agents, TypeScript response types, bounded concurrency with Promise.allSettled, BullMQ workers with graceful shutdown, India DLT template validation, OTP generate and verify, an Express DLR webhook receiver, idempotency and nock tests. Real endpoints throughout.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Table of Contents<\/h2>\n\n\n\n<ol class=\"wp-block-list\">\n<li><a href=\"#executive-summary\" data-type=\"internal\" data-id=\"#executive-summary\">Executive Summary<\/a><\/li>\n\n\n\n<li><a href=\"#tl-dr\" data-type=\"internal\" data-id=\"#tl-dr\">TL;DR<\/a><\/li>\n\n\n\n<li><a href=\"#prerequisites\">Prerequisites and Project Setup<\/a><\/li>\n\n\n\n<li><a href=\"#first-send\">Your First Send: Native fetch<\/a><\/li>\n\n\n\n<li><a href=\"#timeouts\">Timeouts: The Footgun Nobody Mentions<\/a><\/li>\n\n\n\n<li><a href=\"#reusable-client\">A Reusable Client with Keep-Alive and Types<\/a><\/li>\n\n\n\n<li><a href=\"#concurrency\">Concurrency: The Part Node Developers Get Wrong<\/a><\/li>\n\n\n\n<li><a href=\"#message-length\">Message Length, Encoding and Segments<\/a><\/li>\n\n\n\n<li><a href=\"#india-specific\">India Specific: DLT Entity IDs, Templates and Pre-Send Validation<\/a><\/li>\n\n\n\n<li><a href=\"#otp-node-js\">OTP in Node.js: Generate and Verify<\/a><\/li>\n\n\n\n<li><a href=\"#receiving-dlr\">Receiving Delivery Reports in Express<\/a><\/li>\n\n\n\n<li><a href=\"#polling-dlr\">Polling Delivery Reports with Async Iterators<\/a><\/li>\n\n\n\n<li><a href=\"#errors-retries\">Errors, Retries and Idempotency<\/a><\/li>\n\n\n\n<li><a href=\"#queue-workers-bullmq\">Queue Workers with BullMQ and Graceful Shutdown<\/a><\/li>\n\n\n\n<li><a href=\"#testing-without-sending-real-sms\">Testing Without Sending Real SMS<\/a><\/li>\n\n\n\n<li><a href=\"#observability-scaling-notes\">Observability and Scaling Notes<\/a><\/li>\n\n\n\n<li><a href=\"#production-readiness-checklist\">Production Readiness Checklist<\/a><\/li>\n\n\n\n<li><a href=\"#common-mistakes\">Common Mistakes and How to Fix Them<\/a><\/li>\n\n\n\n<li><a href=\"#faq\">FAQs<\/a><\/li>\n<\/ol>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 class=\"wp-block-heading\">1. Executive Summary<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\" id=\"executive-summary\">Sending an SMS from Node.js is one <code>fetch<\/code> call. Running an SMS integration in Node.js without waking up to a duplicated OTP, an exhausted socket pool or a queue that silently dropped four hundred messages during a deploy is a different exercise entirely.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Node makes two specific problems worse than most runtimes. The first is that concurrency is effectively free, so it is trivially easy to open two hundred simultaneous connections to a gateway and get throttled, which is a mistake a synchronous language makes you work for. The second is that <code>fetch<\/code> has no default timeout, so a stalled request hangs until the operating system gives up, and the promise you awaited simply never settles. Both are avoidable and both are absent from every Node SMS tutorial currently ranking.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">This guide covers the full path against real endpoints on the SMSGatewayCenter API. Every URL, method and parameter is taken verbatim from the live <a href=\"https:\/\/www.smsgatewaycenter.com\/developer-api\/\">developer API documentation<\/a>. If you want the language-agnostic orientation first, read the <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/sms-api-getting-started-guide\/\">SMS API getting started guide<\/a>. If your stack is PHP rather than Node, the sibling walkthrough is <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/sms-api-php-integration-tutorial\/\">SMS API in PHP: full integration tutorial<\/a>.<\/p>\n\n\n\n<figure class=\"wp-block-image size-full\"><a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/08\/sms-api-in-node-js.webp\"><img loading=\"lazy\" decoding=\"async\" width=\"1000\" height=\"570\" src=\"https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/08\/sms-api-in-node-js.webp\" alt=\"Diagram of a Node.js event loop sending SMS requests through a concurrency limiter to a gateway and receiving delivery receipts back.\" class=\"wp-image-2608\" srcset=\"https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/08\/sms-api-in-node-js.webp 1000w, https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/08\/sms-api-in-node-js-300x171.webp 300w, https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/08\/sms-api-in-node-js-768x438.webp 768w\" sizes=\"auto, (max-width: 1000px) 100vw, 1000px\" \/><\/a><\/figure>\n\n\n\n<h2 id=\"tl-dr\" class=\"wp-block-heading\">2. TL;DR<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">POST to <code>https:\/\/unify.smsgateway.center\/SMSApi\/send<\/code> with <code>application\/x-www-form-urlencoded<\/code> body containing <code>userid<\/code> and <code>password<\/code> (or send an <code>apikey<\/code> HTTP header instead), plus <code>sendMethod=quick<\/code>, <code>mobile<\/code>, <code>msg<\/code>, <code>senderid<\/code>, <code>msgType=text<\/code> and <code>output=json<\/code>. For Indian recipients add <code>dltEntityId<\/code> and <code>dltTemplateId<\/code>. Read <code>transactionId<\/code> from the JSON response and persist it. Treat the 200 as accepted, not delivered.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Three Node-specific things to do immediately: attach an <code>AbortSignal.timeout()<\/code> to every request, because fetch will otherwise hang indefinitely; set an <code>undici<\/code> Agent with keep-alive so you are not renegotiating TLS per message; and bound your concurrency with a limiter rather than handing an array of a thousand promises to <code>Promise.all<\/code>.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"prerequisites\" class=\"wp-block-heading\">3. Prerequisites and Project Setup<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Node.js 20 LTS or newer.<\/strong> Node 18 introduced global <code>fetch<\/code>, Node 20 stabilised it, and <code>AbortSignal.timeout()<\/code> is available from 17.3. Everything below runs on 20 and 22 without a polyfill. On <a href=\"https:\/\/github.com\/nodejs\/release#release-schedule\" target=\"_blank\" rel=\"noreferrer noopener nofollow\">Node 16 or older<\/a> you need <code>node-fetch<\/code> or <code>axios<\/code>, and Node 16 has been end of life since September 2023.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>An SMSGatewayCenter account with an approved sender ID.<\/strong> India headers are alphanumeric and capped at six characters. Create one in the panel or via <code>POST https:\/\/unify.smsgateway.center\/SMSApi\/senderid\/create<\/code>, documented at <a href=\"https:\/\/www.smsgatewaycenter.com\/developer-api\/create-sender-id\/\">Create Sender ID<\/a>. Approval takes time, so start it before you write code.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>For Indian traffic, a registered DLT entity and approved templates.<\/strong> Not optional. The <a href=\"https:\/\/www.smsgatewaycenter.com\/dlt-sms\/\">DLT hub page<\/a> covers registration; section 9 covers the API side.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Dependencies<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">The core integration needs zero runtime dependencies on Node 20. These are for the parts around it:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>npm init -y\nnpm pkg set type=module\nnpm i undici p-limit bullmq ioredis express libphonenumber-js pino zod\nnpm i -D typescript tsx vitest nock @types\/node @types\/express\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\"><code>undici<\/code> is Node&#8217;s own HTTP client, the one that backs global <code>fetch<\/code>. You install it explicitly only to configure the connection pool. <code>p-limit<\/code> is four lines of logic you could write yourself, but it is correct and battle-tested. <code>zod<\/code> is for validating the gateway response shape at the boundary rather than trusting it.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The developer docs publish <a href=\"https:\/\/www.smsgatewaycenter.com\/developer-api\/download-php-sdk\/\">PHP<\/a> and Python SDKs plus a <a href=\"https:\/\/www.smsgatewaycenter.com\/developer-api\/download-sms-postman\/\">Postman collection<\/a>, and there is a Node example using <code>unirest<\/code> on the enterprise docs site. No official Node package is listed alongside the PHP and Python SDKs in the developer-api sidebar, so this guide builds against the HTTP API directly, which is the right call anyway: you own the timeout, retry and observability behaviour instead of inheriting someone else&#8217;s.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Environment<\/h3>\n\n\n\n<pre class=\"wp-block-code\"><code># .env\nSGC_BASE_URL=https:\/\/unify.smsgateway.center\nSGC_API_KEY=your_api_key_here\nSGC_SENDER_ID=SMSGAT\nSGC_DLT_ENTITY_ID=1201159xxxxxxxxxxx\nREDIS_URL=redis:\/\/127.0.0.1:6379\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Load with <code>node --env-file=.env app.js<\/code> on Node 20.6+ and skip the <code>dotenv<\/code> dependency entirely.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"first-send\" class=\"wp-block-heading\">4. Your First Send: Native fetch<\/h2>\n\n\n\n<pre class=\"wp-block-code\"><code>\/\/ send-once.js\nconst params = new URLSearchParams({\n  sendMethod: 'quick',\n  mobile: '919999999999',\n  msg: 'Your order 4821 has shipped. Track it at https:\/\/example.com\/t\/4821',\n  senderid: process.env.SGC_SENDER_ID,\n  msgType: 'text',\n  duplicatecheck: 'true',\n  output: 'json',\n});\n\nconst response = await fetch(`${process.env.SGC_BASE_URL}\/SMSApi\/send`, {\n  method: 'POST',\n  headers: {\n    'Content-Type': 'application\/x-www-form-urlencoded',\n    apikey: process.env.SGC_API_KEY,\n  },\n  body: params,\n  signal: AbortSignal.timeout(15_000),\n});\n\nconst result = await response.json();\nconsole.log(response.status, result);\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">A successful response:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>{\n  \"status\": \"success\",\n  \"mobile\": \"919999999999\",\n  \"invalidMobile\": \"\",\n  \"transactionId\": \"a1b2c3d4-...\",\n  \"statusCode\": \"200\",\n  \"reason\": \"success\"\n}\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Three details in that snippet are load-bearing.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><code>URLSearchParams<\/code> passed directly as <code>body<\/code> makes fetch set the form-encoded content type and handle escaping. If you build the body by string concatenation, a message containing <code>&amp;<\/code> or a password containing <code>+<\/code> silently corrupts the request, and the failure looks exactly like bad credentials. Do not hand-roll it.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The <code>apikey<\/code> header replaces <code>userid<\/code> and <code>password<\/code> in the body. Prefer it. It rotates independently of your panel password, can be issued per service so a leak has a bounded blast radius, and headers are far less likely to end up in an access log than a body or query string.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><code>AbortSignal.timeout(15_000)<\/code> is the line most tutorials omit, and section 5 explains why it matters more than it looks.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">The Equivalent cURL<\/h3>\n\n\n\n<pre class=\"wp-block-code\"><code>curl -X POST 'https:\/\/unify.smsgateway.center\/SMSApi\/send' \\\n  -H 'Content-Type: application\/x-www-form-urlencoded' \\\n  -H 'apikey: YOUR_API_KEY' \\\n  --data-urlencode 'sendMethod=quick' \\\n  --data-urlencode 'mobile=919999999999' \\\n  --data-urlencode 'msg=Test message from cURL' \\\n  --data-urlencode 'senderid=SMSGAT' \\\n  --data-urlencode 'msgType=text' \\\n  --data-urlencode 'output=json'\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Use POST, not GET. A GET request puts credentials and the full message body into the URL, where they land in web server logs, proxy logs and any CDN on the path. If the message is an OTP, that is a data leak as well as a credential leak.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"timeouts\" class=\"wp-block-heading\">5. Timeouts: The Footgun Nobody Mentions<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\"><strong><code>fetch<\/code> in Node has no default timeout.<\/strong> Not a long one. None. If the far end accepts your connection and then stops responding, the promise never settles. In a request handler that means a hung HTTP request. In a queue worker it means a job that occupies a slot forever, and with enough of them your worker stops processing anything at all.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>\/\/ Wrong. This can hang until the OS gives up, which may be minutes.\nconst r = await fetch(url, { method: 'POST', body: params });\n\n\/\/ Right.\nconst r = await fetch(url, {\n  method: 'POST',\n  body: params,\n  signal: AbortSignal.timeout(15_000),\n});\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Catch the abort specifically, because it is a different failure class from a rejection by the gateway:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>try {\n  const r = await fetch(url, { method: 'POST', body: params, signal: AbortSignal.timeout(15_000) });\n} catch (err) {\n  if (err.name === 'TimeoutError') {\n    \/\/ Request may or may not have been accepted upstream. Retryable, but only\n    \/\/ with an idempotency key, because the message might already be in flight.\n  }\n  if (err.name === 'AbortError') {\n    \/\/ Someone aborted deliberately, for example a shutdown signal.\n  }\n  throw err;\n}\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">That comment on <code>TimeoutError<\/code> is the important one. A timeout is ambiguous: the gateway may have accepted and dispatched the message before the response was lost. Retrying without an idempotency key is how customers receive the same OTP twice. Section 13 handles it.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">To combine a timeout with your own cancellation signal, for example a <a href=\"https:\/\/developer.mozilla.org\/en-US\/docs\/Web\/API\/AbortSignal\/timeout_static\" target=\"_blank\" rel=\"noreferrer noopener nofollow\">shutdown handler<\/a>, use <code>AbortSignal.any<\/code>:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>const signal = AbortSignal.any(&#91;AbortSignal.timeout(15_000), shutdownController.signal]);\n<\/code><\/pre>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"reusable-client\" class=\"wp-block-heading\">6. A Reusable Client with Keep-Alive and Types<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Per-call fetch is fine for a script. For a service you want one client that owns the connection pool, timeouts, auth and response parsing.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>\/\/ src\/sms\/client.ts\nimport { Agent, request } from 'undici';\nimport { z } from 'zod';\n\nconst SendResponse = z.object({\n  status: z.string(),\n  mobile: z.string().optional(),\n  invalidMobile: z.string().optional().default(''),\n  transactionId: z.union(&#91;z.string(), z.number()]).optional(),\n  statusCode: z.union(&#91;z.string(), z.number()]),\n  reason: z.string().optional().default(''),\n});\n\nexport type SendResponse = z.infer&lt;typeof SendResponse&gt;;\n\nexport class SmsTransportError extends Error {\n  readonly retryable = true;\n  constructor(message: string, options?: ErrorOptions) {\n    super(message, options);\n    this.name = 'SmsTransportError';\n  }\n}\n\nexport class SmsApiError extends Error {\n  readonly retryable = false;\n  constructor(message: string, readonly statusCode: string) {\n    super(message);\n    this.name = 'SmsApiError';\n  }\n}\n\nexport class SmsClient {\n  #agent: Agent;\n\n  constructor(\n    private readonly baseUrl: string,\n    private readonly apiKey: string,\n    opts: { connections?: number; timeoutMs?: number } = {},\n  ) {\n    this.#agent = new Agent({\n      connections: opts.connections ?? 16,\n      keepAliveTimeout: 30_000,\n      keepAliveMaxTimeout: 60_000,\n      headersTimeout: opts.timeoutMs ?? 15_000,\n      bodyTimeout: opts.timeoutMs ?? 15_000,\n    });\n  }\n\n  async post(path: string, fields: Record&lt;string, string | number&gt;): Promise&lt;SendResponse&gt; {\n    const body = new URLSearchParams(\n      Object.entries({ ...fields, output: 'json' }).map((&#91;k, v]) =&gt; &#91;k, String(v)]),\n    ).toString();\n\n    let res;\n    try {\n      res = await request(`${this.baseUrl}${path}`, {\n        method: 'POST',\n        dispatcher: this.#agent,\n        headers: {\n          'content-type': 'application\/x-www-form-urlencoded',\n          apikey: this.apiKey,\n        },\n        body,\n      });\n    } catch (cause) {\n      throw new SmsTransportError(`Could not reach the SMS gateway: ${(cause as Error).message}`, { cause });\n    }\n\n    const text = await res.body.text();\n\n    if (res.statusCode &gt;= 500) {\n      throw new SmsTransportError(`Gateway returned HTTP ${res.statusCode}`);\n    }\n\n    let json: unknown;\n    try {\n      json = JSON.parse(text);\n    } catch {\n      throw new SmsTransportError(`Gateway returned a non-JSON body: ${text.slice(0, 200)}`);\n    }\n\n    return SendResponse.parse(json);\n  }\n\n  async close() {\n    await this.#agent.close();\n  }\n}\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Two things here are worth more than they look.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Keep-alive.<\/strong> Without a configured agent, every message can pay a fresh TCP handshake plus a TLS negotiation. Over a batch of ten thousand that is a large amount of latency and CPU spent on nothing. Setting <code>connections<\/code> also caps how many sockets you will ever open to this host, which is a second safety net under the concurrency limiter in section 7.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Parsing at the boundary with zod.<\/strong> Note <code>transactionId<\/code> is typed as <code>string | number<\/code>. Real APIs return <code>\"0\"<\/code> in one endpoint and <code>200<\/code> unquoted in another, and this one does exactly that: the send endpoint returns <code>\"statusCode\": \"200\"<\/code> as a string while the WhatsApp endpoint returns <code>\"statusCode\": 200<\/code> as a number. Coercing at the edge means the rest of your code does not carry defensive <code>String()<\/code> calls forever.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Do not throw on 4xx before reading the body.<\/strong> A rejection from this API arrives as JSON with a specific <code>statusCode<\/code> and <code>reason<\/code> that you need in order to decide whether to retry. Any client that throws on non-2xx before parsing discards the diagnosis.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">If You Prefer axios<\/h3>\n\n\n\n<pre class=\"wp-block-code\"><code>import axios from 'axios';\nimport { Agent } from 'https';\n\nexport const http = axios.create({\n  baseURL: process.env.SGC_BASE_URL,\n  timeout: 15_000,\n  httpsAgent: new Agent({ keepAlive: true, maxSockets: 16 }),\n  headers: { apikey: process.env.SGC_API_KEY },\n  validateStatus: () =&gt; true, \/\/ parse the body yourself, do not throw on 4xx\n});\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\"><code>validateStatus: () =&gt; true<\/code> is the axios equivalent of the &#8220;read the body before deciding&#8221; rule. Note that axios <code>timeout<\/code> covers response headers, not the full body download, which is usually fine for this API since the payloads are tiny.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"concurrency\" class=\"wp-block-heading\">7. Concurrency: The Part Node Developers Get Wrong<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Here is the code that appears in almost every Node SMS tutorial and should not survive contact with production:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>\/\/ Do not do this.\nawait Promise.all(recipients.map((r) =&gt; sendSms(r)));\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Two independent failures. First, with ten thousand recipients this opens ten thousand concurrent requests, which will exhaust sockets, trigger throttling and quite possibly get your traffic treated as abusive. Second, <code>Promise.all<\/code> rejects on the first failure and abandons the results of everything else that was in flight, so one invalid number destroys your record of the nine thousand that succeeded.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Use a bounded limiter and <code>allSettled<\/code>:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>import pLimit from 'p-limit';\n\nexport async function sendBatch(client, messages, { concurrency = 8 } = {}) {\n  const limit = pLimit(concurrency);\n\n  const settled = await Promise.allSettled(\n    messages.map((m) =&gt; limit(() =&gt; sendOne(client, m))),\n  );\n\n  const accepted = &#91;];\n  const failed = &#91;];\n\n  settled.forEach((outcome, i) =&gt; {\n    if (outcome.status === 'fulfilled' &amp;&amp; outcome.value.status === 'success') {\n      accepted.push({ mobile: messages&#91;i].mobile, transactionId: outcome.value.transactionId });\n    } else {\n      const reason =\n        outcome.status === 'rejected'\n          ? outcome.reason.message\n          : `${outcome.value.statusCode}: ${outcome.value.reason}`;\n      failed.push({ mobile: messages&#91;i].mobile, reason });\n    }\n  });\n\n  return { accepted, failed };\n}\n<\/code><\/pre>\n\n\n\n<blockquote class=\"wp-block-quote is-layout-flow wp-block-quote-is-layout-flow\"><\/blockquote>\n\n\n\n<figure class=\"wp-block-image size-full\"><a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/08\/concurrency.webp\"><img loading=\"lazy\" decoding=\"async\" width=\"1000\" height=\"570\" src=\"https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/08\/concurrency.webp\" alt=\"Illustration about Concurrency: The Part Node Developers Get Wrong\" class=\"wp-image-2609\" srcset=\"https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/08\/concurrency.webp 1000w, https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/08\/concurrency-300x171.webp 300w, https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/08\/concurrency-768x438.webp 768w\" sizes=\"auto, (max-width: 1000px) 100vw, 1000px\" \/><\/a><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">Pick <code>concurrency<\/code> conservatively. The right ceiling depends on the throughput provisioned on your account, so start low, watch your acceptance rate and latency, and raise it deliberately. If you find yourself needing high sustained concurrency to keep up, HTTP is the wrong transport and you want a persistent SMPP bind instead. The tuning considerations are covered in <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/smpp-rate-limits-throughput-tuning\/\">SMPP rate limits and throughput tuning<\/a>, and connectivity requirements in the <a href=\"https:\/\/www.smsgatewaycenter.com\/developer-api\/smpp-connectivity\/\">SMPP connectivity docs<\/a>.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Batch Sends Versus Per-Message Sends<\/h3>\n\n\n\n<figure class=\"wp-block-table\"><table class=\"has-fixed-layout\"><thead><tr><th>Approach<\/th><th><code>sendMethod<\/code><\/th><th>Per-recipient text<\/th><th>Best for<\/th><th>Trade-off<\/th><\/tr><\/thead><tbody><tr><td>Saved group on the platform<\/td><td><code>group<\/code><\/td><td>No<\/td><td>Recurring sends to a stable list<\/td><td>The list lives on the platform, not in your database<\/td><\/tr><tr><td>File upload<\/td><td><code>file<\/code><\/td><td>Depends on the file<\/td><td>Large one-off campaigns<\/td><td>Asynchronous, needs a separate status check<\/td><\/tr><tr><td>One request per recipient, limited<\/td><td><code>quick<\/code><\/td><td>Yes<\/td><td>Transactional traffic of any volume<\/td><td>More HTTP calls, full per-message control<\/td><\/tr><\/tbody><\/table><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">For transactional traffic, which is most of what a Node service sends, the last row is correct. Each message needs its own text, its own DLT template, its own idempotency key and its own retry state, and batching removes all four.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Do Not Block the Event Loop<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">If you are reading a large recipient file, stream it. Loading a 200 MB CSV with <code>readFileSync<\/code> and mapping it in one synchronous pass stops your process from serving anything else, including health checks, and your orchestrator will restart the container mid-batch.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>import { createReadStream } from 'node:fs';\nimport { createInterface } from 'node:readline';\n\nconst rl = createInterface({ input: createReadStream('recipients.csv'), crlfDelay: Infinity });\n\nconst limit = pLimit(8);\nconst inFlight = &#91;];\n\nfor await (const line of rl) {\n  const &#91;mobile, name] = line.split(',');\n  inFlight.push(limit(() =&gt; sendOne(client, { mobile, text: render(name) })));\n  if (inFlight.length &gt;= 5_000) {\n    await Promise.allSettled(inFlight.splice(0, inFlight.length));\n  }\n}\n\nawait Promise.allSettled(inFlight);\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">The periodic drain matters. Without it you accumulate an unbounded array of pending promises and reintroduce the memory problem you were avoiding, even though the limiter is correctly capping actual concurrency.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"message-length\" class=\"wp-block-heading\">8. Message Length, Encoding and Segments<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">A GSM-7 SMS holds 160 characters. One character outside the GSM-7 alphabet, including a curly apostrophe pasted from a document, switches the entire message to UCS-2 at 70 characters and multiplies your cost. In JavaScript the trap is that <code>String.length<\/code> counts UTF-16 code units, so an emoji outside the Basic Multilingual Plane counts as 2, and none of it corresponds to SMS segments, because <a href=\"https:\/\/en.wikipedia.org\/wiki\/GSM_03.38\" target=\"_blank\" rel=\"noreferrer noopener nofollow\">GSM<\/a>-7 has extension characters that consume two septets each.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>\/\/ src\/sms\/segments.js\nconst GSM7_BASIC =\n  '@\u00a3$\u00a5\u00e8\u00e9\u00f9\u00ec\u00f2\u00c7\\n\u00d8\u00f8\\r\u00c5\u00e5\u0394_\u03a6\u0393\u039b\u03a9\u03a0\u03a8\u03a3\u0398\u039e\u00c6\u00e6\u00df\u00c9 !\"#\u00a4%&amp;\\'()*+,-.\/0123456789:;&lt;=&gt;?' +\n  '\u00a1ABCDEFGHIJKLMNOPQRSTUVWXYZ\u00c4\u00d6\u00d1\u00dc\u00a7\u00bfabcdefghijklmnopqrstuvwxyz\u00e4\u00f6\u00f1\u00fc\u00e0';\n\nconst GSM7_EXTENDED = '^{}\\\\&#91;~]|\u20ac';\n\nexport function measure(text) {\n  const chars = &#91;...text]; \/\/ code points, not UTF-16 units\n  const isGsm7 = chars.every((c) =&gt; GSM7_BASIC.includes(c) || GSM7_EXTENDED.includes(c));\n\n  if (!isGsm7) {\n    const units = chars.length;\n    return { encoding: 'UCS-2', units, segments: units &lt;= 70 ? 1 : Math.ceil(units \/ 67) };\n  }\n\n  const units = chars.reduce((n, c) =&gt; n + (GSM7_EXTENDED.includes(c) ? 2 : 1), 0);\n  return { encoding: 'GSM-7', units, segments: units &lt;= 160 ? 1 : Math.ceil(units \/ 153) };\n}\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Note <code>[...text]<\/code> rather than <code>text.split('')<\/code>. Spreading iterates code points and handles surrogate pairs correctly; <code>split('')<\/code> does not, and will happily tell you a single emoji is two characters.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Run this before every send and log the result. Reconciling predicted segments against the billed <code>length<\/code> and <code>cost<\/code> fields in the delivery report is the fastest way to catch an encoding regression before it shows up on an invoice. There is also a browser-based <a href=\"https:\/\/www.smsgatewaycenter.com\/sms-length-calculator\/\">SMS length calculator<\/a> for one-off checks.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">When the content is genuinely non-Latin, set <code>msgType<\/code> to <code>unicode<\/code> instead of <code>text<\/code>.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"india-specific\" class=\"wp-block-heading\">9. India Specific: DLT Entity IDs, Templates and Pre-Send Validation<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">If any recipients are Indian numbers, this section decides whether your integration works.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Commercial SMS to Indian subscribers must originate from a registered principal entity, use a registered header, and match a template registered on a DLT platform. The operator&#8217;s scrubbing engine compares your submitted text against the registered template before delivery, and a single character of drift, including a trailing space, gets the message scrubbed. The chain is explained in the <a href=\"https:\/\/www.smsgatewaycenter.com\/pe-tm-binding-chain-process-in-dlt\/\">PE-TM binding walkthrough<\/a>.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Two extra parameters on every India-bound send:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>fields.dltEntityId = process.env.SGC_DLT_ENTITY_ID;\nfields.dltTemplateId = template.dltTemplateId;\n<\/code><\/pre>\n\n\n\n<h3 class=\"wp-block-heading\">Validate Before You Send<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Most teams discover a mismatch hours later from a delivery report. There is a pre-flight endpoint: <code>POST https:\/\/unify.smsgateway.center\/SMSApi\/validateTemplate<\/code>, documented at <a href=\"https:\/\/www.smsgatewaycenter.com\/developer-api\/validateTemplate\/\">Validate Template<\/a>. It takes <code>senderid<\/code>, the fully rendered <code>msg<\/code> and the <code>dltTemplateId<\/code>.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>export class DltTemplateMismatchError extends Error {\n  readonly retryable = false;\n}\n\nexport async function validateTemplate(client, { senderId, renderedMessage, dltTemplateId }) {\n  const res = await client.post('\/SMSApi\/validateTemplate', {\n    senderid: senderId,\n    msg: renderedMessage,\n    dltTemplateId,\n  });\n\n  if (res.status === 'success') return true;\n\n  if (String(res.statusCode) === '188') {\n    throw new DltTemplateMismatchError(\n      `Rendered message does not match DLT template ${dltTemplateId}: ${res.reason}`,\n    );\n  }\n\n  throw new SmsApiError(res.reason || 'Template validation failed', String(res.statusCode));\n}\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\"><code>188<\/code> is the documented mismatch code, returned as <code>{\"status\":\"error\",\"statusCode\":\"188\",\"reason\":\"message template mismatch\"}<\/code>.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Wire this into CI, not just runtime. Render every template in your repo with representative values, validate each, and fail the build on a mismatch. Drift caught at merge costs nothing. The same drift caught in production costs you every OTP sent between deploy and detection.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Rendering Templates Without Corrupting Them<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">DLT variable syntax is <code>{#var#}<\/code>. A registered template might read:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>Dear {#var#}, your order {#var#} has been shipped and will arrive by {#var#}. Team ACME\n<\/code><\/pre>\n\n\n\n<pre class=\"wp-block-code\"><code>const PLACEHOLDER = '{#var#}';\n\nexport function renderDltTemplate(template, values) {\n  const expected = template.split(PLACEHOLDER).length - 1;\n  if (expected !== values.length) {\n    throw new Error(`Template expects ${expected} variables, ${values.length} given.`);\n  }\n\n  let out = '';\n  let rest = template;\n  for (const value of values) {\n    const i = rest.indexOf(PLACEHOLDER);\n    out += rest.slice(0, i) + value;\n    rest = rest.slice(i + PLACEHOLDER.length);\n  }\n  return out + rest;\n}\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Substitute positionally in a single pass rather than using <code>String.replace<\/code> with a global regex. If a substituted value happens to contain something that looks like a placeholder or a <code>$&amp;<\/code> replacement pattern, <code>replace<\/code> will do something surprising, and <code>$&amp;<\/code> in particular is a real hazard because it expands to the matched substring.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Never call <code>.trim()<\/code>, <code>.normalize()<\/code>, or an HTML escaper on the rendered output before sending. Every one of those can create a mismatch against a template you registered without that transformation.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Templates can also be created programmatically via <code>POST https:\/\/unify.smsgateway.center\/SMSApi\/template\/create<\/code>, documented at <a href=\"https:\/\/www.smsgatewaycenter.com\/developer-api\/create-message-template\/\">Create Message Template<\/a>. Approval is asynchronous, so model it as a provisioning workflow with its own states, not as an inline call during a send.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"otp-node-js\" class=\"wp-block-heading\">10. OTP in Node.js: Generate and Verify<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">The reflex is <code>crypto.randomInt(100000, 999999)<\/code>, store in Redis with a TTL, send as a normal SMS, compare on submission. That works, and it also means you now own expiry, replay protection, resend throttling, attempt limiting and timing-safe comparison. Most implementations get at least two of those wrong.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The API exposes a dedicated OTP endpoint at <code>https:\/\/unify.smsgateway.center\/SMSApi\/otp<\/code>, which handles generation, delivery, expiry and verification server side. The same URL serves both operations, switched by <code>sendMethod<\/code>.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Generate<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Per the <a href=\"https:\/\/www.smsgatewaycenter.com\/developer-api\/generate-otp\/\">Generate OTP docs<\/a>: <code>sendMethod=generate<\/code>, plus <code>mobile<\/code>, <code>msgType<\/code>, <code>msg<\/code> containing the literal <code>$otp$<\/code> placeholder which the platform substitutes, <code>medium=sms<\/code>, <code>codeType<\/code> (<code>num<\/code>, <code>alpha<\/code> or <code>alphanum<\/code>), <code>codeExpiry<\/code> in seconds (default 300), <code>codeLength<\/code>, <code>senderid<\/code> and <code>output<\/code>.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>export class OtpService {\n  constructor(private readonly client: SmsClient, private readonly senderId: string) {}\n\n  async generate(mobile: string, messageTemplate: string, { length = 6, expirySeconds = 300 } = {}) {\n    const res = await this.client.post('\/SMSApi\/otp', {\n      sendMethod: 'generate',\n      mobile,\n      msgType: 'text',\n      msg: messageTemplate, \/\/ must contain the literal $otp$\n      medium: 'sms',\n      codeType: 'num',\n      codeExpiry: expirySeconds,\n      codeLength: length,\n      senderid: this.senderId,\n    });\n\n    if (res.status !== 'success') {\n      throw new SmsApiError(res.reason || 'OTP generation failed', String(res.statusCode));\n    }\n\n    return {\n      createdAt: Number(res.createTime),\n      expiresAt: Number(res.expiryTime),\n      resendAllowedAt: Number(res.retryAfter),\n    };\n  }\n\n  async verify(mobile: string, otp: string) {\n    const res = await this.client.post('\/SMSApi\/otp', { sendMethod: 'verify', mobile, otp });\n    return res.status === 'success';\n  }\n}\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">A successful generate returns <code>statusCode<\/code> <code>300<\/code>:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>{\n  \"status\": \"success\",\n  \"mobile\": \"91777xxxxxxx\",\n  \"transactionId\": \"0\",\n  \"statusCode\": \"300\",\n  \"type\": \"new\",\n  \"reason\": \"OTP successfully generated.\",\n  \"createTime\": \"1700117933163\",\n  \"expiryTime\": \"1700118233163\",\n  \"retryAfter\": \"1700117993163\"\n}\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">A successful verify returns <code>statusCode<\/code> <code>301<\/code> with <code>\"reason\": \"OTP has been Verified Successfully.\"<\/code><\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Surface retryAfter, Do Not Invent a Timer<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\"><code>retryAfter<\/code> is a millisecond epoch giving the earliest permitted resend. Send it to the client as a countdown instead of hardcoding thirty seconds in your frontend. Hardcoded timers drift from server policy and produce the worst outcome available: an enabled Resend button that fails.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>app.post('\/auth\/otp', async (req, res) =&gt; {\n  const otp = await otpService.generate(req.body.mobile, OTP_TEMPLATE);\n  const now = Date.now();\n\n  res.json({\n    expires_in: Math.max(0, Math.round((otp.expiresAt - now) \/ 1000)),\n    resend_in: Math.max(0, Math.round((otp.resendAllowedAt - now) \/ 1000)),\n  });\n});\n<\/code><\/pre>\n\n\n\n<h3 class=\"wp-block-heading\">OTP Is the One Send You Do Not Queue<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Everywhere else the advice is to queue. Not here: a queued OTP that leaves the queue eight seconds later is an OTP the user has already abandoned. Send inline, but defend the request with a shorter timeout than your normal sends and a circuit breaker so a gateway outage does not take your login page with it.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>async function sendLoginOtp(mobile) {\n  if (breaker.isOpen()) throw new OtpChannelUnavailableError('SMS channel temporarily unavailable.');\n\n  try {\n    return await otpService.generate(mobile, OTP_TEMPLATE); \/\/ client configured with 5s timeout\n  } catch (err) {\n    breaker.recordFailure();\n    throw new OtpChannelUnavailableError('Could not send the code, please try again.', { cause: err });\n  }\n}\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Worth knowing before you make SMS your only OTP channel: SMS fails observably and reports back, which is why it stays the safest primary channel in India. The failure-mode comparison against WhatsApp is in <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/whatsapp-business-api-vs-sms-api-2025\/\">SMS API vs WhatsApp Business API<\/a>.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"receiving-dlr\" class=\"wp-block-heading\">11. Receiving Delivery Reports in Express<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">A 200 with <code>status: success<\/code> means accepted, not delivered. Closing that gap needs a second, asynchronous inbound flow.<\/p>\n\n\n\n<figure class=\"wp-block-image size-full\"><a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/08\/receiving-delivery-reports.webp\"><img loading=\"lazy\" decoding=\"async\" width=\"1000\" height=\"570\" src=\"https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/08\/receiving-delivery-reports.webp\" alt=\"Illustration about Receiving Delivery Reports in Express\" class=\"wp-image-2610\" srcset=\"https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/08\/receiving-delivery-reports.webp 1000w, https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/08\/receiving-delivery-reports-300x171.webp 300w, https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/08\/receiving-delivery-reports-768x438.webp 768w\" sizes=\"auto, (max-width: 1000px) 100vw, 1000px\" \/><\/a><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">Register a callback with <code>POST https:\/\/unify.smsgateway.center\/SMSApi\/webhook\/create<\/code>, passing <code>smswebhook<\/code> (your URL) and <code>smswebhookrate<\/code> (DLR throughput, default 10), documented at <a href=\"https:\/\/www.smsgatewaycenter.com\/developer-api\/create-webhook\/\">Create Webhook<\/a>.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>await client.post('\/SMSApi\/webhook\/create', {\n  smswebhook: 'https:\/\/api.example.com\/webhooks\/sms-dlr',\n  smswebhookrate: 10,\n});\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Success looks like:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>{\n  \"response\": {\n    \"api\": \"webhook\",\n    \"action\": \"create\",\n    \"status\": \"success\",\n    \"msg\": \"Webhook added successfully.\",\n    \"code\": \"200\"\n  }\n}\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Set <code>smswebhookrate<\/code> to something your receiver can actually sustain. It is a promise about your own capacity, not a throughput upgrade. Registering 100 when your service handles 20 per second while also serving users is a self-inflicted outage.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">The Receiver<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">The rule is acknowledge first, process later.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>\/\/ src\/webhooks\/dlr.js\nimport express from 'express';\nimport { Queue } from 'bullmq';\nimport { timingSafeEqual } from 'node:crypto';\n\nconst dlrQueue = new Queue('sms-dlr', { connection: { url: process.env.REDIS_URL } });\nconst router = express.Router();\n\nfunction safeEqual(a = '', b = '') {\n  const bufA = Buffer.from(a);\n  const bufB = Buffer.from(b);\n  return bufA.length === bufB.length &amp;&amp; timingSafeEqual(bufA, bufB);\n}\n\n\/\/ Capture the raw body. Do not let a JSON parser run before you have stored it.\nrouter.post(\n  '\/sms-dlr',\n  express.raw({ type: '*\/*', limit: '1mb' }),\n  async (req, res) =&gt; {\n    if (!safeEqual(req.query.token, process.env.SGC_WEBHOOK_SECRET)) {\n      return res.sendStatus(403);\n    }\n\n    \/\/ Persist verbatim and acknowledge. A worker parses it out of band.\n    await dlrQueue.add(\n      'dlr.received',\n      {\n        receivedAt: Date.now(),\n        contentType: req.get('content-type') ?? null,\n        query: req.query,\n        raw: req.body.toString('utf8'),\n      },\n      { removeOnComplete: 1000, removeOnFail: 5000 },\n    );\n\n    res.json({ ok: true });\n  },\n);\n\nexport default router;\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Three deliberate choices.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong><code>express.raw<\/code> with <code>type: '*\/*'<\/code>.<\/strong> You do not yet know the content type the platform will send, and a JSON body parser that encounters form-encoded input silently produces an empty object, destroying the payload before you ever see it. Capture the bytes first.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Store raw, parse later.<\/strong> This single decision turns an unanticipated payload format into a replayable record rather than a lost delivery report. Build the parser from what actually arrives, and cross-check status semantics against the <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/kb\/understanding-delivery-reports-dlr\/\">delivery reports knowledge base article<\/a> and the per-status explainers such as <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/kb\/what-does-in_retry-retry-attempted-mean\/\">IN_RETRY<\/a> and <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/kb\/what-does-absent_sub-absent-subscriber-unreachable-mean\/\">ABSENT_SUB<\/a>.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong><code>timingSafeEqual<\/code>, not <code>===<\/code>.<\/strong> Comparing secrets with <code>===<\/code> leaks length and prefix information through timing. It is a cheap fix.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Make the downstream worker idempotent too. Any webhook system can deliver the same event twice, so key on message identifier plus status and upsert, making a duplicate a no-op rather than a double-counted metric.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"polling-dlr\" class=\"wp-block-heading\">12. Polling Delivery Reports with Async Iterators<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Webhooks are the primary signal. Polling is the reconciliation backstop that catches anything lost while your receiver was down. Mature integrations run both.<\/p>\n\n\n\n<figure class=\"wp-block-table\"><table class=\"has-fixed-layout\"><thead><tr><th><\/th><th>Polling <code>SMSApi\/reports\/status<\/code><\/th><th>Webhook push<\/th><\/tr><\/thead><tbody><tr><td>Latency to know<\/td><td>Poll interval<\/td><td>Near real time<\/td><\/tr><tr><td>Infrastructure<\/td><td>A cron and outbound access<\/td><td>A public HTTPS endpoint you must keep up<\/td><\/tr><tr><td>During your outage<\/td><td>Nothing lost, you catch up<\/td><td>Gaps possible<\/td><\/tr><tr><td>Cost at scale<\/td><td>Many requests returning nothing new<\/td><td>One request per state change<\/td><\/tr><tr><td>Role<\/td><td>Backstop<\/td><td>Primary<\/td><\/tr><\/tbody><\/table><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">The endpoint is <code>https:\/\/unify.smsgateway.center\/SMSApi\/reports\/status<\/code> with <code>method=getDlr<\/code>, documented at <a href=\"https:\/\/www.smsgatewaycenter.com\/developer-api\/sms-delivery-report\/\">SMS Delivery Report<\/a>. Parameters are <code>fromdate<\/code>, <code>todate<\/code>, <code>pageLimit<\/code>, optional <code>mobileNo<\/code> or <code>uuId<\/code>, and <code>startCursor<\/code> for pagination.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">An async generator is the natural shape, because it gives the caller backpressure for free:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>export async function* fetchDeliveryReports(client, { from, to, pageLimit = 100 }) {\n  let cursor;\n\n  while (true) {\n    const params = {\n      method: 'getDlr',\n      fromdate: from,\n      todate: to,\n      pageLimit,\n      ...(cursor ? { startCursor: cursor } : {}),\n    };\n\n    const res = await client.post('\/SMSApi\/reports\/status', params);\n    const rows = res.reports_dlrList ?? &#91;];\n    if (rows.length === 0) return;\n\n    for (const row of rows) yield row;\n\n    cursor = rows.at(-1)?.cursorId;\n    if (!cursor || rows.length &lt; pageLimit) return;\n  }\n}\n\n\/\/ Consumer controls the pace. Nothing is buffered in memory.\nfor await (const dlr of fetchDeliveryReports(client, { from: '2026-08-01', to: '2026-08-05' })) {\n  await upsertDeliveryStatus(dlr);\n}\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Each row in <code>reports_dlrList<\/code> carries <code>country<\/code>, <code>amount<\/code>, <code>msgType<\/code>, <code>cost<\/code>, <code>deliveryTime<\/code>, <code>length<\/code>, <code>channel<\/code>, <code>msgId<\/code>, <code>cause<\/code>, <code>mobileNo<\/code>, <code>uuId<\/code>, <code>dltTemplateId<\/code>, <code>globalErrorCode<\/code>, <code>cursorId<\/code>, <code>network<\/code>, <code>senderName<\/code>, <code>flashMsg<\/code>, <code>submitTime<\/code>, <code>text<\/code> and <code>status<\/code>.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Persist <code>cost<\/code> and <code>length<\/code> next to the status. Comparing billed segments against what your <code>measure()<\/code> function predicted is how you catch an encoding regression before finance does.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"errors-retries\" class=\"wp-block-heading\">13. Errors, Retries and Idempotency<\/h2>\n\n\n\n<h3 class=\"wp-block-heading\">Classify Before Retrying<\/h3>\n\n\n\n<figure class=\"wp-block-table\"><table class=\"has-fixed-layout\"><thead><tr><th>Class<\/th><th>Examples<\/th><th>Retry<\/th><th>Backoff<\/th><th>Action<\/th><\/tr><\/thead><tbody><tr><td>Transport<\/td><td>DNS failure, ECONNREFUSED, TLS failure, <code>TimeoutError<\/code><\/td><td>Yes<\/td><td>Exponential with full jitter<\/td><td>Bounded attempts, needs an idempotency key<\/td><\/tr><tr><td>Transient application<\/td><td>Gateway 5xx, throttling, temporary operator unavailability<\/td><td>Yes<\/td><td>Exponential with full jitter<\/td><td>Retry, then park for review<\/td><\/tr><tr><td>Permanent<\/td><td>Invalid sender ID, DLT mismatch (188), malformed recipient, insufficient balance<\/td><td>No<\/td><td>None<\/td><td>Fail immediately, alert, do not consume retry budget<\/td><\/tr><\/tbody><\/table><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">The authoritative code lists are at <a href=\"https:\/\/www.smsgatewaycenter.com\/developer-api\/get-api-response-error-code-list\/\">API Response Error Codes<\/a> and <a href=\"https:\/\/www.smsgatewaycenter.com\/developer-api\/get-delivery-error-code-list\/\">Delivery Error Codes<\/a>, and both are fetchable programmatically via <code>POST \/SMSApi\/info\/responsecodes<\/code> and <code>POST \/SMSApi\/info\/deliverycodes<\/code>. Pull them at deploy time and cache, rather than hardcoding a list that goes stale.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>const PERMANENT_CODES = new Set(&#91;'188']); \/\/ template mismatch, extend from the published list\n\nexport function isRetryable(errOrResult) {\n  if (errOrResult instanceof SmsTransportError) return true;\n  if (errOrResult?.name === 'TimeoutError') return true;\n  if (errOrResult instanceof SmsApiError) return !PERMANENT_CODES.has(errOrResult.statusCode);\n  return false;\n}\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Keep the permanent and transient sets in configuration populated from the published lists, not scattered through the codebase. Deeper treatment of retry-window sizing against message time-to-live is in <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/sms-api-retry-strategy-handling-failed-messages\/\">SMS API retry strategy: handling failed messages<\/a>.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Full Jitter<\/h3>\n\n\n\n<pre class=\"wp-block-code\"><code>export function backoffMs(attempt, { baseMs = 2_000, capMs = 300_000 } = {}) {\n  const exponential = Math.min(capMs, baseMs * 2 ** (attempt - 1));\n  return Math.floor(Math.random() * exponential); \/\/ full jitter\n}\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">The jitter matters more than the curve. If a thousand queued messages fail together and all retry after exactly four seconds, you have built a thundering herd that guarantees the second attempt also fails.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Idempotency<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">The expensive scenario is mundane. Your worker POSTs a send, the gateway accepts and dispatches, the response is lost, your <code>AbortSignal.timeout<\/code> fires, and the retry sends the same OTP again.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>export class IdempotentSender {\n  constructor(redis, client, ttlSeconds = 86_400) {\n    this.redis = redis;\n    this.client = client;\n    this.ttl = ttlSeconds;\n  }\n\n  async send(message, businessKey) {\n    \/\/ Derived from the business event, so every retry produces the SAME key.\n    const key = `sms:idem:${createHash('sha256')\n      .update(`${businessKey}|${message.mobile}|${message.text}`)\n      .digest('hex')}`;\n\n    const cached = await this.redis.get(key);\n    if (cached) return JSON.parse(cached);\n\n    \/\/ SET NX claims the key atomically, so two workers cannot both send.\n    const claimed = await this.redis.set(`${key}:lock`, '1', 'EX', 120, 'NX');\n    if (!claimed) throw new SmsTransportError('Concurrent send in progress for this key.');\n\n    const result = await this.client.post('\/SMSApi\/send', toFields(message));\n\n    if (result.status === 'success') {\n      await this.redis.set(key, JSON.stringify(result), 'EX', this.ttl);\n    }\n\n    return result;\n  }\n}\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">The critical detail is where <code>businessKey<\/code> comes from. It must be stable across retries: an order ID, an invoice number, a session identifier. A <code>randomUUID()<\/code> generated inside the send function defeats the whole mechanism, because every retry gets a fresh key.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The platform&#8217;s <code>duplicatecheck<\/code> parameter is a useful second layer, but it is a platform behaviour with a window you do not control. Own your own.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"queue-workers-bullmq\" class=\"wp-block-heading\">14. Queue Workers with BullMQ and Graceful Shutdown<\/h2>\n\n\n\n<pre class=\"wp-block-code\"><code>\/\/ src\/queue\/sms.worker.js\nimport { Worker, Queue } from 'bullmq';\n\nexport const smsQueue = new Queue('sms-send', { connection: { url: process.env.REDIS_URL } });\n\nconst worker = new Worker(\n  'sms-send',\n  async (job) =&gt; {\n    const { message, businessKey } = job.data;\n\n    try {\n      const result = await sender.send(message, businessKey);\n\n      if (result.status === 'success') {\n        await recordTransaction(businessKey, message.mobile, result.transactionId);\n        return { transactionId: result.transactionId };\n      }\n\n      const err = new SmsApiError(result.reason, String(result.statusCode));\n      if (!isRetryable(err)) {\n        \/\/ Stop BullMQ retrying something that will never succeed.\n        throw new UnrecoverableError(`Permanent failure ${result.statusCode}: ${result.reason}`);\n      }\n      throw err;\n    } catch (err) {\n      if (!isRetryable(err)) throw new UnrecoverableError(err.message);\n      throw err;\n    }\n  },\n  {\n    connection: { url: process.env.REDIS_URL },\n    concurrency: 8,\n    limiter: { max: 50, duration: 1_000 }, \/\/ queue-level rate cap\n  },\n);\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Two things carry most of the value.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><code>UnrecoverableError<\/code> is <a href=\"https:\/\/docs.bullmq.io\/\" target=\"_blank\" rel=\"noreferrer noopener nofollow\">BullMQ<\/a>&#8216;s signal to stop retrying immediately. Without it, a message with an invalid sender ID occupies a worker slot five times across ten minutes and lands in the failed set anyway.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The <code>limiter<\/code> gives you a rate cap that survives horizontal scaling, because it is enforced through Redis. Per-process <code>p-limit<\/code> does not: run six replicas with <code>concurrency: 8<\/code> and you are actually running 48.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Graceful Shutdown<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">This is the piece almost every Node SMS integration is missing, and it costs real messages on every deploy.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>let shuttingDown = false;\n\nasync function shutdown(signal) {\n  if (shuttingDown) return;\n  shuttingDown = true;\n  logger.info({ signal }, 'shutting down');\n\n  server.close();                 \/\/ stop accepting new HTTP\n  await worker.close();           \/\/ finish in-flight jobs, stop taking new ones\n  await smsQueue.close();\n  await client.close();           \/\/ drain the undici agent\n  await redis.quit();\n\n  process.exit(0);\n}\n\nprocess.on('SIGTERM', () =&gt; shutdown('SIGTERM'));\nprocess.on('SIGINT', () =&gt; shutdown('SIGINT'));\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">When Kubernetes or your process manager sends SIGTERM, the default behaviour kills the process immediately. Any send that had been dispatched but whose response had not returned is now in the worst possible state: the gateway may have accepted it, but you have no record. <code>worker.close()<\/code> waits for in-flight jobs to settle. Combined with the idempotency key from section 13, a redelivered job after restart is safe.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Also handle the two events that kill a Node process silently:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>process.on('unhandledRejection', (reason) =&gt; {\n  logger.fatal({ reason }, 'unhandled rejection');\n  shutdown('unhandledRejection');\n});\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">A forgotten <code>await<\/code> on a send is an unhandled rejection, and on modern Node that terminates the process by default.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"testing-without-sending-real-sms\" class=\"wp-block-heading\">15. Testing Without Sending Real SMS<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">You cannot write meaningful tests against an integration that bills per assertion. Intercept HTTP instead.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>\/\/ test\/sms-client.test.js\nimport { describe, it, expect, afterEach } from 'vitest';\nimport nock from 'nock';\nimport { SmsClient, SmsTransportError } from '..\/src\/sms\/client.js';\n\nconst BASE = 'https:\/\/unify.smsgateway.center';\nconst client = new SmsClient(BASE, 'test-key');\n\nafterEach(() =&gt; nock.cleanAll());\n\ndescribe('SmsClient', () =&gt; {\n  it('returns the transaction id on success', async () =&gt; {\n    nock(BASE).post('\/SMSApi\/send').reply(200, {\n      status: 'success',\n      mobile: '919999999999',\n      invalidMobile: '',\n      transactionId: 'txn-123',\n      statusCode: '200',\n      reason: 'success',\n    });\n\n    const res = await client.post('\/SMSApi\/send', { mobile: '919999999999', msg: 'Hello' });\n    expect(res.transactionId).toBe('txn-123');\n  });\n\n  it('sends the api key as a header and never in the body', async () =&gt; {\n    let capturedBody;\n    let capturedHeaders;\n\n    nock(BASE)\n      .post('\/SMSApi\/send', (body) =&gt; {\n        capturedBody = body;\n        return true;\n      })\n      .reply(function (_uri, _body) {\n        capturedHeaders = this.req.headers;\n        return &#91;200, { status: 'success', statusCode: '200' }];\n      });\n\n    await client.post('\/SMSApi\/send', { mobile: '919999999999', msg: 'Hello' });\n\n    expect(capturedHeaders.apikey).toBe('test-key');\n    expect(JSON.stringify(capturedBody)).not.toContain('password');\n  });\n\n  it('surfaces a 188 template mismatch as a non-retryable result', async () =&gt; {\n    nock(BASE).post('\/SMSApi\/validateTemplate').reply(200, {\n      status: 'error',\n      statusCode: '188',\n      reason: 'message template mismatch',\n    });\n\n    const res = await client.post('\/SMSApi\/validateTemplate', { msg: 'wrong' });\n    expect(res.status).toBe('error');\n    expect(String(res.statusCode)).toBe('188');\n  });\n\n  it('converts a connection failure into SmsTransportError', async () =&gt; {\n    nock(BASE).post('\/SMSApi\/send').replyWithError({ code: 'ECONNREFUSED' });\n\n    await expect(client.post('\/SMSApi\/send', { mobile: '91999', msg: 'x' }))\n      .rejects.toBeInstanceOf(SmsTransportError);\n  });\n});\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Four tests, four distinct failure modes, zero messages sent. The second is the one people skip and the one that catches a real regression, because it asserts the credential travels in a header and never appears in the body.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Add a segment-counting test with a known GSM-7 extension character and a known emoji, and a DLT rendering test asserting the rendered output is byte-identical to the registered template with variables substituted. Then run <code>validateTemplate<\/code> against a staging account as a CI integration test: the unit test catches renderer bugs, the integration test catches template drift on the DLT portal, which no unit test can see.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"observability-scaling-notes\" class=\"wp-block-heading\">16. Observability and Scaling Notes<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Log four numbers, not one.<\/strong> Submit latency at p95, submit acceptance rate, time from submit to final DLR, and delivered rate. The first two describe your integration. The last two describe the route. Confusing them sends you debugging your own code when the problem is an operator.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Redact message bodies.<\/strong> They contain OTPs, order values and names, which is personal data under most regimes.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>import pino from 'pino';\n\nexport const logger = pino({\n  redact: {\n    paths: &#91;'*.password', '*.apikey', '*.otp', '*.msg', 'req.headers.apikey'],\n    censor: '&#91;redacted]',\n  },\n});\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Watch socket metrics, not just request counts.<\/strong> If your undici agent&#8217;s <code>connections<\/code> cap is lower than your effective concurrency, requests queue inside the agent and your measured latency climbs for reasons that have nothing to do with the gateway.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Know when HTTP is wrong.<\/strong> HTTP submit carries per-request overhead a persistent binary session does not. For sustained high volume rather than bursts, a persistent SMPP bind is the correct transport. See <a href=\"https:\/\/www.smsgatewaycenter.com\/developer-api\/smpp-connectivity\/\">SMPP connectivity<\/a> and the <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/smpp-rate-limits-throughput-tuning\/\">throughput tuning guide<\/a>.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"production-readiness-checklist\" class=\"wp-block-heading\">17. Production Readiness Checklist<\/h2>\n\n\n\n<figure class=\"wp-block-table\"><table class=\"has-fixed-layout\"><thead><tr><th>Item<\/th><th>Why it matters<\/th><th>Done<\/th><\/tr><\/thead><tbody><tr><td>Every fetch call carries an <code>AbortSignal.timeout<\/code><\/td><td>fetch has no default timeout and will hang forever<\/td><td>\u2610<\/td><\/tr><tr><td><code><a href=\"https:\/\/undici.nodejs.org\/\" target=\"_blank\" rel=\"noreferrer noopener nofollow\">undici<\/a><\/code> Agent configured with keep-alive and a connection cap<\/td><td>Avoids per-message TLS handshakes and caps sockets<\/td><td>\u2610<\/td><\/tr><tr><td>Concurrency bounded by a limiter, never raw <code>Promise.all<\/code><\/td><td>Prevents throttling and socket exhaustion<\/td><td>\u2610<\/td><\/tr><tr><td><code>Promise.allSettled<\/code> used for batches<\/td><td>One failure must not discard the other results<\/td><td>\u2610<\/td><\/tr><tr><td>API key auth via header, not userid and password in the body<\/td><td>Rotatable, revocable, per service<\/td><td>\u2610<\/td><\/tr><tr><td>Recipient numbers normalised to E.164 with <a href=\"https:\/\/gitlab.com\/catamphetamine\/libphonenumber-js\" target=\"_blank\" rel=\"noreferrer noopener nofollow\">libphonenumber-js<\/a><\/td><td>Regex validation of international numbers fails<\/td><td>\u2610<\/td><\/tr><tr><td>Segments and encoding computed before send<\/td><td>Catches accidental UCS-2 cost doubling<\/td><td>\u2610<\/td><\/tr><tr><td>DLT template validated in CI via <code>validateTemplate<\/code><\/td><td>Catches drift at merge, not in production<\/td><td>\u2610<\/td><\/tr><tr><td>Idempotency key derived from a stable business event<\/td><td>Prevents duplicate sends on ambiguous timeouts<\/td><td>\u2610<\/td><\/tr><tr><td>Failures classified before retrying, <code>UnrecoverableError<\/code> on permanent<\/td><td>Stops retry storms<\/td><td>\u2610<\/td><\/tr><tr><td>Backoff uses full jitter<\/td><td>Prevents thundering herd on recovery<\/td><td>\u2610<\/td><\/tr><tr><td><code>transactionId<\/code> persisted against the business record<\/td><td>Without it you cannot correlate a DLR<\/td><td>\u2610<\/td><\/tr><tr><td>DLR ingestion running, webhook plus polling backstop<\/td><td>HTTP 200 is not delivery<\/td><td>\u2610<\/td><\/tr><tr><td>Webhook receiver captures raw body and acknowledges before processing<\/td><td>Prevents payload loss and backpressure<\/td><td>\u2610<\/td><\/tr><tr><td>Webhook secret compared with <code>timingSafeEqual<\/code><\/td><td><code>===<\/code> leaks timing information<\/td><td>\u2610<\/td><\/tr><tr><td>SIGTERM handler closes worker, queue and agent<\/td><td>Deploys otherwise drop in-flight sends<\/td><td>\u2610<\/td><\/tr><tr><td><code>unhandledRejection<\/code> handled<\/td><td>A missing await otherwise kills the process silently<\/td><td>\u2610<\/td><\/tr><tr><td>Message bodies and credentials redacted in logs<\/td><td>OTPs in logs are a breach<\/td><td>\u2610<\/td><\/tr><tr><td>Per-recipient send rate limit<\/td><td>Blocks SMS pumping via resend buttons<\/td><td>\u2610<\/td><\/tr><\/tbody><\/table><\/figure>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<blockquote class=\"wp-block-quote is-layout-flow wp-block-quote-is-layout-flow\">\n<p class=\"wp-block-paragraph\"><strong>Ready to send your first message?<\/strong> Create an account, generate an API key from the control panel, and run the snippet from section 4 against your own sender ID. Sending to Indian numbers means registering your DLT entity and templates first. <a href=\"https:\/\/www.smsgatewaycenter.com\/contact\/\">Get started<\/a> or browse the <a href=\"https:\/\/www.smsgatewaycenter.com\/developer-api\/\">full API reference<\/a>.<\/p>\n<\/blockquote>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"common-mistakes\" class=\"wp-block-heading\">18. Common Mistakes and How to Fix Them<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>No timeout on fetch.<\/strong> The single most common Node-specific bug here. A stalled request hangs forever and the promise never settles. Always attach <code>AbortSignal.timeout()<\/code>.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong><code>Promise.all<\/code> over a large recipient array.<\/strong> Unbounded concurrency plus first-rejection-wins. Use a limiter and <code>allSettled<\/code>.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>A fresh connection per message.<\/strong> Without a keep-alive agent you pay TLS setup on every send. Configure <code>undici.Agent<\/code> once and reuse it.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Retrying a timeout without an idempotency key.<\/strong> A timeout is ambiguous: the message may already be in flight. Retrying blind is how a customer gets two OTPs.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Generating the idempotency key inside the retry path.<\/strong> Every retry gets a new key and the mechanism does nothing. Derive it from the business event upstream.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Using <code>String.length<\/code> for SMS length.<\/strong> It counts UTF-16 code units, not code points and not segments. Use a proper counter and iterate with spread.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong><code>String.replace<\/code> for DLT variable substitution.<\/strong> A value containing <code>$&amp;<\/code> expands to the matched substring and corrupts your message into a template mismatch. Substitute positionally.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Trimming or normalising a rendered DLT template.<\/strong> Any transformation after rendering and before sending can cause a scrubbing rejection. Render, validate, send, unchanged.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>A JSON body parser in front of the DLR webhook.<\/strong> If the payload is form-encoded, a JSON parser yields an empty object and the report is gone. Capture the raw body.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>No SIGTERM handler.<\/strong> Every deploy kills in-flight sends with no record of whether they were accepted.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Throwing on 4xx before reading the body.<\/strong> The rejection reason and status code you need for classification are in that body.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"faq\" class=\"wp-block-heading\">19. FAQs<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>How do I send an SMS in Node.js?<\/strong> POST to <code>https:\/\/unify.smsgateway.center\/SMSApi\/send<\/code> with a <code>URLSearchParams<\/code> body containing <code>sendMethod=quick<\/code>, <code>mobile<\/code>, <code>msg<\/code>, <code>senderid<\/code>, <code>msgType=text<\/code> and <code>output=json<\/code>, authenticating with an <code>apikey<\/code> header. Attach <code>AbortSignal.timeout()<\/code> to the fetch call, then read <code>transactionId<\/code> from the JSON response. Section 4 has a complete working example.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Do I need axios, or is native fetch enough?<\/strong> Native fetch on Node 18+ is enough. Use <code>undici<\/code> directly when you want to configure the connection pool, since it is the client that backs fetch anyway. Choose axios if you are already using its interceptors, but set <code>validateStatus: () =&gt; true<\/code> so you can read error bodies, and configure a keep-alive agent yourself.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Why does my fetch call hang forever?<\/strong> Because fetch in Node has no default timeout. If the server accepts the connection and stops responding, the promise never settles. Pass <code>signal: AbortSignal.timeout(15000)<\/code> on every request and catch <code>err.name === 'TimeoutError'<\/code> separately from gateway rejections.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Is <code>Promise.all<\/code> safe for sending bulk SMS?<\/strong> No, for two reasons. It opens one connection per recipient simultaneously, which exhausts sockets and triggers throttling, and it rejects on the first failure, discarding the results of everything else in flight. Use a concurrency limiter such as <code>p-limit<\/code> together with <code>Promise.allSettled<\/code>.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>How many concurrent SMS requests should I send?<\/strong> Start low, around 8 to 16, measure acceptance rate and p95 latency, and raise deliberately. The correct ceiling depends on the throughput provisioned on your account. If you need high sustained concurrency, move to a persistent SMPP bind rather than scaling HTTP concurrency.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Why did my SMS return success but never arrive?<\/strong> Success means the platform accepted it for delivery. Non-delivery afterwards is a downstream event: DLT scrubbing rejection, handset unreachable, DND registration or an operator failure. Fetch the delivery report from <code>SMSApi\/reports\/status<\/code> and read the <code>cause<\/code> and <code>globalErrorCode<\/code> fields.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>How do I handle DLT templates in Node.js?<\/strong> Store the registered template text with its <code>dltTemplateId<\/code>, substitute variables positionally into <code>{#var#}<\/code> placeholders without any other string transformation, validate the rendered output via <code>POST \/SMSApi\/validateTemplate<\/code> before sending, and pass <code>dltEntityId<\/code> and <code>dltTemplateId<\/code> on the send. Section 9 has the renderer and validator.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>What does status code 188 mean?<\/strong> It is the template mismatch response, returned as <code>{\"status\":\"error\",\"statusCode\":\"188\",\"reason\":\"message template mismatch\"}<\/code>. Your rendered message does not match the registered DLT template. Compare character by character including trailing whitespace and punctuation.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>How do I send OTP SMS in Node.js?<\/strong> Use <code>https:\/\/unify.smsgateway.center\/SMSApi\/otp<\/code> with <code>sendMethod=generate<\/code>, supplying <code>mobile<\/code>, <code>msg<\/code> containing the literal <code>$otp$<\/code> placeholder, <code>codeLength<\/code>, <code>codeExpiry<\/code>, <code>codeType<\/code> and <code>senderid<\/code>. Verify against the same URL with <code>sendMethod=verify<\/code> and the submitted <code>otp<\/code>. Generation returns status code 300, verification returns 301.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Should OTP sends go through the queue?<\/strong> No. Queue everything else, but send OTP inline so the user is not waiting on queue latency. Use a shorter timeout, around five seconds, and a circuit breaker that fails fast when the channel is degraded.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>How do I receive delivery reports in Express?<\/strong> Register a webhook via <code>POST \/SMSApi\/webhook\/create<\/code> with <code>smswebhook<\/code> and <code>smswebhookrate<\/code>. In Express, mount <code>express.raw({ type: '*\/*' })<\/code> on that route so the payload is captured verbatim, verify a shared secret with <code>timingSafeEqual<\/code>, push the raw body onto a queue, and return 200 immediately. Parse in a worker.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>How do I avoid duplicate sends when a request times out?<\/strong> Derive an idempotency key from a stable business identifier such as an order ID, claim it in Redis with <code>SET NX<\/code> before sending, and cache the successful result against it. A timeout is ambiguous about whether the message was accepted, so the key is what makes the retry safe.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>How do I stop losing messages during a deploy?<\/strong> Handle SIGTERM: stop accepting new HTTP, call <code>worker.close()<\/code> so in-flight jobs settle, close the queue, drain the undici agent, then exit. Without this the process dies immediately and any dispatched-but-unacknowledged send is lost.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>How do I test an SMS integration without sending real messages?<\/strong> Intercept HTTP with <code>nock<\/code> and assert on four cases: success, a permanent rejection such as 188, a transport failure, and that the API key travels as a header rather than in the body. Section 15 has all four ready to copy.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n","protected":false},"excerpt":{"rendered":"<p>A complete Node.js SMS API integration tutorial that goes past the axios snippet: native fetch with AbortController timeouts, keep-alive agents, TypeScript response types, bounded concurrency with Promise.allSettled, BullMQ workers with graceful shutdown, India DLT template validation, OTP generate and verify, an Express DLR webhook receiver, idempotency and nock tests. Real endpoints throughout.<\/p>\n","protected":false},"author":118,"featured_media":2608,"comment_status":"closed","ping_status":"closed","sticky":false,"template":"","format":"standard","meta":{"footnotes":""},"categories":[268],"tags":[2066,2063,2062,2061,2057,2064,2059,2058,2060,2065],"class_list":["post-2607","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-tutorials","tag-axios-sms-api","tag-bullmq-sms-queue","tag-dlt-template-nodejs","tag-express-sms-webhook","tag-node-js-sms-integration","tag-nodejs-sms-gateway","tag-otp-sms-nodejs","tag-send-sms-node","tag-sms-api-nodejs","tag-typescript-sms-api"],"_links":{"self":[{"href":"https:\/\/www.smsgatewaycenter.com\/blog\/wp-json\/wp\/v2\/posts\/2607","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/www.smsgatewaycenter.com\/blog\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/www.smsgatewaycenter.com\/blog\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/www.smsgatewaycenter.com\/blog\/wp-json\/wp\/v2\/users\/118"}],"replies":[{"embeddable":true,"href":"https:\/\/www.smsgatewaycenter.com\/blog\/wp-json\/wp\/v2\/comments?post=2607"}],"version-history":[{"count":0,"href":"https:\/\/www.smsgatewaycenter.com\/blog\/wp-json\/wp\/v2\/posts\/2607\/revisions"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/www.smsgatewaycenter.com\/blog\/wp-json\/wp\/v2\/media\/2608"}],"wp:attachment":[{"href":"https:\/\/www.smsgatewaycenter.com\/blog\/wp-json\/wp\/v2\/media?parent=2607"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/www.smsgatewaycenter.com\/blog\/wp-json\/wp\/v2\/categories?post=2607"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/www.smsgatewaycenter.com\/blog\/wp-json\/wp\/v2\/tags?post=2607"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}