
Table of Contents
- The Short Answer
- TL;DR
- What a Missed Call Actually Tells You
- Two Jobs, One Number Pool: Verification and Lead Capture
- The Verification Flow, Step by Step
- The Callback Fires Whether or Not They Dial
- Binding a Call to the Right Session
- The Verification Ledger Schema
- A Callback Receiver That Survives Retries
- Expiry, Retry Windows and Whose Clock Counts
- Normalising Phone Numbers at One Boundary
- What a Missed Call Proves, and What It Doesn’t
- Falling Back to SMS OTP
- Lead Capture: From Miss Call Logs to a Callback Queue
- One Number Per Campaign, and Reading the Summary
- Missed Calls as Opt-In Evidence
- Ten Mistakes That Break Missed Call Flows
- Decision Matrix
- Launch Checklist
- Unspecified Behaviour and How to Code Around It
- FAQs
The Short Answer
A missed call carries exactly one fact: a phone presenting a particular number rang a particular number of yours at a particular time. Missed call verification works by asking the platform to watch for that call, then forwarding the result to a callback URL you supply. Lead capture works by logging every call to a number and letting you search, summarise and export those logs.
Getting it right comes down to five decisions. Put an unguessable, single-use session token in the callback URL itself, so every callback finds its session whatever the payload looks like. Store the raw callback before you decide anything, and answer 200. Verify only when the session is open, unexpired, the caller matches the number you expected and the status is one you’ve actually seen on a successful call. Treat everything else as “not verified yet” rather than “failed”. And when the session runs out, fall back to an SMS OTP through SMSApi/otp instead of asking the user to keep ringing.
For lead capture, load the Miss Call Logs export into your own table, collapse repeat calls from one person into one lead, and use one missed call number per campaign so attribution is a GROUP BY, not a guess.
TL;DR
- The signal is tiny. Caller number, dialled number, time, plus operator and circle in the logs. No code, no content, nothing typed.
- The callback fires either way. The product page says the response “will be forwarded to this URL whether customer dialed the missed call number or not.” Your receiver has to handle a negative outcome as a normal event.
- Bind by token, not by payload. Generate a random token per session and put it in the callback URL as a query parameter. You don’t need to know the payload’s field names to find the session.
- Fail closed. Verified means: open session, inside expiry, caller equals expected number, status in an allowlist you filled from a real successful callback. Anything else leaves the session open until your clock expires it.
- One open session per number and purpose. A new attempt supersedes the old one. A late callback for a superseded token is logged and ignored.
- Normalise numbers once.
91plus ten digits for mobiles, ten-digit national form for your missed call numbers, at a single boundary function. - Fallback is SMS OTP.
SMSApi/otpwithsendMethod=generatereturnsstatusCode“300”;sendMethod=verifyreturns “301”. The sample generate response shows a 300-second expiry and aretryAfter60 seconds after creation. - Leads come from exports. Miss Call Logs and Miss Call Summary are dashboard screens with Export. Load the file, dedupe retries inside a window, reconcile against the Summary.
- A missed call proves less than people think. It shows a call arrived presenting that number. It doesn’t prove who’s holding the phone, and SIM swap is a known weakness the product page itself names.
What a Missed Call Actually Tells You
A missed call is a call that’s cut before anyone answers. The caller pays nothing, nobody speaks, and the only thing that crosses the network is call signalling. Wikipedia files it under one-bit messaging, which is about right: the meaning is whatever you agreed in advance. “Ring this number for a callback.” “Ring this number to confirm it’s you.” “Ring the first number for yes and the second for no.”
On SMSGatewayCenter, the missed call service overview describes a number customers “ring and hang up”, where every attempt is logged. The Miss Call Logs guide lists what each logged row holds:
| Column | Meaning |
|---|---|
| Miss Call No | The number the customer dialled |
| Mobile Number | The caller’s number |
| Operator | The caller’s telecom operator |
| Circle | The caller’s telecom circle, which indicates the region |
| Timestamp | When the call was received |
That’s the whole signal. Compare it with an inbound SMS, which at least carries text you can parse for a keyword (see two-way SMS keyword routing for that design). A missed call has no content at all, so every bit of meaning has to come from context you hold: which number was dialled, which session was waiting, and when.
Two consequences follow, and the rest of this guide is mostly about them.
First, the dialled number is your only “message field”. If two campaigns share a number, you can’t tell them apart afterwards. If two verification purposes share a number, a call can’t say which one it was meant for.
Second, the caller number is the only identity you get, and it’s whatever number the call presented. Treat it as a strong hint about which phone rang, not as proof of who’s holding it. More on that in What a Missed Call Proves.
Two Jobs, One Number Pool: Verification and Lead Capture
The same mechanism does two quite different jobs, and they want different engineering.
Verification is synchronous and per user. Someone is sitting on your signup screen right now, you’ve shown them a number to ring, and you need a yes or no within a few minutes. The Missed Call OTP page describes this as an API flow: you start it with a callback URL, show the user the number, and the result is forwarded to your callback. It needs a dedicated missed call number.
Lead capture is asynchronous and per campaign. You print a number on a flyer, run it in an SMS, or put it on a packaging insert, and people ring it over days or weeks. Nobody’s waiting on a screen. You want a list of callers to phone back, deduplicated, with a sense of which campaign brought them in. That’s what Miss Call Logs and the Miss Call Summary are for: search, totals per number per period, and Export.
| Verification | Lead capture | |
|---|---|---|
| Who’s waiting | A user on a screen, right now | Nobody; you follow up later |
| How results reach you | Forwarded to your callback URL | Logs and Summary in the dashboard, exported |
| Time horizon | Minutes | Days to weeks |
| Unit of work | One session per user attempt | One lead per caller per campaign |
| Failure that hurts | Verifying the wrong person | Double-counting one keen caller as five leads |
| What to build | Session table, callback receiver, expiry, fallback | Import job, dedupe, attribution, callback queue |
Keep the numbers apart too. A number used for verification shouldn’t also be printed on a flyer, because flyer callers will hit your callback with calls no session is waiting for, and a verification user who’s also a campaign lead will turn up in both places. Missed call numbers are allocated to your account through support rather than self-service, so plan the pool before you ask: one or more numbers for verification, one per live campaign for leads.
The Verification Flow, Step by Step
The Missed Call OTP page lays out the platform’s side in seven steps. Here they are, with what your system does at each one.
- Start the session with the platform. The page says: “Hit our Missed Call OTP API with details such as callback and other parameters.” Before you make that call, create your own session row and token (next sections), and build the callback URL with the token in it.
- On a success response, show the number. “Upon Success response, flash the missed call number to be dialed to your user.” Show it as a tappable
tel:link on mobile, and show the expiry as a countdown from your own clock. - The user dials. The call disconnects automatically and costs the user nothing.
- The operator sends the platform the call details. Nothing for you to do here.
- The platform forwards the detail to your callback URL. Your receiver stores the raw request, then decides.
- On a final success, the user is authenticated. “Upon capturing the final response with Success, your user is authenticated.” Your receiver flips the session to verified; the signup screen, polling your own backend, moves on.
- Carry on. “Take your authenticated user for further transactions.” Mark the number as verified on the user record, with a pointer to the session and the callback event that proved it.
There’s one sentence under those steps that changes how you build step 5: “Callback parameter is mandatory, the response will be forwarded to this URL whether customer dialed the missed call number or not.” So your callback receives negatives as well as positives.
The Callback Fires Whether or Not They Dial
If a callback arrives for every session, positive or negative, then “a callback arrived” means nothing on its own. Only the content of the callback does. That sounds obvious, but a lot of first implementations treat any request to the callback URL as success. Don’t.
Here’s the decision your receiver makes for each callback, in order:
- Is there a token, and does it match a session? No token, or an unknown one, means log it and stop.
- Is the session still open? Verified, expired and superseded sessions don’t change state again. Log and stop.
- Is it inside the expiry you set? If not, the session expires now, whatever the callback says.
- Does the status say success? Only values you’ve seen on a real successful call count. Anything else, including a value you’ve never seen, is “not success”.
- Does the caller match? The normalised caller number must equal the normalised number the session was opened for.
Only when all five pass does the session become verified.
Notice what a negative callback does: nothing. The session stays open until your clock expires it. That’s deliberate. If a “not dialled” callback can arrive early (say the platform times out before your expiry), killing the session on it would lock out a user who was about to ring. If it arrives at the end, your expiry sweep closes the session a few seconds later anyway. Leaving it open is correct in both cases, so you don’t need to know which one you’ll get.
The same logic covers retries. If the platform sends the same callback twice, the second copy is a duplicate by content hash and changes nothing. If it sends a negative and then a positive (a user who rang on the second try, say), the positive still verifies because the session was never closed.
Binding a Call to the Right Session
When the callback arrives, which session does it belong to? There are two ways to answer that, and only one of them is safe without knowing the payload.
Matching by payload fields means reading the caller number (and maybe a reference) out of the callback body, then looking up the open session for that number. It works, but it depends on field names you’d have to learn from a real callback, and it gets ambiguous when one person has two open sessions.
Matching by token in the URL means generating a random token when you open the session, and building the callback URL as https://your-host/mc/cb?s=<token>. Whatever the platform sends, it sends it to that URL, so the token comes back with it. The lookup is one primary-key read.
Use the token, and still check the caller. The token tells you which session the callback is for. The caller number tells you the right phone rang. You want both.
A few rules make the token safe to put in a URL:
- Make it unguessable. At least 128 bits from a cryptographic source. Python’s
secrets.token_urlsafe(24)gives 192 bits. - Make it single-use. Once a session is verified, expired or superseded, its token can’t change anything again.
- Make it meaningless. No user ID, no phone number, nothing that leaks if the URL ends up in a log. URLs end up in logs.
- Serve the callback over HTTPS. The token is a bearer secret for the length of the session.
Then the one-session rule. A user who taps “send again” or opens signup in two tabs creates a second session for the same number and purpose. Supersede the older one at the moment you create the newer one, in the same transaction. Now there’s exactly one open session per (msisdn, purpose), a callback for the old token is ignored, and you never have to guess which of two sessions a call was meant for.
The Verification Ledger Schema
Two tables carry the whole verification side: one row per session, one row per callback received. They’re append-friendly and small, and they answer the question you’ll eventually be asked (“how do we know this number was verified?”) with a join.
CREATE TABLE IF NOT EXISTS mc_session (
token TEXT PRIMARY KEY,
msisdn TEXT NOT NULL,
purpose TEXT NOT NULL,
dialled TEXT NOT NULL,
opened_at_ms BIGINT NOT NULL,
expires_at_ms BIGINT NOT NULL,
state TEXT NOT NULL DEFAULT 'open',
resolved_at_ms BIGINT,
resolved_by INTEGER
);
CREATE INDEX IF NOT EXISTS mc_session_open
ON mc_session (msisdn, purpose, state);
CREATE TABLE IF NOT EXISTS mc_callback_event (
id INTEGER PRIMARY KEY AUTOINCREMENT,
token TEXT,
received_at_ms BIGINT NOT NULL,
body_sha256 TEXT NOT NULL,
content_type TEXT,
raw_body TEXT NOT NULL,
query_string TEXT,
parsed_caller TEXT,
parsed_status TEXT,
decision TEXT,
UNIQUE (token, body_sha256)
);
mc_session.state takes five values: open, verified, expired, superseded, and nothing else. There’s no failed, because a negative callback doesn’t close a session (see the previous section). Expiry does.
mc_callback_event stores the raw body and the query string exactly as received, plus what you parsed out of them and the decision you made. If you ever change the parsing, you can replay every stored event against the new code and see what would have changed. The UNIQUE (token, body_sha256) constraint makes duplicates a no-op at the database level rather than a race in your code.
resolved_by points from a verified session to the callback event that verified it. That’s your audit trail in one column.
Phone numbers are TEXT. Always. A 12-digit number fits in a 64-bit integer, but leading zeros, plus signs and the next format change don’t. The lossless JSON parsing guide explains why identifiers and phone numbers should become text at one boundary.
On your user table, store the outcome, not the mechanism:
ALTER TABLE app_user ADD COLUMN phone_verified_at_ms BIGINT;
ALTER TABLE app_user ADD COLUMN phone_verified_method TEXT; -- 'missed_call' | 'sms_otp'
ALTER TABLE app_user ADD COLUMN phone_verified_ref TEXT; -- mc_session.token or OTP attempt id
If you already keep an outbound message table (the outbound message table design covers one), the SMS OTP fallback writes there as usual, and phone_verified_ref points at whichever attempt succeeded.
A Callback Receiver That Survives Retries
Here’s the full module: session creation, the callback handler, and the expiry sweep. It runs on Python’s standard library with SQLite, so you can drop it behind any web framework. The handler takes the parsed query string, the raw body and the content type, and returns a status and a decision label.
The two constants at the top are deliberately placeholders. Fill them from a real callback you’ve captured; the unspecified behaviour section explains how.
"""Missed call verification: sessions, callback receiver, expiry.
The callback payload fields are configured, not assumed. Capture one real
callback, then fill FIELD_MAP from what you saw.
"""
import hashlib
import json
import re
import secrets
import sqlite3
from urllib.parse import parse_qs, urlencode
# Fill these from a captured callback. Placeholders on purpose.
FIELD_MAP = {
"caller": "CALLER_FIELD",
"status": "STATUS_FIELD",
}
# Only values you have actually observed on a successful call go here.
SUCCESS_VALUES = {"OBSERVED_SUCCESS_VALUE"}
SCHEMA = """
CREATE TABLE IF NOT EXISTS mc_session (
token TEXT PRIMARY KEY,
msisdn TEXT NOT NULL,
purpose TEXT NOT NULL,
dialled TEXT NOT NULL,
opened_at_ms BIGINT NOT NULL,
expires_at_ms BIGINT NOT NULL,
state TEXT NOT NULL DEFAULT 'open',
resolved_at_ms BIGINT,
resolved_by INTEGER
);
CREATE INDEX IF NOT EXISTS mc_session_open
ON mc_session (msisdn, purpose, state);
CREATE TABLE IF NOT EXISTS mc_callback_event (
id INTEGER PRIMARY KEY AUTOINCREMENT,
token TEXT,
received_at_ms BIGINT NOT NULL,
body_sha256 TEXT NOT NULL,
content_type TEXT,
raw_body TEXT NOT NULL,
query_string TEXT,
parsed_caller TEXT,
parsed_status TEXT,
decision TEXT,
UNIQUE (token, body_sha256)
);
"""
def normalise_msisdn(raw):
"""Indian mobile to 91 + 10 digits, or None."""
if raw is None:
return None
digits = re.sub(r"\D", "", str(raw))
if digits.startswith("00"):
digits = digits[2:]
if len(digits) == 11 and digits.startswith("0"):
digits = digits[1:]
if len(digits) == 10 and digits[0] in "6789":
return "91" + digits
if len(digits) == 12 and digits.startswith("91") and digits[2] in "6789":
return digits
return None
def normalise_dialled(raw):
"""Missed call number to its 10-digit national form, or None.
Covers 10-digit mobiles and landlines written with a leading 0 STD code.
"""
digits = re.sub(r"\D", "", str(raw or ""))
if len(digits) == 12 and digits.startswith("91"):
digits = digits[2:]
if len(digits) == 11 and digits.startswith("0"):
digits = digits[1:]
return digits if len(digits) == 10 else None
def connect(path=":memory:"):
db = sqlite3.connect(path)
db.executescript(SCHEMA)
return db
def open_session(db, msisdn, purpose, dialled, now_ms, ttl_ms, base_url):
"""Open one session per (msisdn, purpose); older open ones are superseded."""
number = normalise_msisdn(msisdn)
pool_number = normalise_dialled(dialled)
if number is None or pool_number is None:
raise ValueError("unusable number")
token = secrets.token_urlsafe(24)
with db:
db.execute(
"UPDATE mc_session SET state='superseded', resolved_at_ms=? "
"WHERE msisdn=? AND purpose=? AND state='open'",
(now_ms, number, purpose))
db.execute(
"INSERT INTO mc_session (token, msisdn, purpose, dialled, "
"opened_at_ms, expires_at_ms) VALUES (?, ?, ?, ?, ?, ?)",
(token, number, purpose, pool_number, now_ms, now_ms + ttl_ms))
return token, base_url + "?" + urlencode({"s": token})
def _parse_body(raw_body, content_type):
text = raw_body.strip()
if "json" in (content_type or "") or text.startswith("{"):
try:
data = json.loads(text)
return data if isinstance(data, dict) else {}
except ValueError:
return {}
return {k: v[0] for k, v in parse_qs(text).items()}
def handle_callback(db, query, raw_body, content_type, now_ms,
field_map=None, success_values=None):
"""Store first, decide second, always answer 200."""
field_map = field_map or FIELD_MAP
success_values = success_values or SUCCESS_VALUES
token = (query.get("s") or [None])[0]
digest = hashlib.sha256(raw_body.encode("utf-8")).hexdigest()
fields = _parse_body(raw_body, content_type)
merged = {k: v[0] for k, v in query.items() if k != "s"}
merged.update(fields)
caller_raw = merged.get(field_map["caller"])
status_raw = merged.get(field_map["status"])
caller = normalise_msisdn(caller_raw)
status = None if status_raw is None else str(status_raw)
with db:
cur = db.execute(
"INSERT OR IGNORE INTO mc_callback_event (token, received_at_ms, "
"body_sha256, content_type, raw_body, query_string, "
"parsed_caller, parsed_status) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
(token, now_ms, digest, content_type, raw_body,
urlencode(merged), caller, status))
if cur.rowcount == 0:
return 200, "duplicate"
event_id = cur.lastrowid
decision = _decide(db, token, caller, status, now_ms,
success_values, event_id)
db.execute("UPDATE mc_callback_event SET decision=? WHERE id=?",
(decision, event_id))
return 200, decision
def _decide(db, token, caller, status, now_ms, success_values, event_id):
if not token:
return "no-token"
row = db.execute(
"SELECT msisdn, state, expires_at_ms FROM mc_session WHERE token=?",
(token,)).fetchone()
if row is None:
return "unknown-token"
msisdn, state, expires_at_ms = row
if state != "open":
return "ignored-" + state
if now_ms > expires_at_ms:
db.execute("UPDATE mc_session SET state='expired', resolved_at_ms=? "
"WHERE token=?", (now_ms, token))
return "expired"
if status not in success_values:
return "not-success"
if caller != msisdn:
return "caller-mismatch"
db.execute("UPDATE mc_session SET state='verified', resolved_at_ms=?, "
"resolved_by=? WHERE token=? AND state='open'",
(now_ms, event_id, token))
return "verified"
def expire_sessions(db, now_ms):
with db:
cur = db.execute(
"UPDATE mc_session SET state='expired', resolved_at_ms=? "
"WHERE state='open' AND expires_at_ms < ?", (now_ms, now_ms))
return cur.rowcount
def session_state(db, token):
row = db.execute("SELECT state FROM mc_session WHERE token=?",
(token,)).fetchone()
return row[0] if row else None
Wire it to your framework in three lines. In Flask, for example:
@app.route("/mc/cb", methods=["GET", "POST"])
def missed_call_callback():
status, decision = handle_callback(
db, request.args.to_dict(flat=False),
request.get_data(as_text=True), request.content_type, now_ms())
return "OK", status
Accept both GET and POST on that route, and read fields from both the query string and the body. The handler already merges them. That way it doesn’t matter which method or encoding the forwarded request uses.
What the handler gets right:
- It stores before it decides. The insert and the decision happen in one transaction, and the raw body is saved even when the decision is “unknown-token”.
- It always answers 200. A duplicate, an unknown token, a late callback: all 200. A non-2xx answer only invites a retry of something you’ve already handled. If your database is down, let the framework return a 5xx; that’s the one case where a retry helps you.
- It’s idempotent by content. The same body for the same token is inserted once. A retry can’t verify twice or flip a state back.
- It fails closed. A status value you’ve never seen is “not-success”. A caller that doesn’t normalise is a mismatch. The worst an unknown does is delay a verification until the fallback kicks in.
The test file that ships with it covers the cases that break first implementations: success then duplicate, wrong caller, negative then positive, a callback after expiry, a callback for a superseded token, unknown and missing tokens, and a form-encoded body. All nine pass against the code above.
def test_not_success_keeps_open_then_success():
db, tok, q = setup()
assert call(db, q, body("9876543210", "something_else"), T0 + 1000)[1] \
== "not-success"
assert m.session_state(db, tok) == "open"
assert call(db, q, body("9876543210", "ok_value"), T0 + 2000)[1] == \
"verified"
def test_superseded_token_ignored():
db, tok, q = setup()
tok2, _ = m.open_session(db, "9876543210", "signup", "08012345678",
T0 + 5000, TTL, "https://example.test/mc/cb")
assert m.session_state(db, tok) == "superseded"
assert call(db, q, body("9876543210", "ok_value"), T0 + 6000)[1] == \
"ignored-superseded"
assert m.session_state(db, tok2) == "open"
If you already run a webhook receiver for delivery reports, this is the same shape: durable insert, fast 200, decide from stored rows. The delivery report ingestion guide goes deeper on that pattern, and message idempotency covers the duplicate-handling side.
Expiry, Retry Windows and Whose Clock Counts
Two clocks are involved in every session: the platform’s and yours. Make yours the one that decides.
Your session row has expires_at_ms, set when you open it. The receiver checks it on every callback, and a sweep job (expire_sessions in the module) closes anything that’s run out. The signup screen shows a countdown from the same value. So the user, the receiver and the database all agree on when the session ends, and none of them depend on a platform timer you can’t see.
How long should it be? Long enough for someone to read a number, switch to the dialler and ring; short enough that an abandoned session doesn’t hang around. A few minutes is the usual range. For reference, the SMS OTP endpoint you’ll fall back to defaults codeExpiry to 300 seconds, and its sample response shows exactly that gap:
Field in the SMSApi/otp generate sample | Value | As IST | Offset from createTime |
|---|---|---|---|
createTime | “1700117933163” | 2023-11-16 12:28:53.163 | 0 |
retryAfter | “1700117993163” | 2023-11-16 12:29:53.163 | 60 seconds |
expiryTime | “1700118233163” | 2023-11-16 12:33:53.163 | 300 seconds |
Those values are strings of epoch milliseconds, so parse them as text and convert explicitly. Using the same five-minute window for both methods keeps the user experience consistent when you switch from one to the other.
Retry is a user action, not a loop. If the user says “I rang but nothing happened”, don’t open a second session silently in the background. Offer two buttons: “Ring again” (same session, still counting down) and “Send me a code instead” (supersede the session, start an SMS OTP). The retryAfter value on the OTP response tells you the earliest moment a new OTP request makes sense; grey out the resend button until then.
Two clock traps to avoid:
- Don’t compare your clock against a timestamp in the callback. You don’t know its zone or precision. Compare the callback’s arrival time (
now_mswhen your receiver ran) against your ownexpires_at_ms. - Run your servers on NTP. Your expiry is only as good as the clock that wrote it and the clock that checks it.
Normalising Phone Numbers at One Boundary
The verification decision compares two phone numbers: the one the user typed into your form and the one the call presented. If those are spelled differently, a real user fails verification. Typical spellings of one Indian mobile number:
9876543210
09876543210
+91 98765 43210
919876543210
0091-9876543210
Pick one canonical form and convert at exactly one function, called on the way in from every source: your form, the callback, the log export. For Indian mobiles, 91 followed by ten digits starting 6 to 9 is the form the SMS APIs use for mobile. Your missed call numbers can be mobile or landline (the pricing page offers a 10-digit mobile or a landline of 10 to 11 digits including the STD code), so normalise those to their ten-digit national form instead.
The Python versions are normalise_msisdn and normalise_dialled in the module above. If your signup form validates in the browser or a Node service, use the same rules there:
// Same rules as the Python boundary: one canonical form, or null.
export function normaliseMsisdn(raw) {
if (raw === null || raw === undefined) return null;
let d = String(raw).replace(/\D/g, "");
if (d.startsWith("00")) d = d.slice(2);
if (d.length === 11 && d.startsWith("0")) d = d.slice(1);
if (d.length === 10 && "6789".includes(d[0])) return "91" + d;
if (d.length === 12 && d.startsWith("91") && "6789".includes(d[2])) return d;
return null;
}
export function normaliseDialled(raw) {
let d = String(raw ?? "").replace(/\D/g, "");
if (d.length === 12 && d.startsWith("91")) d = d.slice(2);
if (d.length === 11 && d.startsWith("0")) d = d.slice(1);
return d.length === 10 ? d : null;
}
Return null for anything that doesn’t fit, and treat null as “can’t verify this number by missed call”. Don’t try to be clever with partial numbers. A normaliser that guesses is a normaliser that one day matches the wrong person.
What a Missed Call Proves, and What It Doesn’t
A verified missed call proves that, during the session window, a call reached your number presenting the caller number you expected. In practice that usually means the person on your screen has the phone. Usually.
The Missed Call OTP page is candid about the limits. It lists “SIM Swap Vulnerability: If a user’s SIM is swapped, unauthorized access could occur”, says “Some networks may block missed calls to certain numbers”, and notes that call behaviour varies by country. It also recommends having another method available “if the user is not able to make a missed call due to network issues”. All three are worth designing for.
What a verified missed call doesn’t tell you:
- Who’s holding the phone. Same as SMS OTP. Possession of a number isn’t identity.
- That the number is still theirs next month. Numbers get recycled. Re-verify on sensitive actions rather than trusting a verification from a year ago.
- That the presented number is genuine in every case. Caller number presentation is set by the originating network. Treat a missed call as a possession check on par with an SMS code, not as something stronger.
So use it where SMS OTP would be acceptable: signup, confirming a contact number, a cash-on-delivery order confirmation, a low-risk login. For payments, account recovery or changing the registered number, add a second factor. The OWASP Multifactor Authentication Cheat Sheet is a good reference for where phone-based factors sit.
Also rate-limit session creation per phone number and per client IP. Every session you open asks the user to place a call, and a script that opens thousands of sessions for numbers it doesn’t own is a nuisance to those numbers’ owners even if it can’t verify any of them.
Falling Back to SMS OTP
Some users can’t ring: a dual-SIM phone set to call from the other SIM, a SIM with outgoing calls barred, a network that blocks the call. When the missed call session expires, or the user taps “Send me a code instead”, supersede the session and send an SMS OTP.
The platform’s OTP endpoint does generation and verification for you. Generate with sendMethod=generate, verify with sendMethod=verify, both on https://unify.smsgateway.center/SMSApi/otp. Send your API key in the apikey header alongside userid:
#!/usr/bin/env bash
# SMS OTP fallback when the missed call session expires.
# Usage: otp_fallback.sh generate 919876543210
# otp_fallback.sh verify 919876543210 438592
set -euo pipefail
: "${SGC_USERID:?set SGC_USERID}" "${SGC_APIKEY:?set SGC_APIKEY}"
: "${SGC_SENDERID:?set SGC_SENDERID}" "${SGC_OTP_MSG:?set SGC_OTP_MSG}"
API="https://unify.smsgateway.center/SMSApi/otp"
action="$1"; mobile="$2"
case "$action" in
generate)
curl -sS --fail-with-body -X POST "$API" \
-H "apikey: ${SGC_APIKEY}" \
--data-urlencode "userid=${SGC_USERID}" \
--data-urlencode "mobile=${mobile}" \
--data-urlencode "sendMethod=generate" \
--data-urlencode "msgType=text" \
--data-urlencode "msg=${SGC_OTP_MSG}" \
--data-urlencode "medium=sms" \
--data-urlencode "codeType=num" \
--data-urlencode "codeLength=6" \
--data-urlencode "codeExpiry=300" \
--data-urlencode "senderid=${SGC_SENDERID}" \
--data-urlencode "output=json"
;;
verify)
otp="${3:?otp required}"
curl -sS --fail-with-body -X POST "$API" \
-H "apikey: ${SGC_APIKEY}" \
--data-urlencode "userid=${SGC_USERID}" \
--data-urlencode "mobile=${mobile}" \
--data-urlencode "sendMethod=verify" \
--data-urlencode "otp=${otp}" \
--data-urlencode "output=json"
;;
*) echo "usage: $0 generate|verify mobile [otp]" >&2; exit 2 ;;
esac
SGC_OTP_MSG is your DLT-registered OTP template with the code written as $otp$, for example Your verification code is $otp$. It is valid for 5 minutes. Your Brand. The text has to match your registered template, so check it against the OTP delivery checklist for India before you go live.
The responses from the Generate OTP and Verify OTP pages look like this:
{
"status": "success",
"mobile": "91777xxxxxxx",
"transactionId": "0",
"statusCode": "300",
"type": "new",
"reason": "OTP successfully generated.",
"createTime": "1700117933163",
"expiryTime": "1700118233163",
"retryAfter": "1700117993163"
}
{
"status": "success",
"mobile": "91777xxxxxx",
"transactionId": "0",
"statusCode": "301",
"reason": "OTP has been Verified Successfully."
}
Branch on status only. Treat statusCode as a label for your logs, and never parse reason. The transactionId is “0” in both samples, so don’t use it as a key; key the attempt on your own ID.
If voice is part of your stack, a voice OTP call is a third option for users who can’t receive SMS either; the Voice Call API guide covers sendMethod=pin. For the general shape of chaining channels with one code across steps, see multi-channel fallback design.
Lead Capture: From Miss Call Logs to a Callback Queue
For lead capture, the platform’s output is the Miss Call Logs screen. You can filter by Mobile Number (the caller), Miss Call Number (yours) and a From and To date, and an Export button appears once a search returns rows. The KB suggests feeding the export into your CRM or calling list. Here’s how to do that without double-counting anyone.
The pipeline has four steps:
- Export on a fixed schedule with a date range that overlaps the previous one a little. Overlap is safe because the import is idempotent.
- Load the file into your own table, failing loudly if the header has changed.
- Collapse retries. The KB itself warns: “The same number appearing many times in minutes is usually one person retrying, not several leads.”
- Queue callbacks from the collapsed list, newest first, and record the outcome against the lead.
Here’s the loader and the two queries that matter. It reuses the normalisers from the verification module.
"""Load a Miss Call Logs export, collapse retries, queue callbacks."""
import csv
import io
import sqlite3
from mcverify import normalise_dialled, normalise_msisdn
EXPECTED = ["Miss Call No", "Mobile Number", "Operator", "Circle", "Timestamp"]
RETRY_WINDOW_S = 15 * 60
SCHEMA = """
CREATE TABLE IF NOT EXISTS missed_call (
id INTEGER PRIMARY KEY AUTOINCREMENT,
miss_call_no TEXT NOT NULL,
msisdn TEXT NOT NULL,
operator TEXT,
circle TEXT,
ts_raw TEXT NOT NULL,
ts_epoch_s BIGINT NOT NULL,
import_batch TEXT NOT NULL,
UNIQUE (miss_call_no, msisdn, ts_raw)
);
"""
# One lead per caller per number, where calls less than RETRY_WINDOW_S
# apart are treated as the same person retrying.
LEADS_SQL = """
WITH ordered AS (
SELECT miss_call_no, msisdn, ts_epoch_s,
LAG(ts_epoch_s) OVER (PARTITION BY miss_call_no, msisdn
ORDER BY ts_epoch_s) AS prev_s
FROM missed_call
)
SELECT miss_call_no, msisdn, ts_epoch_s AS first_call_s
FROM ordered
WHERE prev_s IS NULL OR ts_epoch_s - prev_s >= :window
ORDER BY first_call_s
"""
DAILY_SQL = """
SELECT miss_call_no,
date(ts_epoch_s + 19800, 'unixepoch') AS ist_day,
COUNT(*) AS calls,
COUNT(DISTINCT msisdn) AS callers
FROM missed_call
GROUP BY miss_call_no, ist_day
ORDER BY miss_call_no, ist_day
"""
def load_export(db, csv_text, batch, parse_ts):
reader = csv.DictReader(io.StringIO(csv_text))
missing = [c for c in EXPECTED if c not in (reader.fieldnames or [])]
if missing:
raise ValueError("export header changed, missing: %s" % missing)
loaded = skipped = 0
with db:
for row in reader:
number = normalise_msisdn(row["Mobile Number"])
pool = normalise_dialled(row["Miss Call No"])
if number is None or pool is None:
skipped += 1
continue
cur = db.execute(
"INSERT OR IGNORE INTO missed_call (miss_call_no, msisdn, "
"operator, circle, ts_raw, ts_epoch_s, import_batch) "
"VALUES (?, ?, ?, ?, ?, ?, ?)",
(pool, number, row["Operator"], row["Circle"],
row["Timestamp"], parse_ts(row["Timestamp"]), batch))
loaded += cur.rowcount
return loaded, skipped
def leads(db, window_s=RETRY_WINDOW_S):
return db.execute(LEADS_SQL, {"window": window_s}).fetchall()
def daily(db):
return db.execute(DAILY_SQL).fetchall()
The header check. EXPECTED holds the column names the KB lists. If the export ever arrives with different headers, the import stops instead of quietly loading the caller’s number into the operator column.
The natural key. UNIQUE (miss_call_no, msisdn, ts_raw) makes re-importing an overlapping export a no-op. The raw timestamp text is part of the key, so two genuine calls a minute apart stay separate rows.
The retry window. LEADS_SQL uses LAG to compare each call with the previous call from the same caller to the same number, and keeps only calls that start a new burst. With a 15-minute window, three calls at 10:00, 10:02 and 10:20 become two leads: the 10:02 call is a retry of 10:00, but 10:20 is far enough apart to count as a fresh contact. Pick the window that matches how quickly you call people back.
parse_ts is passed in, not built in, because the timestamp’s text format and zone are something you confirm from a real export (see unspecified behaviour). The daily query adds 19,800 seconds (five and a half hours) to bucket by IST calendar day, which matches how your campaigns are scheduled.
Then call people back quickly. The KB puts it plainly: “A missed call is a signal of interest that goes cold fast.” That argues for exporting several times a day on a busy campaign, not once a week.
One Number Per Campaign, and Reading the Summary
A missed call has no content, so the only way to know which campaign a caller came from is the number they rang. Both KB pages make the same recommendation: run a separate number per campaign, advert or mailer, and then “the summary becomes a direct response comparison.”
With one number per campaign, attribution is a join:
CREATE TABLE campaign_number (
miss_call_no TEXT PRIMARY KEY,
campaign_id TEXT NOT NULL,
live_from_s BIGINT NOT NULL,
live_to_s BIGINT
);
SELECT c.campaign_id,
COUNT(*) AS calls,
COUNT(DISTINCT m.msisdn) AS callers
FROM missed_call m
JOIN campaign_number c
ON c.miss_call_no = m.miss_call_no
AND m.ts_epoch_s >= c.live_from_s
AND (c.live_to_s IS NULL OR m.ts_epoch_s < c.live_to_s)
GROUP BY c.campaign_id;
The live_from_s and live_to_s columns matter when you reuse a number for a later campaign. Calls that land after the first campaign ends but before the second starts belong to neither, and that’s the right answer: they’re probably people reading an old flyer.
Miss Call Summary gives you totals per number for a period. Use it as a check on your import, not as a second source of truth. The KB says the two “should agree for the same date range”. So after each import, compare your daily calls count per number with the Summary’s Total for the same days. If they differ, the likely causes are on your side: a skipped row that failed normalisation, an export range that missed the end of the day, or a time zone shift between the Summary’s periods and your buckets. Your skipped count and the raw ts_raw values will tell you which.
The Summary also tells you how long a campaign keeps working. Day-by-day totals that taper off over a week tell you when to schedule a reminder SMS, and a spike with no campaign behind it is something to investigate before you treat those callers as leads.
Missed Calls as Opt-In Evidence
One of the uses the overview KB lists is “Opt-in and registration: collecting consent to be contacted.” A missed call to a number advertised with a clear statement (“Give a missed call to 080 1234 5678 to get offers by SMS”) is an easy way to collect that. It’s also easy to collect badly.
If you’re going to message people because they rang, record it the way you’d record any other consent:
- What they were told. The exact wording and placement of the call to action, versioned. “Flyer v3, back page” is evidence; “they rang us” isn’t.
- Which number they rang. That’s how you link the call to the wording.
- When. The raw timestamp from the log, plus your parsed value.
- What it covers. Purpose and channel. A missed call to a “callback” number is consent to a callback, not to a year of promotional SMS.
The consent capture and evidence guide has a consent_event table built for this, with origin and capture_point columns that a missed call maps onto naturally. And whatever the call collected, your suppression list wins: check it before any follow-up goes out. The opt-out and suppression guide covers that side. Promotional SMS in India also has to respect the customer’s registered preferences under the TRAI TCCCPR framework, whatever your own records say.
Ten Mistakes That Break Missed Call Flows
- Treating any callback as success. The callback arrives either way. Only the content decides.
- Finding the session by caller number alone. Two open sessions for one number make it ambiguous. Put a token in the callback URL.
- Closing the session on a negative callback. If it can arrive early, you lock out a user who was about to ring. Let your expiry close it.
- Answering non-2xx for duplicates or unknown tokens. That only invites retries. Store, decide, answer 200.
- Deciding before storing. If your decision code throws, you’ve lost the only evidence. Insert the raw request first.
- Comparing unnormalised numbers.
09876543210and919876543210are the same phone. One boundary function, every source. - Sharing a number between verification and a campaign. Campaign callers hit your callback with no session waiting, and attribution gets muddied.
- Using the platform’s clock for expiry. You can’t see it. Use
expires_at_msfrom your own row. - Counting every logged call as a lead. Collapse retries inside a window before anyone picks up the phone.
- Calling a missed call stronger than an OTP. It’s a possession check on a number, with the same SIM swap exposure. Use it where an SMS code would be acceptable.
Decision Matrix
| Your situation | Use | Why |
|---|---|---|
| Signup or contact-number check, user on screen, India mobile users | Missed call verification, SMS OTP fallback | Nothing to type; fallback covers users who can’t ring |
| Users who can’t easily switch apps (feature phones, assisted signup at a counter) | Missed call verification | One call from the phone, no code to read back |
| Payments, account recovery, changing the registered number | SMS OTP or missed call plus a second factor | A possession check on a number isn’t enough on its own |
| Users mostly outside India | SMS OTP first | The product page warns call behaviour varies by country; test reachability before relying on it |
| Print, outdoor or packaging campaign wanting responses | Missed call lead capture, one number per campaign | Free for the caller, attribution by dialled number |
| Campaign that needs a keyword or free text reply | Two-way SMS on a long code | A missed call carries no content |
| You need to know which of several options someone chose | One missed call number per option | The dialled number is the only field |
| Verification and campaigns on the same number | Don’t | Ambiguous callbacks and muddied attribution |
Launch Checklist
Before verification goes live:
- Dedicated verification number allocated, separate from any campaign number
- Session table and callback event table created; phone numbers stored as text
- Token is at least 128 bits from a cryptographic source, single-use, carries no personal data
- Callback URL is HTTPS and accepts both GET and POST
- Receiver stores the raw request before deciding, and answers 200 for everything it accepts
- One real callback captured for a successful ring and one for a session nobody rang;
FIELD_MAPandSUCCESS_VALUESfilled from them - Verified requires open session, inside expiry, caller match and an allowlisted status
- Expiry set from your own clock, shown as a countdown, enforced by a sweep job
- New attempt supersedes the old session in the same transaction
- “Send me a code instead” wired to
SMSApi/otpwith a DLT-matched template - Rate limits on session creation per number and per IP
- Sensitive actions require a second factor
Before lead capture goes live:
- One missed call number per campaign, with live-from and live-to dates recorded
- Export schedule set, with overlapping date ranges
- Loader checks the header and fails loudly on change
- Timestamp format and zone confirmed from a real export,
parse_tswritten to match - Retry window chosen and applied before the callback queue
- Daily totals reconciled against Miss Call Summary
- Consent wording versioned and linked to the number; suppression checked before any follow-up
Unspecified Behaviour and How to Code Around It
Several behaviours in a missed call flow aren’t pinned down by anything you can read before you build. Each item below gives you the choice that stays safe either way, and none of them needs you to wait for an answer before you ship.
One. Capture two real callbacks before you write the parser, and make them your schema. Open one session and ring it; open another and let it expire without ringing. Log both requests verbatim (method, headers, query string, body). The field holding the caller number, the field holding the outcome, and the values you saw become FIELD_MAP and SUCCESS_VALUES. Until then, the receiver stores everything and verifies nothing, which is the right default.
Two. Bind sessions with a token in the callback URL, whatever the payload carries. If the callback includes a reference of yours, fine; if it doesn’t, the token is already in the URL. Either way the session lookup is one primary-key read.
Three. Read fields from the query string and the body, on GET and on POST. Whichever method and encoding the forwarded request uses, a handler that merges both finds the fields.
Four. Treat only observed success values as success. A value you haven’t seen fails closed: the session stays open and either a later callback verifies it or the fallback takes over. A new value costs a delay, never a wrong verification.
Five. Never close a session on a negative callback. Whether “not dialled” arrives at the end of the platform’s window or earlier, leaving the session open until your own expiry is correct in both cases.
Six. Require a caller match, and if the payload has no caller number, use SMS OTP instead. A verification you can’t check against the expected number isn’t one you can audit later. If your captured callbacks don’t include the caller, keep missed calls for lead capture and verify with codes.
Seven. Make the receiver idempotent by content hash, so retry policy doesn’t matter. Whether the platform sends each callback once or several times, UNIQUE (token, body_sha256) turns repeats into no-ops.
Eight. Decide expiry from your own expires_at_ms, never from a timestamp in the payload. Its zone and precision don’t matter if you never compare against it. Store it raw for the record.
Nine. Parse the export’s Timestamp by probe, and fail closed if the probe doesn’t match. Ring your campaign number at a known minute, export, and check the row reads that minute in the zone you expect. Write parse_ts to that format and have it raise on anything else, so a format change stops the import instead of shifting every lead by hours.
Ten. Check the export header on every import. Column names can change between versions of a dashboard. A loader that compares against EXPECTED and stops on a mismatch can’t silently load the wrong column.
Eleven. Reconcile against the Summary, and investigate your side first when they disagree. Count skipped rows, check the export range covered whole days, and compare zone boundaries. Those three explain most gaps, and all three are in your data.
Twelve. Keep verification numbers and campaign numbers apart, so you never need to tell their callers apart. Whether or not a stray campaign caller would trigger your callback, a number that’s only ever shown inside a session can’t produce one.
FAQs
Is missed call verification free for the user?
Yes. The call disconnects before it’s answered, so the user isn’t charged for it. Your business pays for the service under its plan.
Does the callback only fire when the user actually rings?
No. The Missed Call OTP page says the response is forwarded to your callback URL “whether customer dialed the missed call number or not”. Your receiver has to read the outcome from the content, not from the fact that a request arrived.
How do I match a callback to the right user?
Put a random, single-use token in the callback URL when you open the session, and look the session up by that token. Then check that the caller number in the callback matches the number the session was opened for.
What should my callback endpoint return?
HTTP 200 for everything it accepts, including duplicates, unknown tokens and late callbacks. Store the request first, then decide. Only return a 5xx when you genuinely couldn’t store it, such as a database outage.
How long should a missed call session last?
A few minutes. Five minutes lines up with the 300-second default codeExpiry on SMSApi/otp, so switching to the SMS fallback feels consistent.
What if the user can’t place the call?
Offer “Send me a code instead”. Supersede the missed call session and send an SMS OTP with SMSApi/otp (sendMethod=generate, then sendMethod=verify). The product page itself recommends having an alternative for users who can’t ring.
Is missed call verification more secure than SMS OTP?
Not really. Both check possession of a phone number, and both are exposed to SIM swap, which the product page names as a limitation. Missed calls avoid code interception and typing errors, but treat them as equivalent strength and add a second factor for sensitive actions.
Can I get missed call logs through an API?
The logs and summary are dashboard screens with search and Export. Build your lead pipeline around scheduled exports loaded into your own table, as in the lead capture section above.
Why do I see the same caller several times in a few minutes?
Usually it’s one person retrying. Collapse calls from the same caller to the same number inside a window (15 minutes is a reasonable start) before you count leads or queue callbacks.
How do I track which campaign a lead came from?
Use a different missed call number for each campaign and record when each number went live and when it stopped. Attribution is then a join on the dialled number and the call time.
Can I use a landline number as my missed call number?
Yes. The pricing page offers either a 10-digit mobile number or a landline of 10 to 11 digits including the STD code. Normalise it to the ten-digit national form so every source spells it the same way.
Does a missed call count as consent to send marketing SMS?
Only for what you told people the call was for, and only if you can show that wording later. Record the call to action, the number, the time and the purpose, and check your suppression list and the customer’s registered preferences before you send anything.
Turn One Ring Into a Verified Number and a Clean Lead
Ready to put a missed call number behind your signup screen or your next campaign? Missed call numbers are allocated to your account rather than self-service, so get in touch with the numbers you’ll need for verification and for each campaign. While that’s being set up, wire the SMS OTP fallback and test it in the sandbox, so both paths are ready on day one.
More from the blog
Lossless JSON Parsing for Messaging APIs: 19-Digit IDs, Mixed Types and JSON Inside JSON
RCS Rich Cards and Carousels as a Content Model: Validate, Version and Send Cards Without Surprises
Voice Call API Wire Contract: OBD Calls, Voice OTP, Audio Library, Schedules and Delivery Reports