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
When you send, three things have to be true:
- The fixed text matches exactly, including spaces, punctuation and case.
- Each value fits its tag.
- 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 };
}
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;
}
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);
}
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".
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
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.
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'
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) };
}
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
matchespasses. - 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)