DEV Community

Cover image for Catch DLT template mismatches in code, before the operator drops your SMS
SMS Gateway Center for SMS Gateway Center

Posted on AI-assisted

Catch DLT template mismatches in code, before the operator drops your SMS

If you send SMS to Indian numbers, every message has to match a template you registered on a DLT platform. If it doesn't, the operator's scrubbing layer drops it. The annoying part is that your API call still returns success. You only find out later, from a failed delivery report or a customer asking where their OTP went.

TRAI's direction of 18 November 2025 made the matching stricter. Every variable in a template now has to be tagged with what it holds: a number, a URL, a callback number, and so on. If the value you send doesn't fit the tag, the message fails.

Most of these failures come from our own code, which means a test can catch them. Below is a small checker with no dependencies that you can run in CI.

What the scrubbing layer checks

A registered template is fixed text with typed slots:

{#numeric#} is your OTP for login to ACME. Valid for 10 minutes. Do not share it. - ACME
Enter fullscreen mode Exit fullscreen mode

When you send, three things have to be true:

  1. The fixed text matches exactly, including spaces, punctuation and case.
  2. Each value fits its tag.
  3. Any URL or callback number is on your whitelist.

These are the tags the DLT platforms published for the change:

Tag Holds Example
{#numeric#} OTPs, amounts, counts 482913
{#alphanumeric#} Order or ticket IDs ORD12345
{#url#} Website links https://acme.in/t/1
{#urlott#} App or OTT links Store links
{#cbn#} Callback numbers 9876543210
{#email#} Email addresses help@acme.in

The old untyped {#var#} is exactly what this change is getting rid of. If you still have templates using it, plan to re-register them.

The checker

One caveat first: the value rules below are my best guess at what passes, not the operator's actual algorithm. The portals don't publish exact character sets, so treat TAG_RULES as config and tighten it to whatever your portal enforces.

// dltPreflight.js
const TAG_RULES = {
  numeric:      { pattern: /^[0-9]+$/,                   desc: 'digits only' },
  alphanumeric: { pattern: /^[A-Za-z0-9]+$/,            desc: 'letters and digits' },
  url:          { pattern: /^https?:\/\/[^\s]+$/i,       desc: 'an http(s) link' },
  urlott:       { pattern: /^https?:\/\/[^\s]+$/i,       desc: 'an app/OTT link' },
  cbn:          { pattern: /^\+?[0-9]{8,13}$/,           desc: 'a callback number' },
  email:        { pattern: /^[^\s@]+@[^\s@]+\.[^\s@]+$/, desc: 'an email address' },
};
const TAG_RE = /\{#([a-z]+)#\}/g;
const MAX_VAR_LENGTH = 30; // set to your portal's limit

function parseTemplate(template) {
  const parts = [];
  let last = 0;
  for (const m of template.matchAll(TAG_RE)) {
    parts.push({ type: 'text', value: template.slice(last, m.index) });
    parts.push({ type: 'var', tag: m[1] });
    last = m.index + m[0].length;
  }
  parts.push({ type: 'text', value: template.slice(last) });
  const slots = parts.filter(p => p.type === 'var');
  const problems = [];
  for (const s of slots) {
    if (s.tag === 'var') problems.push('Untyped {#var#} slot: re-register it with a typed tag.');
    else if (!TAG_RULES[s.tag]) problems.push(`Unknown tag {#${s.tag}#}.`);
  }
  return { parts, slots, problems };
}
Enter fullscreen mode Exit fullscreen mode

render fills in the template and throws if a value doesn't fit. Build every outgoing message with it rather than gluing strings together:

function render(template, values, { allowedHosts = [] } = {}) {
  const { parts, slots, problems } = parseTemplate(template);
  if (problems.length) throw new Error(problems.join(' '));
  if (values.length !== slots.length) {
    throw new Error(`Template has ${slots.length} variables, got ${values.length} values.`);
  }
  let i = 0;
  const errors = [];
  const text = parts.map(p => {
    if (p.type === 'text') return p.value;
    const v = String(values[i++]);
    const rule = TAG_RULES[p.tag];
    if (!rule.pattern.test(v)) errors.push(`Slot ${i} {#${p.tag}#} needs ${rule.desc}, got "${v}".`);
    if (v.length > MAX_VAR_LENGTH) errors.push(`Slot ${i} is ${v.length} characters (limit ${MAX_VAR_LENGTH}).`);
    if ((p.tag === 'url' || p.tag === 'urlott') && allowedHosts.length) {
      try {
        const host = new URL(v).hostname;
        if (!allowedHosts.includes(host)) errors.push(`Slot ${i} host "${host}" is not on your whitelist.`);
      } catch { /* the pattern check already reported it */ }
    }
    return v;
  }).join('');
  if (errors.length) throw new Error(errors.join(' '));
  return text;
}
Enter fullscreen mode Exit fullscreen mode

matches is for messages your code doesn't build itself, like ones coming out of a CRM or a marketing tool. It checks that the fixed text is exactly right:

function matches(template, message) {
  const { parts } = parseTemplate(template);
  const escape = s => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
  const source = parts.map(p => (p.type === 'text' ? escape(p.value) : '(.+?)')).join('');
  return new RegExp(`^${source}$`, 's').test(message);
}
Enter fullscreen mode Exit fullscreen mode

Four bugs it catches

1. A formatted amount in a numeric slot. Your code formats money so people can read it. The template only wants digits.

const debit = 'Rs {#numeric#} debited from your ACME wallet. Ref {#alphanumeric#}. - ACME';
render(debit, ['5,000.00', 'TXN88231']);
// Error: Slot 1 {#numeric#} needs digits only, got "5,000.00".
Enter fullscreen mode Exit fullscreen mode

Before you loosen the pattern, check whether your portal actually accepts separators in a numeric slot.

2. Someone tidied up the copy after approval. A product manager changes "Do not share it." to "Please do not share it." in a config file. The template is still approved, but the message no longer matches it.

const otp = '{#numeric#} is your OTP for login to ACME. Valid for 10 minutes. Do not share it. - ACME';
const msg = render(otp, ['482913']);

matches(otp, msg.replace('Do not share it.', 'Please do not share it.')); // false
matches(otp, msg.replace('10 minutes', '10 mins'));                       // false
Enter fullscreen mode Exit fullscreen mode

Run this as a test over every template you have.

3. A link shortener nobody whitelisted. Marketing swaps your tracking link for a bit.ly one.

const track = 'Your order {#alphanumeric#} has shipped. Track: {#url#} - ACME';

render(track, ['ORD123', 'https://bit.ly/x'], { allowedHosts: ['acme.in'] });
// Error: Slot 2 host "bit.ly" is not on your whitelist.
Enter fullscreen mode Exit fullscreen mode

4. A curly apostrophe that doubles your bill. This one gets past scrubbing; it just costs you money. One character outside the GSM-7 set switches the whole message to UCS-2, and a single segment shrinks from 160 characters to 70.

segments("Don't share").encoding;  // 'GSM-7'
segments('Don’t share').encoding;  // 'UCS-2'
Enter fullscreen mode Exit fullscreen mode

Word processors swap in the curly apostrophe without telling you. A 120-character OTP that ends up in UCS-2 gets billed as two segments, on every single send.

Here's the counter:

const GSM_BASIC =
  '@£$¥èéùìòÇ\nØø\rÅåΔ_ΦΓΛΩΠΨΣΘΞÆæßÉ !"#¤%&\'()*+,-./0123456789:;<=>?' +
  '¡ABCDEFGHIJKLMNOPQRSTUVWXYZÄÖÑܧ¿abcdefghijklmnopqrstuvwxyzäöñüà';
const GSM_EXT = '^{}\\[~]|€\f'; // these cost two units each

function segments(message) {
  let units = 0;
  let gsm = true;
  for (const ch of message) {
    if (GSM_BASIC.includes(ch)) units += 1;
    else if (GSM_EXT.includes(ch)) units += 2;
    else { gsm = false; break; }
  }
  if (gsm) return { encoding: 'GSM-7', units, segments: units <= 160 ? 1 : Math.ceil(units / 153) };
  const u = message.length; // UTF-16 code units; an emoji uses two
  return { encoding: 'UCS-2', units: u, segments: u <= 70 ? 1 : Math.ceil(u / 67) };
}
Enter fullscreen mode Exit fullscreen mode

Once a message spans more than one segment, each part holds 153 or 67 characters. Every part carries a small header so the phone can put the message back together.

Running it in CI

Keep your registered templates in one file, with exactly the text you submitted to the DLT portal. In your tests:

  • Render each template with realistic sample values.
  • Assert that matches passes.
  • Fail the build if a message crosses a segment boundary you didn't expect.

Catching a mismatch in a pull request costs you nothing. Catching it in production can cost you a whole campaign.

What it won't catch

The checker can't see your DLT registration. That means it won't notice a header linked to the wrong entity, a template in the wrong category, or a template that was never approved. Those show up at the portal or in your delivery reports. The value patterns are also guesses, so confirm them with your portal.

The full module and tests are on GitHub: dlt-sms-preflight repo.

I work at SMS Gateway Center, an Indian SMS provider. If you're moving old {#var#} templates over, our notes on the variable-tagging change list the deadlines.

Disclosure: AI tools were used to assist with drafting and editing this article. The technical examples, code, and final content have been reviewed and adapted by the author.

Top comments (0)