Backend, DLT-Compliant Notification Service Architecture in India (2026): SMS, WhatsApp, and Email Without Silent OTP FailuresBackend
Backend

DLT-Compliant Notification Service Architecture in India (2026): SMS, WhatsApp, and Email Without Silent OTP Failures

RRRavi Rai·July 17, 2026·28 min read

Every Indian SaaS team eventually hits the same wall. Your notification code works fine in staging, ships to production, and then one Tuesday a customer calls support because the OTP never arrived. You check the gateway dashboard. Delivered. You check your application logs. 200 OK. Nothing in your stack disagrees with anything else in your stack, and yet the message is not on the handset.

This post is about the layer that sits between your domain events and your SMS gateway, the one nobody sells you and everybody ends up building by accident. It covers the template registry that makes DLT drift a build failure instead of a 2am incident, the idempotency discipline that stops failover from double-sending OTPs, the channel ladder that crosses from Meta's approval regime into TRAI's without anybody noticing, and the reconciliation job that finally answers whether the message landed.

We are writing this from production, not from a vendor doc. We run these flows on MultiVendor CRM (lead and order alerts) and PlugEV (session start/stop, payment receipts). The architecture below is the one we maintain, including the parts where we tell you not to build it.

Delivered in Our Logs, Never on the Handset

The project always starts with the same ticket. A customer swears the OTP never came. Your gateway dashboard shows DELIVERED. Your application logs show 200 OK from the send call. Three systems agree, and all three are wrong. Nobody is lying, they are each reporting on a different hop, and none of them can see the handset.

In our experience, the failure is almost always one of three things. Template drift: someone edited the marketing copy in the DLT portal, the variable count changed, and the operator now silently rejects a message your gateway still acks. Header category mismatch: a transactional OTP going out on a promotional header, then getting swallowed by DND filters for exactly the users who complain loudest. A fallback rung that never fired: your code has a WhatsApp retry, but the SMS provider returned a soft success, so the retry never triggered. Each of these has a design answer, not a checklist item.

There is a fourth failure mode, and this one is written into the regulation itself. Clause 8(e) of the 18 November 2025 TRAI Direction mandates a "logger mode" for the first sixty days from the start of variable-tag scrubbing: during that window, messages that fail variable-tag validation (the pre-tagging checks we cover below) are still delivered, after the operator generates a fault message, and only after the sixty days are failing messages rejected outright. Read that again. The regulator required a period where broken templates keep landing on handsets while quietly generating faults you never see, and clause 8(f) puts the duty to identify and notify the affected Principal Entity on the Access Provider, not on you. That is the green-dashboard, broken-system pathology this whole post is about, except mandated by law, which means your gateway may have been sitting on fault messages about your templates that you were never shown. When scrubbing crosses its sixty-day mark, those same messages stop being delivered. If you did not fix them during logger mode, they fail hard on the day the grace ends.

Scope, plainly. This assumes you already have an entity ID, a registered header, and a gateway account. Registration is your vendor's blog post, not ours.

The thesis: your SMS vendor cannot sell you the queue, the template registry, the idempotency layer, or the reconciliation job. You own that layer whether you designed it or it grew by accident.

DLT Is a Schema Constraint, Not Paperwork

Treat DLT like a database migration, not a GST filing. A filing is something you do once and forget. A migration is something your code knows about, your CI enforces, and your deploy fails on when it drifts. DLT decides the shape of your message payload: which header sends it, which template ID it must match, how many variables it carries, and where they sit in the string. That is schema. It belongs in your codebase, not in a spreadsheet somebody's ops lead maintains.

Six layers own six different things, and most broken systems have collapsed all six into one controller. The domain event says OrderShipped and nothing more. The notification intent decides this event deserves a message and who receives it. The channel policy picks SMS, WhatsApp, or email based on regime (transactional OTP versus service-explicit promo) and consent state. Template resolution maps intent plus channel plus locale to a registered DLT template and its variable slots. The provider adapter speaks one gateway's HTTP dialect. DLR reconciliation closes the loop and tells you it actually landed.

Your domain code should never know a Template ID exists. The moment checkout_controller.py contains "1707161234567890123", you have lost. We see this in almost every audit: bare string literals pasted across controllers, no registry, no single call site. When an operator rejects a template, nobody can grep their way to every usage, so the fix ships partial and OTPs keep failing for the one path someone missed.

The PE to telemarketer binding chain is deploy-time configuration, not application logic, and it helps to know why. On every message the operator's scrubber validates five things: your PE ID, the header, the content template ID, the PE-to-telemarketer binding chain, and the recipient's DND status. Four of those five are static config for a given deploy, which is exactly why they belong in typed config that fails loudly on boot, not env-var soup that fails silently at 2am. Entity ID, header, and template IDs go in that config. And if you are building multi-tenant, each tenant may carry its own entity ID and header, so make the registry tenant-aware on day one rather than retrofitting it under load.

The Template Registry: Your Approved Body, in Version Control, Checked by CI

Check the operator-approved body, character for character, into version control next to the code that renders it. This is the single highest-leverage move in this post. We built a free DLT template checker you can paste a template into to catch the common rejection causes (untyped variables, adjacency, the two-variable limit) before you ever register it.

The shape to copy is a registry object keyed by domain name, not by template ID. otp.login, order.shipped, payment.failed. Each entry holds the templateId the operator issued, the registered body string exactly as approved, the declared variables with their types, the category (transactional, service-implicit, promotional), and the registered header. One file. Greppable. When a backend dev asks "which sender ID does the OTP go out on," the answer is a single search, not a Slack thread with your gateway account manager.

The CI test that earns its keep renders each template with fixture values, then asserts the static text matches the approved body exactly. Not fuzzy, not trimmed, not normalized. A stray double space between "OTP" and "is" fails the build. A PM changing Rs. to INR in a "harmless copy tweak" fails the build. A smart quote pasted out of a Google Doc, the one that renders identically in every editor you own, fails the build. All of these otherwise fail at the operator's scrubber instead, at 2am, silently, on your live login flow.

This is why the registry beats the checklist posts already ranking for this. "Fix your template mismatch" is reactive advice you apply after production breaks. A CI assertion makes drift structurally impossible to merge. Same problem, one level up.

Registry-level validation also catches the variable ceilings at build time. Under the pre-tagging regime this post is about, limits are per tag, not a single global 30-character ceiling. The Direction's Annexure-I caps #alphanumeric# at 40 characters in scrubbing ("Not more than 40 Characters will be allowed in scrubbing"), while #cbn# runs 3 to 14 and #url# allows 120 (TRAI Direction, 18 Nov 2025, Annexure-I). That is exactly why the type belongs in the registry entry: one ceiling per slot type, asserted in CI. And length is not even the sharp edge. #alphanumeric# is specified as letters and numbers only, so a customer named "Venkataraman Subramanian", well under any length limit, still fails type validation on the space in the middle, and a hyphenated order ID fails on the hyphen. Those become failing tests instead of an incident and a refund.

The registry also has to assert two rules that predate pre-tagging and that most checklist posts miss entirely. First, variable count: the number of variable portions in a content template is capped at two, a third allowed only for reasons recorded as an exigency, and more than that only on written requisition with justification from the Principal Entity. Second, adjacency: variables must be non-contiguous, never placed back to back and never separated only by a space, comma, or other special character. Both come from TRAI's 16 February 2023 Direction, recounted in the 18 November 2025 Direction. Put a variable-count assertion and an adjacency check in the same CI test that matches the body, and a template that quietly grows a third variable in a copy edit fails the build instead of the scrubber.

Version the registry deliberately. When an operator approves a new body, it lands as a PR with a reviewable diff, which means you can revert a bad template as fast as you shipped it.

Typed Variables: TRAI's 2026 Pre-Tagging Is a Type System Now

Pre-tagging is not new, and getting its history right matters for how you plan around it. TRAI directed variable pre-tagging back in its 12 May 2023 Direction, and the principle is already written into item 4(3) of Schedule-I of the TCCCPR 2018: each variable in a message template should be pre-tagged for the purpose it is proposed to be used. The reason the 18 November 2025 Direction exists at all is that, in TRAI's own words at para 7(a), the earlier directions "have not been implemented till date" (see also TRAI Press Release 133/2025). The 2025 Direction universalises tagging to every variable field and attaches a hard rejection date. So the industry got roughly two and a half years of grace, and what expired in 2026 was the grace, not the rule. Most coverage treated this as a registration chore. It is not. It is a schema change to your application.

The dates are widely misreported, so pin them to the Direction. Clause 8(c) requires all new content templates registered after ten days from the date of issue, that is from 18 November 2025, to be pre-tagged. Clause 8(d) gives the remaining, older templates sixty days to comply, and the clock starts from when your operator begins variable-tag scrubbing, not from any template's registration date and not from a fixed calendar deadline. Gateways reported scrubbing commencing around mid-January 2026, which put the practical migration deadline near mid-March 2026. The "14 January 2026" date circulating in vendor posts is at best the day operators started scrubbing, not a TRAI deadline, so confirm your operator's actual scrubbing start date with your gateway.

The six tags are types, and your template abstraction has to carry them. #numeric#, #alphanumeric#, #url#, #urlott#, #cbn#, #email#. One nuance worth reading the Direction for rather than a vendor summary: Annexure-I writes the first as "#number# / #numeric#", so check which spelling your operator's portal actually accepts (TRAI Direction, 18 Nov 2025, Annexure-I). Before pre-tagging, a variable was a hole you dropped a string into and the operator's scrubber sorted it out. Now every slot has a declared type that lives on the operator's side, and your code has no idea about it unless you put it there.

Declare the type in your template registry and validate at the boundary. If your registry entry for ORDER_CONFIRM says slot 2 is #numeric#, that fact belongs in the entry, enforced with Zod or whatever you already use. Then a bad send throws inside your process, with a stack trace, at the call site. The alternative is finding out from a delivery report the next morning, or worse, from a customer.

Here is the bug this catches. You register a slot as #numeric# because your fixtures are 4471. Production order IDs are ORD-4471. Staging is green, production OTPs and confirmations quietly die at the scrubber, and nothing in your logs says "type rejection" because the rejection happened at someone else's shop.

URLs are the sharp edge. #url# and #urlott# are not interchangeable, shortened links and UTM parameters interact badly with whitelisted-domain rules, and the failure is invisible from your side. When marketing swaps the shortener next quarter, your template breaks and nobody connects the two events.

Pre-tagging is a code-layer change. That is exactly why the gateways who sent migration notices could tell you it was coming, but not what to refactor.

Header and Category Routing: The Choice That Silently Kills DND Delivery

The failure mode here looks like nothing at all. Register a header under the wrong category and your OTP is legally a promotional message. Every subscriber with DND active never sees it. The gateway still returns a message ID, your service still logs sent, your dashboards stay green, and your error rate is zero. Nothing in your stack ever says blocked. You find out from support tickets about users who cannot log in, weeks late. Hundreds of millions of Indian numbers carry a DND preference (NCPR registrations past 230 million), so even at a conservative share this is a large, silent slice of your delivery, not an edge case.

Category belongs in the registry as a routing decision, never in a vendor dashboard. Every template row declares its category and resolves to exactly one header. The router reads that at lookup time and refuses to put an OTP on a promotional header. A toggle somebody flipped in a gateway console six months ago is not a system, it is a rumour.

Untangle the three names before mapping anything. Service Implicit, Service Explicit, and Transactional are not what most backend teams assume. In developer speech "transactional" just means "not marketing". Under DLT rules, Transactional is a narrow category restricted to banks sending OTPs and account alerts, open to national, scheduled, private, government, and MNC banks and nobody else. Most order updates and shipment alerts, and every OTP from a non-bank product, are Service Implicit, not Transactional. We have seen teams register everything as Transactional and then wonder why traffic gets filtered.

Consent is data, not a checkbox. Service Explicit means producing the record on demand, so store principal, timestamp, source (which form, which screen, which IVR flow), scope, and revocation state. Those records and the message bodies are personal data under a second regime, which we cover in our DPDP compliance guide.

Our build rule: one header per category, wired into the registry, resolved at lookup. If a template has no header for its category, the build fails instead of production.

The Channel Ladder, and the Regime Boundary Nobody Designs For

When your OTP falls from WhatsApp to SMS, it does not just change transport, it changes jurisdiction. The WhatsApp rung lives under Meta's utility template rules, approved by Meta. The rung directly below it lives under TRAI's DLT regime, approved through your operator's DLT portal via your gateway. Two templates, two approval bodies, two sets of failure modes, aimed at the same user in the same second. Most notification code we inherit treats that boundary as a config flag.

The design consequence: your fallback rung needs its own registered DLT template and header, approved before you ever need it. Teams learn this mid-incident. WhatsApp goes flaky at 9pm, someone reroutes OTPs to SMS, and the gateway rejects every send because there is no approved template ID and no registered Service Implicit header for that content. A new content template is typically one to three working days to approve, and a header you do not already have adds one to two more, so the full path from nothing to a live fallback runs closer to three to seven working days. Your incident does not wait that long.

The ladder we actually build: WhatsApp utility template, then SMS on a Service Implicit header, then email. Transactional if you are a bank, Service Implicit for everyone else, which is most readers, and neither MultiVendor CRM nor PlugEV is a bank. Each rung owns its own entry in the template registry, its own timeout, and its own delivery contract. The registry is what makes the regime boundary explicit in code instead of leaving it in one engineer's head.

Do not ladder on optimism. WhatsApp "sent" is not "read", and silence is not failure. Define the promotion signal explicitly: a failed status callback, or no delivery confirmation inside N seconds. Then cap total attempts, or a flapping provider fans out four messages to one user, who now holds four OTPs and trusts none of them.

Cost asymmetry is real and it changes the design. Since 1 July 2025, WhatsApp bills per delivered template message, not per conversation, and the old "utility conversation" billing unit is gone (Meta pricing updates). Utility templates sent inside an open customer-service window are free, while authentication templates are billed even inside that window, which is the opposite of the asymmetry most teams assume. Your OTP rung is precisely the category that never rides the free window. We will not quote rates we cannot defend, but the model is a fact: authentication is metered, so model the ladder at your real volume before you tune timeouts down.

For the platform layer underneath the architecture, see our WhatsApp Business API guide and the WhatsApp CRM setup post.

The Queue: Mint the Idempotency Key Before the Provider Call

Mint the idempotency key before the provider call, not after the response. Everyone gets this backwards. If you wait for the provider to hand you a message ID and then store it, the window where you have already sent but recorded nothing is exactly the window where the timeout happens. Key on (event_id, channel, recipient) with a unique constraint in Postgres, insert first, send second. The insert is your permission slip to call the provider.

A duplicate OTP is worse than a missing one, which is why this matters more here than in most systems. The second SMS invalidates the first, so the user is now typing a code your backend has already retired, and they blame your app, not your gateway. Provider timeouts and cross-provider failover are precisely the conditions that produce double sends, so failover without a claimed key is a bug generator. Same discipline we wrote up in Razorpay webhooks, idempotency and reconciliation, and the same one we use for OCPP transaction dedupe on PlugEV: OCPP 2.0.1 security and transaction reconciliation.

Retries need a policy, not a for-loop. Exponential backoff with jitter, capped attempts, and a dead letter queue a human actually opens on Monday. Split retryable failures (5xx, connection timeout, gateway 429) from terminal ones (template rejected, invalid number, scrubber block). Retrying a scrubber rejection just burns INR credits at machine speed and teaches you nothing.

OTP gets its own rules. Rate limit per recipient and per IP. Treat a resend inside the validity window as an idempotent read of the existing OTP, not a fresh mint. The user pressing "Resend" three times should cost you one SMS.

BullMQ on Redis is an honest default at Indian SMB volume. In our own load tests on these builds it stops being enough somewhere past sustained five-figure messages per minute, or when you need replayable event history across teams, or when Redis memory becomes your ceiling. Most startups reaching for Kafka on day one are buying ops load, not throughput.

Provider Adapters: Making MSG91, Kaleyra, and Exotel Swappable Rungs

Your gateway will never write this section. Their docs are built to make their SDK the center of your notification stack, because a vendor whose API is one rung in a ladder you own is a vendor you can replace. That is exactly what we build for clients, and it is the part of the architecture that has the clearest commercial payoff.

The adapter interface is deliberately small: send(templateKey, vars, recipient) returns a providerMessageId. Everything DLT-specific resolves in your layer before the adapter is called. Entity ID, template ID, registered header, and category come out of your template registry, not out of vendor config. The adapter translates your normalized payload into the vendor's request shape and does nothing else. No retries, no logging decisions, no template lookups. In our builds, if an adapter file is longer than roughly 150 lines, DLT logic has leaked into it.

What actually differs between vendors is narrower than it looks. MSG91, Kaleyra, Exotel, and 2Factor diverge on four things: auth scheme (header key vs bearer vs query param), parameter naming (template_id vs dlt_template_id vs tid), DLR webhook payload shape, and error taxonomy. The first three are mechanical. The fourth is where teams bleed. Normalize every vendor error into your own enum at the adapter edge, something like INVALID_TEMPLATE, DND_BLOCKED, RATE_LIMITED, TRANSIENT, AUTH_FAILED. Your retry policy then gets written once, against your enum, instead of five times against five vendors' string codes.

Failover works because the idempotency key spans providers, not attempts. When provider A times out at the socket, the retry through provider B carries the identical key. Your outbox already knows a send is in flight for that key, so the second attempt is a continuation, not a new message. Without this, every failover is a double-OTP and an angry support ticket.

The honest caveat: swapping providers still means re-binding templates through the new telemarketer chain. A pure re-bind of already-approved templates through the delivery telemarketer's regulatory team is typically thirty to sixty minutes to a few hours, not seconds. A template that has to be re-registered rather than re-bound is one to three days on top. Either way it is not the config-flag switch vendors imply. Anyone selling you instant provider migration is selling a fantasy. Build the adapter layer anyway. Two things make it worth it. You get real leverage in rate negotiations, because a vendor who knows you can leave prices differently than one who knows you cannot. And you get an exit that exists, which matters the week your provider has an outage during a sale.

DLRs Are an Accounting System, Not a Dashboard

Treat delivery receipts like a ledger, not a webhook you eyeball. The handler does three things: verify the signature or shared secret, write the raw payload to a receipts table, return 200. Everything else happens in a worker. A DLR handler that takes 4 seconds because it updates six rows and pings Slack will make the provider retry, and now the data you built the whole system to trust is polluted with duplicates you caused yourself.

Dedupe on the provider's message ID and never trust ordering. DLRs come out of order, arrive twice, and occasionally show up for a message you sent last Tuesday. A DELIVERED landing after a FAILED for the same ID is normal, not a bug. Store the receipts append-only, then fold them into a current status with an explicit precedence rule, so replaying the table always yields the same answer.

The reconciliation job is where the delivered-but-never-received bugs finally die. Run a daily sweep across three columns: what we sent, what the provider says was delivered, and what the user actually did (OTP verified, link clicked, order page opened). Column one versus column two is the vendor's story. Column two versus column three is the truth, and no gateway dashboard will ever show you that gap, because the gateway cannot see your verify events.

Alert on OTP verify-rate sliced by template and by operator, not raw send counts. Sends stay flat while a rejected template quietly drops everything on one operator. Verify-rate cliffs hours before your first support ticket, usually overnight when nobody is watching sends anyway.

Set retention deliberately, and do not run one clock for everything. Split the table by what the data is. Message bodies and the phone numbers you sent to are personal data under purpose limitation, so erase them once the purpose they were collected for is served. Your processing logs, the send logs and DLR receipts themselves, are a different animal: under the DPDP Rules 2025 they carry a one-year minimum retention for forensic and investigative purposes before you may erase them. So a naive 90-day purge on the receipts table is not a privacy win, it is a compliance bug that can put you under the statutory floor. Do not reach for a single fixed clock. Enforce the two rules separately in the job, and keep the reasoning next to our DPDP compliance guide.

Email: The Channel With No DLT, and the Trap That Creates

Email has no DLT registry, and that is exactly why teams get burned by it. No approval queue means no forcing function, so the discipline you built for SMS quietly gets skipped. Then your OTP lands in Promotions, or a receiving MTA rejects it on DMARC policy, and you have the same silent failure as a template mismatch with a different root cause and no gateway dashboard to blame it on.

Treat email as a peer channel, not an escape hatch. Same template registry with versioned bodies, same idempotency key so a retry does not double-send, and the DLR equivalent is your bounce and complaint webhooks. Nobody approves your body first, so your registry is the only place drift gets caught.

The deliverability floor is not optional. SPF, DKIM, and DMARC alignment on the sending domain, plus Gmail's bulk-sender rules, which bind any sender of 5,000 or more messages a day to Gmail addresses: SPF and DKIM both, DMARC on the sending domain, a one-click unsubscribe on marketing mail, and a user-reported spam rate kept under 0.1 percent (Gmail sender guidelines). The honest caveat for an Indian OTP sender is that you may sit under 5,000 a day to Gmail specifically, so the bulk tier may not bind you, but SPF and DKIM are expected of every sender regardless of volume.

Email is the right last rung and the wrong first one. Cheap, no approval regime, slowest to arrive, least reliable for auth. Never put it above SMS in an OTP ladder.

What to Build First, and When to Just Use the Vendor SDK

Stage this honestly, because the order decides whether you waste a month. Build the template registry and the CI character-match first. It was about a week of work for us on PlugEV, including the fixture set, and it kills the most expensive bug on the list: template drift silently dropping OTPs while your dashboard says everything is green. Second, the idempotent queue, so a retry never double-charges a customer's patience with two OTPs. Third, provider adapters and the reconciliation job. Reconciliation last is deliberate. You cannot reconcile deliveries you are not yet sending correctly.

Now the part that argues against our own invoice. If you send low volume on one channel through one vendor, use their SDK and go. Seriously. The queue, the registry, the idempotency layer, none of it earns its cost at that size. This architecture starts paying when you have two channels, or two vendors, or real revenue sitting behind a single OTP that has to land in eight seconds.

Where you are decides what you need. OTPs cratering right now, that is a delivery-failure audit. Already patching and want it to stop recurring, that is an OTP hardening sprint. Pre-launch and scoping, that is a notification service build.

Frequently asked questions

Our gateway dashboard says the SMS was delivered, but the customer never received it. What is actually going on?

The dashboard is reporting on the hop it can see, which is usually the handoff to the operator, not the handset. The three usual culprits are template drift (an edited body no longer matches the registered template, so the operator's scrubber drops it), a category mismatch (a transactional OTP riding a promotional header, filtered for every DND subscriber), or a fallback rung that never fired because the provider returned a soft success. The only way to know which one is to compare what you sent against what the user actually did, which means a reconciliation job, not a dashboard.

Do we need a separate DLT template and header for the SMS fallback when a WhatsApp message fails?

Yes, and you need it approved before the incident, not during it. The WhatsApp rung is approved by Meta under its utility template rules; the SMS rung below it is approved through the DLT portal under TRAI's regime. They are two different templates governed by two different bodies, and a new content template is typically one to three working days to approve, with a header you do not already have adding one to two more. If you build the ladder without a registered fallback template and a registered Service Implicit header, your gateway will reject every send at exactly the moment you need it. Unless you are a bank, that fallback rides Service Implicit, not Transactional.

Can we switch SMS providers (say MSG91 to Kaleyra) without redoing our DLT registration and templates?

Your entity ID, header, and approved template bodies stay yours. A pure re-bind of those templates through the new telemarketer chain is typically thirty to sixty minutes to a few hours, and any template that has to be re-registered rather than re-bound is one to three days on top. Anyone promising instant provider migration is selling a fantasy. What the adapter layer buys you is that the switch is a config change plus a binding wait, not a rewrite of every controller that sends a message. That is worth building for the rate leverage alone.

What changed with DLT variable pre-tagging in 2026, and do our older templates still work?

Pre-tagging itself is not new: TRAI directed it in May 2023 and it is written into Schedule-I of the TCCCPR 2018. The 18 November 2025 Direction exists because TRAI recorded that the earlier directions had not been implemented, so it universalised tagging to every variable field and attached a hard rejection date. Clause 8(c) requires new content templates registered after ten days from the 18 November 2025 issue to be pre-tagged, and clause 8(d) gives older templates sixty days from the start of your operator's variable-tag scrubbing, not from any fixed calendar date. Gateways reported scrubbing beginning around mid-January 2026, which put the migration deadline near mid-March 2026, so confirm your operator's actual scrubbing start date. In practice every variable slot now carries a declared type on the operator's side (#numeric#, #alphanumeric#, #url#, #urlott#, #cbn#, #email#, with Annexure-I writing the first as "#number# / #numeric#"). If your older templates were migrated, they work; if they were not, they fail at the scrubber with nothing useful in your logs. Note that clause 8(e) mandates a sixty-day logger mode from the start of scrubbing, during which failing messages are still delivered after generating fault messages, so a template can look fine right up to the day the grace ends and it starts being rejected outright. Put the types in your template registry and validate at the boundary so the failure happens in your process instead.

Is it worth building our own notification layer, or should we just use our gateway's SDK directly?

If you send low volume on one channel through one vendor, use the SDK and go. The registry, the queue, and the idempotency layer do not earn their cost at that size, and we will tell a client that before quoting them. This architecture starts paying once you have two channels, or two vendors, or real revenue sitting behind an OTP that has to land in eight seconds. The one piece we would build early regardless is the template registry with a CI character-match, because it was about a week of work for us on PlugEV and it kills the most expensive bug on the list.

How do we stop retries and provider failover from sending the same OTP to a customer twice?

Mint the idempotency key before the provider call, not after the response, and key it on (event_id, channel, recipient) with a unique constraint in Postgres. Insert first, send second, so the row is your permission slip to call the provider. When provider A times out at the socket and you fail over to provider B, the retry carries the identical key, so your outbox recognises it as a continuation rather than a new message. Also treat a user pressing "Resend" inside the validity window as a read of the existing OTP, not a fresh mint.

The honest summary

None of this is clever. It is a registry, a queue, a thin adapter, and a job that checks whether the thing you sent actually landed. The reason it is rare is not difficulty, it is that no vendor has a reason to sell it to you, so it gets built in fragments during incidents and never gets a name.

If your OTPs are failing right now, start with the reconciliation query, not the refactor. Find the gap between what your provider says it delivered and what your users actually verified. That number tells you which of the three failure modes you have, and it usually takes an afternoon.

If you would rather have someone who has done this a few times look at it with you, we do delivery-failure audits, OTP hardening sprints, and full notification service builds. Flat INR quotes, GST invoice, no fear-selling. Get in touch, or run the cost calculator if you want a build estimate first.

RR
Written by
Ravi Rai

Founder of buildbyRaviRai, a freelance web development agency based in Noida, India. 5+ years shipping Next.js, WordPress, Shopify, and Laravel projects for clients in India, USA, Canada, and the UK.

Keep Reading

Backend, Zoho Books Integration in India (2026): Connect Your Website, Store, or CRM to Your Books, What It Costs, and Build vs BuyBackend

Zoho Books Integration in India (2026): Connect Your Website, Store, or CRM to Your Books, What It Costs, and Build vs Buy

Zoho Books integration in India: connect your website, store, or CRM to your books. REST API, webhooks, GST/IRN handling, 2026 costs, and build vs buy.

Backend, Tally Integration With Your Website or Custom Software (India, 2026): How It Actually Works, What It Costs, and When to Build vs BuyBackend

Tally Integration With Your Website or Custom Software (India, 2026): How It Actually Works, What It Costs, and When to Build vs Buy

Tally integration with a website or custom software, explained the way it actually works: the XML API on port 9000, TDL and ODBC, build versus buy, real 2026 India costs, and how to stop duplicate vouchers in TallyPrime.

Backend, How to Actually Accept Payments in India in 2026: UPI, Cards, Autopay, and What Each One Really CostsBackend

How to Actually Accept Payments in India in 2026: UPI, Cards, Autopay, and What Each One Really Costs

Taking money online in India works differently from almost anywhere else: a national rail that is free to accept, an RBI rulebook rewritten in 2025, recurring payments that do not behave like a US SaaS, and card rules that forbid storing a card number at all. This is the honest, practical guide for founders and developers: payment gateway versus aggregator, how UPI and cards and autopay actually work, what each method really costs, how to collect from customers abroad, and a checklist to go live without leaking money on fees or failing renewals.

Backend, Multi-Tenant SaaS Architecture in 2026: Data Isolation, Per-Tenant Billing, and Scaling (A Developer's Guide)Backend

Multi-Tenant SaaS Architecture in 2026: Data Isolation, Per-Tenant Billing, and Scaling (A Developer's Guide)

Multi-tenancy is the single most consequential decision in any SaaS build, and the one founders never hear about until it causes pain. This is the developer-honest guide: the three isolation models and their trade-offs, how to keep one tenant's data truly separate from another's, tenant routing, per-tenant billing and entitlements, the noisy-neighbour problem, migrations, and the security mistakes that leak data.

Backend, GST E-Invoicing in India 2026: How IRN, the IRP API, and QR Codes Actually Work (A Developer's Guide)Backend

GST E-Invoicing in India 2026: How IRN, the IRP API, and QR Codes Actually Work (A Developer's Guide)

E-invoicing is not 'generate a nicer PDF'. It is reporting each B2B invoice to a government portal (the IRP), getting back a signed IRN and QR code, and doing it inside the time limit. This is the developer-honest guide to how the IRP API works: the turnover threshold, the auth and encryption handshake, the invoice JSON schema, how the IRN is generated, the signed QR code, the 24-hour cancellation rule, and the mistakes that quietly break integrations.

bR

buildbyRaviRai Assistant

Replies within 24 hours

Chat on WhatsApp

+91 74289 19927 · Replies within 24 hours

Pick a quick message to start a conversation on WhatsApp, or type your own below. Your message pre-fills, you hit send from WhatsApp.

Or type your own

We'll send your message via WhatsApp Web or the WhatsApp app.