
Table of Contents
- Executive summary
- TL;DR
- The inversion, stated precisely
- The two endpoints, exactly as documented
- The response contract, and what is missing from it
- Correlating a send with no identifier
- The three timestamps are millisecond epochs
- Designing the resend button from retryAfter
- The arithmetic of an impatient user
- The undocumented type field, and why it matters most
- Shed, do not buffer
- The DLT layer for OTP templates
- Validating the OTP body before you ship it
- Route characteristics: why OTP is not promotional
- Server-side verification rules the API does not enforce
- The sticky session cookie in the official sample
- Stop putting OTP bodies in query strings
- Multi-channel fallback for OTP
- Monitoring OTP separately from everything else
- Testing OTP without billing yourself
- The ten-step build order
- Decision matrix
- The reliability checklist
- Ten mistakes
- What this article deliberately does not claim
- FAQ
Executive summary
An OTP is not a short transactional SMS with a number in it. It is a message whose entire value is destroyed by latency, whose failure is immediately visible to the user, and whose recovery path is a button that costs you money every time it is pressed. Every policy that makes campaign messaging efficient makes OTP delivery worse.
Both operations run against one endpoint, https://unify.smsgateway.center/SMSApi/otp, switched by sendMethod. Generate returns statusCode 300 and three millisecond epochs. Verify returns statusCode 301. That part is twenty minutes of work.
The reliability work is elsewhere, and it starts with something the documentation shows but does not comment on: the OTP generate and verify responses both return transactionId as the literal string "0". There is no per-send identifier. You cannot correlate an OTP submission to a delivery report by transaction ID the way you can for every other send on this platform, because there is nothing to correlate on. Correlation has to be reconstructed from recipient, sender ID and a time window, and if you did not design for that up front you will discover it during your first billing dispute.
This guide covers the inversion in policy, the correlation problem and its workaround, sizing the resend cooldown from the documented retryAfter, the cost arithmetic of an impatient user, the DLT layer for OTP templates, the route characteristics that make OTP different from promotional traffic, verification rules the API does not enforce for you, and how to monitor OTP separately so that an OTP incident is visible before support tickets arrive. It assumes you already know how to make an HTTP request; the language-level integration work lives in the PHP, Node.js and Python tutorials.
TL;DR
transactionIdis"0"on both OTP operations. Build correlation on recipient plus sender ID plus time window, and store your own identifier.createTime,expiryTimeandretryAfterare millisecond epochs, not durations and not ISO strings.- Drive the resend button’s cooldown from
retryAfter. A hardcoded fifteen seconds either generates rejected requests or generates a second billed send. - Shed OTP load, never buffer it. A queued OTP is a guaranteed duplicate charge, because the user resends before your queue drains.
- Never retry an ambiguous OTP submission. There is no transaction ID to reconcile it with, so an ambiguous send is permanently ambiguous.
- Enforce attempt limits, single-use consumption and per-recipient rate limits yourself. The verify endpoint does not document any of them.
- Use
SMSApi/previewto confirm your OTP body matches a registered DLT template before you deploy it. - Monitor OTP on its own sender ID with
SMSApi/reports/smsSummaryandgroupby=senderid. A global delivery rate will not move when OTP breaks. - Alert on the 95th percentile of submit-to-delivery latency, not the mean. The mean is fine during every OTP incident you will ever have.
The inversion, stated precisely

Most messaging advice optimises for throughput and cost per message. OTP optimises for tail latency and correctness, and will happily spend more per message to get them. Here is the reversal, row by row.
| Policy | Campaign or bulk traffic | OTP |
|---|---|---|
| Over capacity | Buffer and drain later | Shed immediately and tell the user |
| Ambiguous submission | Reconcile, possibly resend | Never resend, fail the attempt |
| Batching | Batch aggressively, hundreds per request | Never batch, one recipient per request |
| Latency target | Minutes is fine | Seconds, and the tail is what counts |
| Retry on transient failure | Yes, with backoff | Only if the user asks, via the resend button |
| Cost focus | Cost per message | Cost per successful authentication |
| Failure visibility | Discovered in reports | Discovered by the user, instantly |
| Encoding | Unicode acceptable | Keep it GSM-7, always |
| Delivery window | Restricted for promotional | 24×7 |
| DND numbers | Filtered on promotional routes | Delivered |
Two rows deserve expansion because they are the ones people get wrong.
Buffering an OTP is worse than dropping it. If your queue is thirty seconds deep and you enqueue an OTP, the user waits, sees nothing, and presses resend at around fifteen seconds. You now have two OTPs in the queue, you will be billed for both, and depending on how your verification store handles a second generate, the first code may no longer be valid when it finally arrives. The user then receives two messages, one of which does not work. Shedding, by contrast, produces an immediate error, an immediate retry by the user, and one charge.
Batching an OTP is a category error. The send endpoint accepts a comma-separated recipient list, and it is tempting to reuse the same client. Do not. An OTP is per-user state; there is no scenario where two users legitimately share one submission. Worse, on the batch send path there is one top-level transactionId per request, so batching would compound the correlation problem described below.
The two endpoints, exactly as documented
Both operations POST or GET to the same URL, distinguished by sendMethod.
https://unify.smsgateway.center/SMSApi/otp
Generate, documented at Generate OTP:
| Parameter | Value | Notes |
|---|---|---|
userid + password, or apiKey header | credentials | Choose one method, not both |
mobile | integer | One recipient |
sendMethod | generate | |
msgType | text or unicode | Use text |
msg | body containing the literal $otp$ | The platform substitutes the code |
medium | sms | Default is sms |
codeType | num, alpha or alphanum | Use num |
codeExpiry | seconds | Default is 300 |
codeLength | integer | Such as 4 or 6 |
senderid | approved header | |
output | plain, json or xml | System default is plain, so always set json |
Two documentation notes worth knowing rather than discovering. The codeType value column contains a typo, listing aplha where the description says alpha. And the official cURL sample passes format=json rather than output=json. Those are not two different parameters: the Preview Message documentation states plainly that output is an “Alias for format parameter”. Either works. Pick output and be consistent, because that is the name in the parameter tables across the rest of the API.
Verify, documented at Verify OTP, takes only mobile, sendMethod=verify, otp and output.
# Generate
curl -s -X POST "https://unify.smsgateway.center/SMSApi/otp" \
-H "Content-Type: application/x-www-form-urlencoded" \
-H "apiKey: YOUR_API_KEY" \
--data-urlencode "userid=YOUR_USERNAME" \
--data-urlencode "sendMethod=generate" \
--data-urlencode "mobile=919999999999" \
--data-urlencode "msgType=text" \
--data-urlencode "msg=Your verification code is \$otp\$. Valid for 5 minutes. YourBrand" \
--data-urlencode "medium=sms" \
--data-urlencode "codeType=num" \
--data-urlencode "codeExpiry=300" \
--data-urlencode "codeLength=6" \
--data-urlencode "senderid=YOURID" \
--data-urlencode "output=json"
# Verify
curl -s -X POST "https://unify.smsgateway.center/SMSApi/otp" \
-H "Content-Type: application/x-www-form-urlencoded" \
-H "apiKey: YOUR_API_KEY" \
--data-urlencode "userid=YOUR_USERNAME" \
--data-urlencode "sendMethod=verify" \
--data-urlencode "mobile=919999999999" \
--data-urlencode "otp=438592" \
--data-urlencode "output=json"
Note the escaped \$otp\$ in the shell. In a real client the placeholder is the literal seven-character string $otp$ and must survive whatever templating layer you put in front of it. A language that interpolates $ in double-quoted strings, or a regex-based renderer, will eat it. This is the same class of bug as the DLT variable substitution hazard, and it fails silently: the message goes out containing the literal text rather than a code.
The response contract, and what is missing from it
The documented generate success response:
{
"status": "success",
"mobile": "91777xxxxxxx",
"transactionId": "0",
"statusCode": "300",
"type": "new",
"reason": "OTP successfully generated.",
"createTime": "1700117933163",
"expiryTime": "1700118233163",
"retryAfter": "1700117993163"
}
The documented verify success response:
{
"status": "success",
"mobile": "91777xxxxxx",
"transactionId": "0",
"statusCode": "301",
"reason": "OTP has been Verified Successfully."
}
Read transactionId again. It is "0" in both. Not a number, not an identifier, not a correlation key. Every other send path on this platform returns a real transactionId you can carry into reporting; the OTP path does not.
That single fact drives three design decisions, and it is the reason this article exists as an engineering guide rather than a parameter reference.
Correlating a send with no identifier

You need to answer three questions after an OTP goes out: did it get delivered, how long did it take, and what did it cost. Without a transaction ID, all three have to be answered by reconstruction.
The reconstruction key is recipient plus sender ID plus a tight time window. Store your own row at generate time and match against the delivery report later.
CREATE TABLE otp_attempt (
id BIGSERIAL PRIMARY KEY,
attempt_key UUID NOT NULL UNIQUE, -- yours, not theirs
recipient TEXT NOT NULL,
sender_id TEXT NOT NULL,
purpose TEXT NOT NULL, -- login, payment, password_reset
status_code TEXT, -- "300" on success
generate_type TEXT, -- the platform's `type` field, verbatim
created_at TIMESTAMPTZ NOT NULL, -- from createTime
expires_at TIMESTAMPTZ NOT NULL, -- from expiryTime
retry_after TIMESTAMPTZ NOT NULL, -- from retryAfter
submitted_at TIMESTAMPTZ NOT NULL DEFAULT now(),
delivered_at TIMESTAMPTZ, -- filled by reconciliation
dlr_status TEXT,
dlr_cause TEXT,
cost NUMERIC(12,4),
verify_count INT NOT NULL DEFAULT 0,
verified_at TIMESTAMPTZ,
consumed BOOLEAN NOT NULL DEFAULT false
);
-- The reconciliation access pattern: unmatched rows, oldest first.
CREATE INDEX ix_otp_unreconciled
ON otp_attempt (submitted_at)
WHERE delivered_at IS NULL;
-- The correlation access pattern.
CREATE INDEX ix_otp_correlate
ON otp_attempt (recipient, sender_id, submitted_at DESC);
Reconciliation pulls from the delivery report endpoint filtered by recipient, then matches on the tightest time window that is still safe.
from datetime import timedelta
from decimal import Decimal
# Rows within this window of our submit time are candidates.
CORRELATION_WINDOW = timedelta(seconds=90)
def reconcile(attempt, dlr_rows):
"""
dlr_rows: reports_dlrList entries for this recipient, from
https://unify.smsgateway.center/SMSApi/reports/status with method=getDlr
"""
candidates = [
r for r in dlr_rows
if r["mobileNo"] == attempt.recipient
and r["senderName"] == attempt.sender_id
and abs(parse_ts(r["submitTime"]) - attempt.submitted_at) <= CORRELATION_WINDOW
]
if len(candidates) == 1:
row = candidates[0]
attempt.delivered_at = parse_ts(row["deliveryTime"])
attempt.dlr_status = row["status"]
attempt.dlr_cause = row["cause"]
attempt.cost = Decimal(row["cost"] or "0") # never float
return "matched"
if len(candidates) == 0:
return "not_found"
# Two OTPs to the same number inside 90 seconds is the RESEND case,
# which is common, not rare. Do NOT guess. Record the ambiguity.
return "ambiguous"
The ambiguous branch is the one that matters and the one most implementations omit. Two OTPs to the same recipient within ninety seconds is not an edge case, it is exactly what a resend looks like, and a naive “take the first match” will attach the wrong delivery record to the wrong attempt. Count ambiguous reconciliations as their own metric. If that count is high, your resend cooldown is too short.
Three practical notes. Use the sender ID filter, not just the recipient, because a user who receives a shipping notification and an OTP in the same minute gives you two candidate rows that are trivially separable by header. Keep the window tight; ninety seconds is a starting point to tune, not a constant to copy. And treat not_found as an inference rather than an observation, because it may simply mean the report has not landed yet. Retry it before you conclude anything.
The three timestamps are millisecond epochs
createTime, expiryTime and retryAfter arrive as quoted strings containing millisecond epochs. Convert them at the boundary and never let a raw string or a bare integer escape into your domain layer.
The sample values are internally consistent and worth reading arithmetically:
createTime 1700117933163
expiryTime 1700118233163 = createTime + 300000 ms = codeExpiry of 300 s
retryAfter 1700117993163 = createTime + 60000 ms = 60 s
So expiryTime is createTime plus whatever codeExpiry you requested, which is the behaviour you would expect. The sixty second retryAfter gap is an observation from the documented sample, not a stated rule, and the documentation does not say whether that interval is fixed, configurable, or account-dependent. Read it from the response every time. Do not hardcode sixty.
from datetime import datetime, timezone
def to_instant(raw):
"""Millisecond epoch, sometimes quoted. Returns an aware datetime."""
if raw is None or raw == "":
return None
return datetime.fromtimestamp(int(str(raw).strip()) / 1000, tz=timezone.utc)
def parse_generate(payload, clock):
if payload.get("statusCode") != "300":
raise OtpGenerateFailed(payload.get("reason"), payload.get("statusCode"))
return OtpChallenge(
recipient = payload["mobile"],
created_at = to_instant(payload.get("createTime")),
expires_at = to_instant(payload.get("expiryTime")),
retry_after = to_instant(payload.get("retryAfter")),
generate_type = payload.get("type"), # store it verbatim
raw = payload,
)
Pass a clock into anything that computes a remaining duration. Expiry logic is untestable without an injectable clock, and untested expiry logic is wrong expiry logic. With a fixed clock you can assert behaviour at the exact millisecond of expiry rather than hoping a sleep-based test is not flaky.
One defensive detail: the platform’s clock and yours will differ. If retryAfter is already in the past when you receive it, clamp to zero rather than producing a negative countdown, and if expiresAt is implausibly far in the future, log it rather than trusting it. Never compute the countdown by taking your own now() and adding a constant; that discards the server’s opinion, which is the authoritative one.
Designing the resend button from retryAfter
The resend button is the most consequential piece of UI in an OTP flow, and it is almost always built from a constant somebody picked in a design review.
There are exactly two ways to get it wrong.
Cooldown shorter than the platform’s retryAfter. The user presses resend, your server calls generate, and the platform declines because the previous code is still within its retry interval. The user sees an error for pressing a button you showed them as enabled. This is a pure self-inflicted support ticket.
Cooldown longer than typical delivery latency. The message is genuinely lost, the user can see it is not arriving, and your UI makes them wait. They abandon, or they refresh the page and start a new flow, which generates a fresh code anyway and bills you the same as a resend would have.
The correct value is the server’s, surfaced to the client and enforced again on the server.
def start_otp(recipient, purpose, clock, store, client):
# Server-side floor. The UI countdown is a courtesy, not a control.
last = store.last_attempt(recipient, purpose)
if last and clock.now() < last.retry_after:
wait = (last.retry_after - clock.now()).total_seconds()
raise OtpTooSoon(retry_in_seconds=max(0, int(wait)))
payload = client.generate(recipient=recipient, ...)
challenge = parse_generate(payload, clock)
store.record(challenge, purpose=purpose)
# Give the client the countdown rather than a boolean.
return {
"retry_in_seconds": max(0, int((challenge.retry_after - clock.now()).total_seconds())),
"expires_in_seconds": max(0, int((challenge.expires_at - clock.now()).total_seconds())),
}
Return a countdown, not a flag. A client that receives retry_in_seconds can render an accurate timer; a client that receives can_resend: false has to invent one, and it will invent a wrong one.
Enforce on the server regardless of what the client does. The countdown in the browser is advisory. Anyone can call your endpoint directly, and an OTP endpoint that trusts a client-side timer is an endpoint that can be driven to bill you at whatever rate the attacker chooses.
The arithmetic of an impatient user
This is the part that turns a UX decision into a budget line, and it is worth doing explicitly because the numbers are larger than people expect.
Take a login flow with one hundred thousand OTP sends per month. Suppose the resend rate is R, meaning the fraction of authentications where the user presses resend at least once. Total billed sends are 100,000 x (1 + R).
| Resend rate | Billed sends | Extra sends per month |
|---|---|---|
| 2 percent | 102,000 | 2,000 |
| 8 percent | 108,000 | 8,000 |
| 15 percent | 115,000 | 15,000 |
| 25 percent | 125,000 | 25,000 |
At the published OTP slab rates, the gap between a well-tuned flow at two percent and a poorly tuned one at twenty five percent is twenty three thousand extra messages a month, on the same number of real authentications. The OTP pricing page carries the current per-unit rates; multiply and the number is not trivial, and it recurs monthly.
What actually drives R down, in rough order of effect:
- Delivery latency at the 95th percentile. Not the mean. Users resend based on the worst experience they personally have, not the average across your user base.
- An accurate countdown. A visible timer that matches reality suppresses the reflexive second press.
- A code length users can transcribe. Six numeric digits transcribe cleanly. Alphanumeric codes generate retyping errors, which present as failed verifications and then as resends.
- Autofill compatibility. A message body that platform autofill can parse removes transcription entirely for a large share of users. Keep the code adjacent to recognisable text and avoid decorating it.
- Not sending a second code the user did not ask for. Some implementations helpfully resend on a timer. This doubles the bill and confuses the user about which code is live.
The metric to instrument is not the resend count. It is cost per successful authentication, computed as total OTP spend divided by successful verifications. That number captures resends, failed deliveries and abandoned flows in one figure, and it is the only OTP cost metric that will not mislead you.
The undocumented type field, and why it matters most
The generate response contains "type": "new". That is the only value the documentation shows, and the documentation does not enumerate the others or explain the field at all.
This is the single most important gap for anyone designing a resend flow, and it is worth stating why rather than skipping it. If type distinguishes a freshly minted code from a re-emission of an existing unexpired one, then it answers a question your flow depends on: when a user presses resend inside the validity window, does the second message carry the same code as the first, or a different one? Those two behaviours require different verification logic. If the code is the same, the first message remains valid and a user who eventually finds it can still use it. If the code is different, the first message is now a trap, and a user who reads the older SMS will fail verification and probably resend again.
I am not going to guess which it is. What I will give you is the procedure to determine it on your own account in about five minutes, and the instruction to record the answer where your team will find it.
- Generate an OTP to a test number. Record the code received and the full response body including
type. - Well inside
codeExpirybut afterretryAfter, generate again to the same number with identical parameters. - Record the second code and the second
typevalue. - Attempt verification with the first code.
- Write down all four observations: whether the codes matched, both
typevalues, whether the first code still verified, and whether the balance moved twice.
Whatever you find, encode it as a test against a recorded fixture so that a change in platform behaviour breaks a build rather than breaking a login flow. And store the type value verbatim on every attempt row, as the schema above does, so that if the behaviour ever changes you have the historical evidence.
Shed, do not buffer
The general rule for messaging under load is to buffer and drain. For OTP, invert it.
def submit_otp(recipient, purpose, limiter, clock, store, client):
# Try to acquire capacity immediately. Do not wait, do not enqueue.
if not limiter.try_acquire(timeout=0):
# Honest, immediate, cheap. The user retries in two seconds
# and we are billed once, not twice.
raise OtpCapacityUnavailable(
"verification is temporarily busy, please try again")
return start_otp(recipient, purpose, clock, store, client)
timeout=0 is the whole point. A rate limiter that blocks a caller for two seconds waiting for a permit has silently become a queue, and a queue is the thing you are trying to avoid.
Two corollaries.
Reserve capacity for OTP rather than sharing it. If OTP and campaign traffic draw on one limiter, a large campaign will starve authentication. Give OTP its own reserved allowance that campaign traffic cannot consume, even when OTP is idle. Wasting a little headroom is much cheaper than failing logins during a marketing push.
Never put an OTP on a retry queue. If a submission fails ambiguously, the attempt is over. Tell the user, let them press resend, and let that be a fresh attempt with a fresh row. This is not laziness; it is forced by the missing transaction ID. With no identifier, an ambiguous OTP submission can never be definitively reconciled, so an automatic retry is a coin flip on whether you are about to send and bill a second message.
The broader treatment of shedding, buffering and priority lanes across all message classes is a separate topic; for the provider-side view of throttling behaviour see API Rate Limiting and Throttling.
The DLT layer for OTP templates
OTP messages to Indian numbers need a registered DLT template like every other message. Registration mechanics are covered in the DLT registration guide, and the reasons templates get rejected in DLT Template Rejected: Every Reason and How to Fix It. Three things are OTP-specific.
Category selection. Registering an OTP template under the wrong category is a common and expensive error, because it changes the route your messages take and therefore both their delivery characteristics and their cost. The knowledge base addresses this directly in under which category should I register my OTP templates. Get this right before you register, because the fix is a re-registration and an approval wait, not a config change.
The $otp$ placeholder is not a DLT variable. DLT variables are {#var#}. The $otp$ token is a platform-level substitution that happens before the message is handed onward. Your registered template will carry {#var#} where the code goes; your API request carries $otp$ in the same position. These are two different substitution layers and confusing them produces a template mismatch.
Keep the body in GSM-7. An OTP body should never be Unicode. It roughly halves your per-segment character budget and doubles the cost for no benefit. The silent way this happens is a smart quote, an en dash, an ellipsis or a rupee sign arriving from a content management system or a document, replacing the ASCII character somebody originally typed. Normalise the body on the way in and assert on it in a unit test. Character-level detail on this trap is in the template rejection article linked above.
Validating the OTP body before you ship it
There is a better tool for this than most teams realise. The Preview Message API at https://unify.smsgateway.center/SMSApi/preview takes a message body, a sender ID and optionally a dltEntityId, and returns the detected message type, the character length, the number of parts, the estimated cost, and, when an entity ID is supplied, the DLT template ID that the body matches.
That last one is a reverse lookup, and it is genuinely useful. Rather than asserting that your body matches template X, you ask the platform which template it matches and compare.
curl -s -X POST "https://unify.smsgateway.center/SMSApi/preview" \
-H "Content-Type: application/x-www-form-urlencoded" \
--data-urlencode "userid=YOUR_USERNAME" \
--data-urlencode "password=YOUR_PASSWORD" \
--data-urlencode "msg=Your verification code is 123456. Valid for 5 minutes. YourBrand" \
--data-urlencode "senderid=YOURID" \
--data-urlencode "dltEntityId=YOUR_ENTITY_ID" \
--data-urlencode "output=json"
The documented response:
{
"status": "success",
"statusCode": "200",
"reason": "Preview generated successfully",
"msgType": "text",
"length": 28,
"parts": 1,
"dltTemplateId": "1234567890123456",
"dltEntityId": "YOUR_ENTITY_ID",
"amount": 0.05,
"message": "Hello, this is a test message"
}
Wire this into CI as a gate over your OTP bodies, asserting three things: msgType is text and not unicode, parts is 1, and dltTemplateId equals the template you expect. A copy edit that adds a character and pushes the message to two parts then fails a build instead of quietly doubling your OTP costs.
Two implementation notes. The amount field here is a float representing estimated cost, whereas the older SMS length and cost endpoint returns a credit integer. They are different fields answering related questions; parse whichever you use into a decimal type rather than a binary float before it touches any cost report. And run this at template save time or in CI, not on the send path, because it is a round trip you do not want between a user pressing a button and a code being submitted.
Route characteristics: why OTP is not promotional
The route matters more for OTP than for anything else you send, and the difference is published on the pricing pages.
| Characteristic | Promotional route | OTP and transactional routes |
|---|---|---|
| Delivery window | 10am to 9pm | 24×7 |
| DND numbers | Filtered | Delivered |
| Sender ID | Numeric | Static alphanumeric header |
A user registering at eleven at night on a number registered with DND preferences is an entirely normal event, and on a promotional route that message is not going to arrive. Both restrictions are structural, not probabilistic, so no amount of retrying will fix a message that went out on the wrong route. This is the single largest cause of “OTP works for most users but not for some” reports.
Verify which route your OTP sender ID and template are actually bound to before you spend time debugging application code. For background on the regulatory framework driving these distinctions, TRAI publishes the commercial communications regulations at trai.gov.in.
Server-side verification rules the API does not enforce
The verify endpoint documents four parameters and two outcomes. It does not document an attempt limit, a lockout, single-use consumption, or a per-recipient ceiling on verification attempts. Do not assume any of them exist. Implement them yourself, on your side, where you can prove they are running.
MAX_VERIFY_ATTEMPTS = 5
def verify_otp(recipient, purpose, submitted_code, clock, store, client):
attempt = store.latest_active(recipient, purpose)
if attempt is None:
raise OtpNotFound()
# 1. Single use. A consumed code is dead even if still within expiry.
if attempt.consumed:
raise OtpAlreadyUsed()
# 2. Local expiry check BEFORE spending a network call.
if clock.now() >= attempt.expires_at:
raise OtpExpired()
# 3. Attempt ceiling. Six numeric digits is a million combinations;
# unlimited attempts makes that a tractable online guess.
if attempt.verify_count >= MAX_VERIFY_ATTEMPTS:
store.invalidate(attempt)
raise OtpTooManyAttempts()
store.increment_verify_count(attempt)
# 4. Trim. The official sample literally shows otp=438592%0A,
# a trailing newline. Users paste whitespace constantly.
code = (submitted_code or "").strip()
if not code:
raise OtpInvalid()
payload = client.verify(recipient=recipient, otp=code)
if payload.get("statusCode") == "301":
store.mark_consumed(attempt, at=clock.now())
return True
return False
Five things in that function are your responsibility and not the platform’s.
Single-use consumption. Mark the attempt consumed on success. A code that verified once must not verify again, or a leaked code stays live for the remainder of its window.
Local expiry check first. You already know expiryTime. Checking locally avoids a network round trip on a request you can already answer, which matters because verification is on the user’s critical path.
An attempt ceiling. Six numeric digits is one million combinations. With no ceiling, and with a five minute window, that is an online guessing problem rather than an impossible one. Five attempts is a reasonable default. Invalidate the code when the ceiling is hit rather than merely rejecting the attempt.
Trimming input. The official verify sample in the documentation shows otp=438592%0A, which is a URL-encoded trailing newline. Whether that is an artefact of how the sample was generated or a real behaviour, it is a good reminder: users paste codes with whitespace, autofill sometimes appends characters, and an untrimmed comparison fails for a code that is actually correct. Strip before you send.
Per-recipient rate limiting on verify, not just on generate. Generate is rate limited by retryAfter. Verification is not, as far as the documentation states. An unlimited verify endpoint in front of a six digit code is the attack surface here.
Also treat the pairing as authoritative on your side. Verification succeeding for a mobile is not by itself an authorisation decision; bind the verified attempt to the session, the purpose and the user account you issued it for, so that a code issued for a password reset cannot be replayed against a payment confirmation.
The sticky session cookie in the official sample
Here is a detail from the documentation that deserves a paragraph rather than a footnote. The official verify sample sends an HTTP header:
Cookie: SERVERID=webC1
The documentation does not explain it. What it looks like is a load balancer affinity cookie, the kind that pins a client to a particular backend. It appears on the verify sample and not on the generate sample.
I am not going to assert that OTP state is server-local, or that verification will fail if generate and verify land on different backends. That is not documented and I have not tested it. What I will say is that the cheapest possible defensive posture costs you nothing: use one HTTP client with a shared cookie jar for the generate and verify calls belonging to a single OTP flow, and let any cookie the platform sets be echoed back. In most HTTP clients that is a one-line configuration, and it makes the question moot.
import httpx
# One client for the process, cookies preserved across calls.
otp_http = httpx.Client(
base_url="https://unify.smsgateway.center",
timeout=httpx.Timeout(connect=3.0, read=10.0, write=10.0, pool=3.0),
# Cookies set by the platform are stored and resent automatically.
cookies=httpx.Cookies(),
)
If you are running verification from a different service, a different region, or a serverless function that gets a fresh client per invocation, you have no cookie continuity at all. That is worth knowing about your own architecture even if the cookie turns out to be irrelevant. Ask support to clarify whether affinity is required; it is a five minute question with a potentially significant answer.
Stop putting OTP bodies in query strings
The official samples for both generate and verify use GET with everything in the query string, including userid, password, the full message body and, on verify, the code itself.
Do not copy that pattern into production. Query strings are logged. They land in web server access logs, in proxy logs, in APM traces, in browser history if a URL ever escapes to a client, and in error reporting tools that capture request URLs. Putting a password and a one-time code into a query string means depositing both into every logging system in the path.
Both operations accept POST. Use POST with an application/x-www-form-urlencoded body, and use the apiKey header rather than a password parameter, so that the credential is in a header your logging can redact by name.
resp = otp_http.post(
"/SMSApi/otp",
headers={"apiKey": settings.api_key},
data={ # form-encoded body, NOT json=
"userid": settings.userid,
"sendMethod": "verify",
"mobile": recipient,
"otp": code,
"output": "json",
},
)
Note data= and not json=. Posting a JSON body to this API does not fail as a malformed request; the parameters simply are not parsed, and the response reads as an authentication failure. That misdirection has cost people entire afternoons and at least one unnecessary credential rotation.
While you are here, redact deliberately. The OTP code should never reach your logs, the message body should never reach your logs in full, and recipient numbers should be salted and hashed rather than truncated, since truncation is reversible against a customer database. Implement redaction as a serialisation filter rather than as discipline at each call site, because discipline does not survive the next developer adding a debug line.
Multi-channel fallback for OTP
If SMS does not land, a second channel can. The platform supports ordered multi-channel fallback, and the knowledge base covers what it is, how to enable it, and specifically how to map an OTP from SMS to WhatsApp in a fallback chain.
The behaviour that matters for OTP is the trigger error code configuration, documented in what are trigger error codes: “Trigger error codes let you control when the platform moves to the next channel. By default, any failure on a channel (except the last) can trigger the next step. You can narrow this to specific delivery error codes only.”
For OTP, the default is what you want. Leaving the trigger codes empty means any failure moves to the next channel, which gives maximum coverage. Narrowing to specific codes is an optimisation appropriate for campaign traffic where you would rather not pay for a second channel on a soft failure. On an authentication message, the cost of not arriving exceeds the cost of a second attempt on another channel.
Two cautions specific to OTP fallback.
Fallback adds latency, and latency is the thing you are protecting. A chain that waits for an SMS delivery report to time out before trying WhatsApp may take longer than the user’s patience. Understand what the timeout is before you rely on the chain, and consider whether a fast user-driven resend is a better experience than a slow automatic fallback.
Each channel needs its own approved template. SMS templates are governed by DLT; WhatsApp templates are governed by Meta. They are separate registrations with separate approval processes and separate rejection reasons. A fallback chain whose second hop has an unapproved template is a chain with one hop. If your chain is silently not advancing, the knowledge base has a dedicated entry on why a fallback flow is not trying the next channel.
Monitoring OTP separately from everything else
This is the section that determines whether you find out about an OTP incident from a dashboard or from your support queue.
The core problem: OTP is a small fraction of your volume. If OTP is five percent of messages and OTP delivery drops to zero, your global delivery rate moves by five percentage points, which on most dashboards is inside normal variance. The incident is invisible by construction. OTP must be measured on its own axis.
The practical lever is the sender ID. Give OTP its own header, then use the DLR summary endpoint to pull its numbers in isolation. That endpoint is https://unify.smsgateway.center/SMSApi/reports/smsSummary, documented at Get DLR Summary Report, and it takes fromdate, todate, a groupby of summary, senderid or date, and an optional senderid filter.
curl -s -X POST "https://unify.smsgateway.center/SMSApi/reports/smsSummary" \
-H "Content-Type: application/x-www-form-urlencoded" \
--data-urlencode "userid=YOUR_USERNAME" \
--data-urlencode "password=YOUR_PASSWORD" \
--data-urlencode "groupby=senderid" \
--data-urlencode "senderid=YOUR_OTP_SENDER" \
--data-urlencode "fromdate=2026-08-01" \
--data-urlencode "todate=2026-08-26" \
--data-urlencode "output=json"
The documented response shape:
{
"response": {
"api": "report",
"action": "smsSummary",
"status": "success",
"msg": "success",
"code": "200",
"count": 1,
"report_smsSummaryList": [
{
"report_summary": {
"summary": "Summary",
"total": "9",
"success": "4",
"pending": 0,
"failed": "5",
"notSent": "0",
"others": "0",
"refund": "0"
}
}
]
}
}
Two parsing hazards in that payload, both of which will bite a statically typed client. Every counter is a quoted string except pending, which is an unquoted number. And each list element is a wrapper object whose single key is report_summary, so the array is one level deeper than it looks. Coerce all seven counters through one lenient integer parser rather than trusting the types.
There is also a refund counter, which is worth noticing given that the published billing terms state credits are non-refundable once a message is successfully submitted to the operator. A non-zero value there is not something to ignore; it is something to ask about.
What to actually alert on, in order of value:
- The 95th percentile of submit-to-delivery latency for the OTP sender ID. This is the metric that corresponds to user experience. The mean will look healthy during every OTP incident you will ever have, because most messages are still fast and it is the tail that is failing.
- Pending over submitted for the OTP sender. Compute the delivery ratio with
totalas the denominator, notsuccess + failed. If delivery reports stop arriving, success and failed both freeze and their ratio freezes at a healthy-looking constant, whereas a rising pending share catches it immediately. - Verification success rate. The proportion of generated codes that are ever successfully verified. This is the closest thing to an end-to-end health signal, because it fails when delivery fails, when the code is unreadable, and when your own verification logic breaks.
- Resend rate, and its cost consequence, cost per successful authentication.
- Ambiguous reconciliation count, from the correlation logic above. A rise here usually means the resend cooldown is too short.
Two instrumentation rules carried over from general messaging practice, both of which apply with force here. Never use the recipient number as a metric label; it is unbounded cardinality and will take your monitoring down during a traffic spike. And keep submission metrics and delivery metrics on separate time axes, because they run on different clocks and overlaying them hides exactly the failure mode where submission is healthy and delivery is not.
Testing OTP without billing yourself
Billing fires at submission. The published terms state that credits are non-refundable once a message is successfully submitted to the operator, and that the per-SMS rate is deducted from the wallet while sending. A test suite that really generates OTPs really bills, on every commit, forever.
Push almost everything below the network boundary.
- Pure functions. Millisecond epoch conversion, countdown clamping when
retryAfteris in the past, attempt-ceiling logic, code trimming. Zero cost, run on every commit. - Transport mocks. Mock at the HTTP layer, not at your own client class. Mocking your own client proves your code called your code and catches none of the bugs this integration actually has: a wrong parameter name, a JSON body where form encoding was required, a misparsed epoch. Assert on the recorded request body.
- Expiry and cooldown tests with a fixed clock. Inject the clock and assert behaviour at exactly
expires_atminus one millisecond and exactlyexpires_at. - An ambiguity test. Two attempts to the same recipient inside the correlation window, and an assertion that reconciliation returns
ambiguousrather than silently matching one. - Live smoke tests, limited to a handful of runs before a release, against an environment-driven recipient allowlist so a misconfiguration cannot spray real numbers.
An account-level Sandbox exists, documented on the demo page: “The demo environment is designed for testing. Some features use a Sandbox mode, where delivery is simulated for security.” The page does not enumerate which features are simulated, so whether OTP generation is among them is something to confirm on your own account rather than assume.
The ten-step build order
- Register the OTP template under the correct DLT category, and confirm the sender ID is bound to a 24×7 route that delivers to DND numbers.
- Run your OTP body through
SMSApi/previewand confirmmsgTypeistext,partsis 1, anddltTemplateIdmatches what you registered. - Generate one OTP with cURL, using POST, and read the full response with your own eyes. Confirm
transactionIdis"0"on your account before you design around it. - Run the five-step
typefield experiment described above and record the result in your runbook. - Create the
otp_attempttable with your ownattempt_key. Do not rely on any platform identifier. - Build the generate path with a server-side cooldown enforced from stored
retry_after, returning a countdown to the client. - Build the verify path with local expiry checking, single-use consumption, an attempt ceiling and input trimming.
- Add the shed-not-buffer limiter with
timeout=0and a reserved OTP allowance. - Add reconciliation against the delivery report, with an explicit
ambiguousoutcome that is counted rather than resolved by guessing. - Add monitoring on the OTP sender ID: 95th percentile latency, pending over total, verification success rate, and cost per successful authentication. Alert on the first before you tune anything else.
Step 4 before step 6 is deliberate. The resend flow you build depends on what the platform does when a user resends inside the validity window, and building it before you know is how you end up rewriting it.
Decision matrix
| Situation | Choice | Why |
|---|---|---|
| Over capacity on OTP traffic | Shed immediately | A queued OTP guarantees a user resend and a second charge |
| Over capacity on campaign traffic | Buffer | Late is acceptable there |
| Ambiguous submission outcome | Fail the attempt, tell the user | No transaction ID means it can never be reconciled |
| Choosing code length | 6 numeric | Transcribes cleanly, autofills well, a million combinations |
Choosing codeType | num | Alphanumeric raises transcription errors and therefore resends |
| Setting the resend cooldown | From retryAfter in the response | A constant is either too short or too long, never right |
Setting codeExpiry | 300 seconds default, tune with data | Shorter increases resends, longer widens the attack window |
| Correlating a delivery report | Recipient plus sender ID plus tight window | The only keys available |
| Two candidate DLR rows | Record ambiguous, do not guess | Guessing corrupts the cost and latency record |
| OTP body encoding | Always GSM-7 | Unicode halves the budget and doubles the cost |
| Fallback trigger codes | Leave empty for maximum coverage | Any failure should advance the chain for authentication |
| Monitoring OTP health | Dedicated sender ID plus groupby=senderid | A global rate will not move when OTP breaks |
| Latency alerting | 95th percentile | The mean stays healthy through every OTP incident |
| Validating a body change | SMSApi/preview in CI | Catches encoding drift and part-count increases before deploy |
The reliability checklist
Template and route
- OTP template registered under the correct DLT category
- Sender ID bound to a 24×7 route that delivers to DND numbers
$otp$placeholder survives the templating layer intact{#var#}and$otp$not confused with each other- Body verified GSM-7, no smart quotes, en dashes, ellipses or rupee signs
SMSApi/previewassertsmsgTypetext,parts1, expecteddltTemplateId- Preview assertion wired into CI as a gate
Request hygiene
- POST, never GET, for both generate and verify
- Form-encoded body, not JSON
apiKeyheader rather than a password form parameteroutput=jsonset explicitly, since the system default is plain- One recipient per request, never batched
- Connect and read timeouts both set explicitly
- Shared cookie jar across generate and verify in one flow
State and correlation
- Own
attempt_keygenerated and stored, no reliance ontransactionId typefield stored verbatim on every attempt- Three timestamps converted from millisecond epochs at the boundary
- Clock injected everywhere expiry or countdown is computed
- Negative countdowns clamped to zero
- Reconciliation matches on recipient, sender ID and time window
ambiguousreconciliation outcome recorded, never guessednot_foundtreated as an inference, retried before concluding
Resend and capacity
- Cooldown derived from
retryAfter, not a constant - Countdown returned to the client, not a boolean
- Cooldown enforced server side regardless of client behaviour
- Limiter acquires with zero timeout, so it sheds rather than queues
- OTP capacity reserved, not shared with campaign traffic
- No automatic retry of an ambiguous OTP submission
- No timer-based automatic resend the user did not request
Verification
- Single-use consumption on success
- Local expiry check before the network call
- Attempt ceiling enforced, code invalidated when reached
- Submitted code trimmed of whitespace
- Per-recipient rate limit on verify, not only on generate
- Verified attempt bound to session, purpose and account
Security and privacy
- No credentials, message bodies or codes in query strings
- OTP code never written to logs
- Recipient numbers salted and hashed in logs, not truncated
- Redaction implemented as a serialisation filter
Monitoring
- OTP on its own sender ID
- 95th percentile submit-to-delivery latency alerted
- Delivery ratio denominator is total, not success plus failed
- Pending share alerted as the dead-report-pipeline signal
- Verification success rate tracked end to end
- Cost per successful authentication tracked
- Ambiguous reconciliation count tracked
- No recipient numbers used as metric labels
- Submission and delivery metrics on separate time axes
Ten mistakes
- Assuming
transactionIdis usable. It is"0". Every correlation design built on it fails on the first day, usually silently. - Hardcoding the resend cooldown. Either the user gets an error for pressing an enabled button, or they abandon while waiting for a message that is never arriving.
- Buffering OTP under load. The user resends before the queue drains. Two messages, two charges, one confused user.
- Retrying an ambiguous submission. With no transaction ID it can never be reconciled, so the retry is a coin flip on double billing.
- Batching OTP sends. One
transactionIdfor the batch, per-user state shared across a request, and correlation made impossible. - Treating
retryAfteras a duration. It is a millisecond epoch. Adding it tonow()produces a countdown roughly fifty four thousand years long. - No attempt ceiling on verify. Six digits and unlimited attempts is a tractable online guessing problem, not a theoretical one.
- Sending on a promotional route. Delivery stops at 9pm and DND numbers are filtered, so it works for most users and fails for a consistent, confusing subset.
- Monitoring OTP inside the global delivery rate. OTP is a small share of volume, so a total OTP outage moves the global number by less than normal variance.
- Copying the GET samples into production. Credentials, message bodies and codes deposited into every access log and trace in the request path.
What this article deliberately does not claim
Each of these is a question a reader will reasonably have, where the honest answer is that the platform does not publish it. Guessing would make this article worse.
- What values
typecan take besides"new", and whether a resend inside the validity window returns the same code or a new one. The five-step procedure above determines it on your account. This is the largest open question for resend design. - Whether the sixty second gap between
createTimeandretryAfterin the documented sample is fixed, configurable or account-dependent. ReadretryAfterfrom every response rather than assuming. - Whether the
SERVERIDcookie in the official verify sample is functionally required. It is undocumented. Preserving cookies costs nothing, so preserve them. - Whether the verify endpoint enforces any attempt limit, lockout or single-use behaviour of its own. None is documented. Implement all three yourself.
- Whether a failed OTP generate consumes credit. Not stated. Determine it by reading your balance before and after a deliberately failing call.
- No delivery percentage, delivery time or SLO figure is quoted here. Delivery depends on route, operator, header, template and time of day, and a single number would mislead.
- The delivery report retention window is not documented, which bounds how long an unreconciled OTP attempt stays resolvable.
- Rate limits on the reporting and summary endpoints are not documented. Poll conservatively.
- What the
refundcounter in the DLR summary represents, given that published terms describe credits as non-refundable once submitted. Worth asking support.
FAQ
Why is transactionId always “0” on OTP responses?
The documented sample responses for both generate and verify return transactionId as the string "0". The practical consequence is that there is no per-send identifier to correlate with a delivery report, so you must generate and store your own attempt key and reconstruct correlation from recipient, sender ID and a tight time window.
How do I match an OTP send to its delivery report?
Filter the delivery report by recipient, then narrow by sender ID and by a time window around your submission, typically under two minutes. If exactly one row matches, take it. If two match, that is almost certainly a resend, so record the outcome as ambiguous rather than guessing.
Are createTime, expiryTime and retryAfter durations?
No. All three are millisecond epochs delivered as quoted strings. Convert them to instants at the parsing boundary. Treating retryAfter as a number of seconds produces a countdown tens of thousands of years long.
How long should the resend cooldown be?
Take it from retryAfter in the generate response and enforce it server side. In the documented sample it sits sixty seconds after createTime, but the documentation does not state whether that interval is fixed, so read it from each response rather than hardcoding it.
Should I retry an OTP that timed out ambiguously?
No. There is no transaction ID to reconcile it with, so you can never establish whether it was submitted. Fail the attempt, tell the user, and let the resend button create a fresh attempt with a fresh row.
Should OTP traffic be queued when I am over capacity?
No, shed it. A queued OTP arrives after the user has already pressed resend, which means you pay twice and deliver a confusing experience. An immediate error lets the user retry a request that will actually be fast.
Can I send OTPs to multiple recipients in one request?
You should not. An OTP is per-user state, and the batch send path returns a single top-level transaction identifier for the whole request, which compounds the correlation problem. One recipient per request.
What code length and type should I use?
Six numeric digits. Set codeType=num. Alphanumeric codes raise transcription errors, and every transcription error becomes a failed verification and then a resend, so the apparent security gain is paid for in delivery cost and abandoned logins.
Why do OTPs reach most users but not some?
The most common structural cause is the route. Promotional routes deliver only between 10am and 9pm and filter DND numbers, while OTP and transactional routes are 24×7 and deliver to DND numbers. Verify which route your sender ID and template are bound to before debugging application code.
Does the verify endpoint limit how many guesses I get?
No attempt limit, lockout or single-use behaviour is documented. Assume none exists and implement all of them yourself. Six numeric digits with unlimited attempts is a realistic online guessing target.
How do I check my OTP message will not become a two-part Unicode message?
Post it to SMSApi/preview with your sender ID and DLT entity ID. The response returns msgType, length, parts, amount and the matched dltTemplateId. Assert on all of those in CI so a copy edit fails a build instead of quietly doubling your cost.
How should I monitor OTP delivery separately?
Give OTP its own sender ID and query SMSApi/reports/smsSummary with groupby=senderid. Alert on the 95th percentile of submit-to-delivery latency and on the pending share of total. A global delivery rate will not move meaningfully when OTP fails, because OTP is a small fraction of most senders’ volume.
Should I use output or format in the request?
Either. The Preview Message documentation describes output as an alias for format. The OTP samples happen to use format while the parameter tables use output. Pick output for consistency with the rest of the API.
Can I test OTP flows without being charged?
Mostly. Keep pure logic and transport-mocked tests below the network boundary, since billing fires at submission. A Sandbox mode exists and is described on the demo page, but the documentation does not enumerate which features it simulates, so confirm whether OTP generation is included on your own account before relying on it.
Building or fixing an OTP flow? Start with the OTP SMS service overview for route and compliance details, check current rates on the OTP pricing page, and read the generate and verify endpoint references before you write code. If you need the type field behaviour or the session affinity question answered definitively for your account, contact support.