Short answer
A DLR webhook receiver is an HTTPS endpoint that your SMS gateway calls each time a message's delivery status changes. A production-ready receiver does four things on every request. It verifies the HMAC-SHA256 signature on the raw body, validates the payload, ignores duplicates using the message ID and status, and hands the work to a queue so it can return 200 OK quickly. This guide budgets 2,000 ms for that. Below are tested receivers for Node.js (Express) and Python (FastAPI), plus the queue, database and local testing setup around them.
About the examples: the payload fields, status names and the X-SMSTAKE-Signature header used in this guide are illustrative. The exact field names, status values, signing scheme and retry schedule for your account are in the API documentation shared during onboarding. Map them in one place in your code, and the rest of this guide applies unchanged.
Technical overview and architecture
DLR lifecycle statuses
Each message you send gets a unique message_id. As the message moves through the network, its status changes and the gateway sends a delivery report (DLR) for each change. This guide uses six normalised statuses:
| Status | Meaning | Final? | Typical SMPP receipt state |
|---|---|---|---|
SUBMITTED | The gateway accepted the message and passed it on towards the operator | No | None yet; the message is en route |
DELIVERED | The operator confirmed delivery to the handset. It doesn't mean the message was read | Yes | DELIVRD |
UNDELIVERED | The network couldn't deliver it, for example because the phone stayed switched off or unreachable | Yes | UNDELIV |
FAILED | A permanent failure, such as an invalid or inactive number, or a provider-side error | Yes | Provider-specific error codes |
EXPIRED | Not delivered before the message's validity period ended | Yes | EXPIRED |
REJECTED | Blocked before delivery, for example by DLT scrubbing (unregistered header or template, a value that fails its variable tag, a missing PE-TM chain) or operator policy | Yes | REJECTD |
Treat SUBMITTED as the only non-final status. Receipts can arrive out of order, so your code must never let a late SUBMITTED overwrite a final status. Error codes aren't standard either: the SMPP protocol leaves them to each network, so always read err_code against your provider's documentation. For what each status means in plain language, see SMS delivery reports explained. For the common reasons behind failures and rejections, see why SMS delivery fails.
Webhook push vs API polling
| Aspect | Webhook push | API polling |
|---|---|---|
| Latency | Status arrives moments after the gateway receives it | Up to one full polling interval late, plus request time |
| Network traffic | One request per status change | Requests for every pending message on every poll, most of them returning "no change" |
| Scaling | Grows with message volume only | Grows with message volume multiplied by polling frequency |
| API quota | Status updates don't use your API calls | Status checks can count against your rate limits |
| Infrastructure | Needs a public HTTPS endpoint | Works from behind a firewall |
| Missed updates | The sender retries unacknowledged callbacks | Gaps in polling delay updates until the next run |
For real-time flows such as OTP fallback or order notifications, push is the better default. Polling still has a role as a safety net: a scheduled reconciliation job that looks for messages still SUBMITTED long after their validity period catches anything a webhook missed.
Reference architecture
Jio / Airtel / Vi / BSNL
| delivery receipt
v
+------------------------+
| SMSTAKE gateway |
+------------------------+
| HTTPS POST + signature
v
+------------------------+
| Your webhook receiver |
| 1. verify signature |
| 2. validate payload |
| 3. dedupe id + status |
| 4. enqueue, return 200 |
+------------------------+
| job
v
+------------------------+
| Queue: Redis + BullMQ |
| or Celery |
+------------------------+
| workers retry with backoff
v
+------------------------+
| Database, CRM, alerts |
+------------------------+
Webhook security and best practices
HMAC-SHA256 payload signature verification
With HMAC signing, the sender and your receiver share a secret key. For each request, the sender computes an HMAC-SHA256 of the exact request body using the secret and sends the result in a header. Your receiver recomputes it and rejects the request if the two don't match. A forged or modified request fails, even if an attacker knows your webhook URL.
- Hash the raw bytes: if you parse the JSON and re-serialise it, whitespace or key order can change and a genuine request will fail. Both examples below read the raw body before parsing it.
- Compare in constant time: use
crypto.timingSafeEqualin Node.js andhmac.compare_digestin Python. A normal string comparison can leak how many characters matched. - Check first, then process: return
401for a bad signature before touching the payload. - Protect the secret: keep it in an environment variable or secrets manager, never in code, logs or screenshots, and rotate it if it's exposed.
- Block replays where possible: if the signing scheme includes a timestamp, also reject requests older than a few minutes.
Confirm with our team whether webhook signing is enabled for your SMSTAKE account, and which header and format it uses. If signatures aren't available for your setup, combine HTTPS with a long random token in the webhook URL path and, where available, IP allowlisting.
POST /webhooks/smstake/dlr HTTP/1.1
Content-Type: application/json
X-SMSTAKE-Signature: sha256=<hex-encoded HMAC-SHA256 of the raw body>
{
"message_id": "msg_test_0001",
"recipient": "+91XXXXXX3210",
"status": "DELIVERED",
"err_code": null,
"delivered_at": "2026-10-03T10:15:04+05:30"
}
Idempotency and retries
Webhook delivery is at-least-once. If your endpoint times out or returns a non-2xx status, SMSTAKE retries the DLR, so the same callback can arrive more than once. The retry schedule for your account is in the API documentation shared during onboarding.
- Deduplicate on
message_idplusstatus: one message legitimately produces several callbacks, such asSUBMITTEDthenDELIVERED. Deduplicating on the message ID alone would drop the final status. - Acknowledge fast: return
200 OKas soon as the event is verified and safely queued. This guide budgets 2,000 ms end to end, well inside typical sender timeouts. Do database writes, CRM calls and notifications in a worker. - Return 200 for duplicates: a duplicate has already been handled, and an error would only cause more retries.
- Choose error codes deliberately: use
503for temporary problems you want retried, such as your queue being down, and4xxfor requests that will never be valid. - Make the final write idempotent too: in-memory or Redis checks cut duplicate work, but only a database unique key guarantees it. Workers that fail should retry with exponential backoff rather than give up on the first error.
-- PostgreSQL. One row per status event: a repeated callback inserts nothing.
CREATE TABLE dlr_events (
message_id text NOT NULL,
status text NOT NULL,
err_code text,
delivered_at timestamptz,
received_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (message_id, status)
);
INSERT INTO dlr_events (message_id, status, err_code, delivered_at)
VALUES ($1, $2, $3, $4)
ON CONFLICT (message_id, status) DO NOTHING;
-- Update the message row, but never let a late SUBMITTED overwrite a final status.
UPDATE messages
SET status = $2, err_code = $3, delivered_at = $4
WHERE message_id = $1
AND ($2 <> 'SUBMITTED' OR status IS NULL);
Rate limiting and queueing
DLRs arrive in bursts. A campaign to a large list produces a matching wave of callbacks within minutes, and your receiver has to absorb it without timing out.
- Queue the work: use BullMQ on Redis for Node.js, or Celery with Redis or RabbitMQ for Python. The receiver only enqueues.
- Control concurrency: set worker concurrency so bursts don't overload your database or CRM.
- Retry with exponential backoff: if a downstream system is briefly unavailable, workers retry at increasing intervals instead of failing.
- Rate-limit with care: if you put a rate limiter in front of the endpoint, set it well above your peak DLR rate. A
429is just another failure that the sender will retry. - Monitor: track 4xx and 5xx responses, queue depth, failed jobs and the share of messages still
SUBMITTED.
Code implementation: Node.js (Express)
Tested with Node.js 24 and both Express 4 and Express 5. The route uses express.raw(), so the body arrives as a Buffer for signature verification. Don't register express.json() globally before this route; it would consume the body, and the handler would answer 415.
// server.mjs - Node.js 18+ | npm install express
import express from 'express';
import crypto from 'node:crypto';
const SECRET = process.env.SMSTAKE_WEBHOOK_SECRET;
if (!SECRET) throw new Error('Set SMSTAKE_WEBHOOK_SECRET');
const STATUSES = new Set(['SUBMITTED', 'DELIVERED', 'UNDELIVERED', 'FAILED', 'EXPIRED', 'REJECTED']);
function verifySignature(rawBody, header) {
if (typeof header !== 'string' || !header.startsWith('sha256=')) return false;
const received = Buffer.from(header.slice(7), 'hex');
const expected = crypto.createHmac('sha256', SECRET).update(rawBody).digest();
return received.length === expected.length && crypto.timingSafeEqual(received, expected);
}
// In-memory stand-ins so the example runs as-is.
// In production, use Redis (SET NX) or a database unique key, and a real queue.
const seen = new Set();
const jobs = [];
let draining = false;
async function processDlr(event) {
// e.g. UPDATE messages SET status = $2, err_code = $3, delivered_at = $4 WHERE message_id = $1
console.log('processed', event.message_id, event.status);
}
async function drain() {
if (draining) return;
draining = true;
while (jobs.length) {
const event = jobs.shift();
try {
await processDlr(event);
} catch (err) {
console.error('processing failed', event.message_id, err);
}
}
draining = false;
}
const app = express();
app.post(
'/webhooks/smstake/dlr',
express.raw({ type: 'application/json', limit: '100kb' }),
(req, res) => {
if (!Buffer.isBuffer(req.body)) {
return res.status(415).json({ error: 'expected application/json' });
}
if (!verifySignature(req.body, req.get('X-SMSTAKE-Signature'))) {
return res.status(401).json({ error: 'invalid signature' });
}
let event;
try {
event = JSON.parse(req.body.toString('utf8'));
} catch {
return res.status(400).json({ error: 'invalid JSON' });
}
if (typeof event.message_id !== 'string' || !STATUSES.has(event.status)) {
return res.status(422).json({ error: 'unexpected payload' });
}
const key = `${event.message_id}:${event.status}`;
if (!seen.has(key)) {
seen.add(key);
jobs.push(event);
setImmediate(drain);
}
res.status(200).json({ received: true });
},
);
app.listen(process.env.PORT || 3000, () => {
console.log(`DLR receiver on port ${process.env.PORT || 3000}`);
});
Run it with SMSTAKE_WEBHOOK_SECRET=your_secret node server.mjs. The in-memory set and array keep the example self-contained. They're lost on restart and aren't shared between instances, so replace them with Redis and a real queue in production.
Production queue with BullMQ
// queue.mjs - npm install bullmq (needs a Redis server)
import { Queue } from 'bullmq';
export const connection = { host: process.env.REDIS_HOST || '127.0.0.1', port: 6379 };
const dlrQueue = new Queue('smstake-dlr', { connection });
export async function enqueueDlr(event) {
await dlrQueue.add('dlr', event, {
jobId: `${event.message_id}-${event.status}`, // a job with an existing ID is not added again
attempts: 5,
backoff: { type: 'exponential', delay: 2000 }, // retries after 2s, 4s, 8s, 16s
removeOnComplete: { age: 24 * 3600 }, // keep IDs for a day so late duplicates are still skipped
removeOnFail: { age: 7 * 24 * 3600 },
});
}
// In the Express handler, replace the in-memory queue with:
//
// try {
// await enqueueDlr(event);
// } catch (err) {
// console.error('enqueue failed', err);
// return res.status(503).json({ error: 'temporarily unavailable' }); // the sender will retry
// }
// res.status(200).json({ received: true });
// worker.mjs - run as a separate process: node worker.mjs
import { Worker } from 'bullmq';
import { connection } from './queue.mjs';
async function processDlr(event) {
// Idempotent database write (see the SQL above). Throwing here makes BullMQ retry with backoff.
console.log('processed', event.message_id, event.status);
}
const worker = new Worker('smstake-dlr', (job) => processDlr(job.data), { connection, concurrency: 10 });
worker.on('failed', (job, err) => console.error('DLR job failed', job?.id, err.message));
BullMQ skips a job whose ID already exists, so the message_id-status job ID deduplicates for as long as completed jobs are kept. BullMQ doesn't allow a colon in custom job IDs, which is why the example uses a hyphen.
Code implementation: Python (FastAPI)
Tested with Python 3.13, FastAPI 0.142 and Pydantic 2. The handler reads the raw body with await request.body(), so the signature is computed over the exact bytes received. The Pydantic model then validates the fields and returns 422 for unknown statuses or malformed data.
# app.py - Python 3.10+ | pip install fastapi uvicorn
import hashlib
import hmac
import os
from datetime import datetime
from typing import Literal, Optional
from fastapi import BackgroundTasks, FastAPI, Header, HTTPException, Request
from pydantic import BaseModel, ValidationError
SECRET = os.environ["SMSTAKE_WEBHOOK_SECRET"].encode()
Status = Literal["SUBMITTED", "DELIVERED", "UNDELIVERED", "FAILED", "EXPIRED", "REJECTED"]
class DlrEvent(BaseModel):
message_id: str
recipient: str
status: Status
err_code: Optional[str] = None
delivered_at: Optional[datetime] = None
app = FastAPI()
seen: set[str] = set() # use Redis SET NX or a database unique key in production
def verify_signature(raw: bytes, header: Optional[str]) -> bool:
if not header or not header.startswith("sha256="):
return False
expected = hmac.new(SECRET, raw, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, header[len("sha256="):].lower())
def process_dlr(event: DlrEvent) -> None:
# e.g. UPDATE messages SET status = ..., err_code = ..., delivered_at = ... WHERE message_id = ...
print("processed", event.message_id, event.status)
@app.post("/webhooks/smstake/dlr")
async def smstake_dlr(
request: Request,
background: BackgroundTasks,
x_smstake_signature: Optional[str] = Header(default=None),
):
raw = await request.body()
if not verify_signature(raw, x_smstake_signature):
raise HTTPException(status_code=401, detail="invalid signature")
try:
event = DlrEvent.model_validate_json(raw)
except ValidationError:
raise HTTPException(status_code=422, detail="unexpected payload")
key = f"{event.message_id}:{event.status}"
if key not in seen:
seen.add(key)
background.add_task(process_dlr, event)
return {"received": True}
Run it with SMSTAKE_WEBHOOK_SECRET=your_secret uvicorn app:app --port 3000. BackgroundTasks runs after the response, in the same process. That's fine for light work, but tasks are lost if the process restarts. For production, move the work to Celery:
Production queue with Celery
# tasks.py - pip install celery redis | run: celery -A tasks worker
from celery import Celery
celery_app = Celery("smstake_dlr", broker="redis://localhost:6379/0")
@celery_app.task(
autoretry_for=(Exception,),
retry_backoff=True, # exponential delays between retries: about 1s, 2s, 4s, 8s...
retry_backoff_max=600,
retry_jitter=True,
max_retries=5,
)
def process_dlr_task(event: dict) -> None:
# Idempotent database write keyed on (message_id, status)
print("processed", event["message_id"], event["status"])
# In app.py, replace background.add_task(...) with:
#
# process_dlr_task.delay(event.model_dump(mode="json"))
Testing and debugging workflow
- Start the receiver with a test secretUse a throwaway value such as
test_secret_change_me, never your production secret, and run either example on port 3000. - Send a signed test payloadRun the
curlcommand below. You should getHTTP/1.1 200 OKand{"received":true}. - Check the failure pathsChange one character in
BODYafter computingSIGand expect401. Send the same request twice and confirm it is processed once. Set the status toREADand expect422. - Expose your local portRun
ngrok http 3000(after adding your ngrok authtoken) ornpx localtunnel --port 3000. Each prints a public HTTPS URL that forwards to your machine. - Point test traffic at the tunnelSet
https://<your-tunnel>/webhooks/smstake/dlras the webhook URL in your SMSTAKE account's delivery report settings, or ask our team to configure it. Then send a test SMS to your own number. - Inspect and replayngrok's local inspector at
http://127.0.0.1:4040shows each request's headers and body and can replay it. That makes signature mismatches much easier to debug. - Clean upRemove the tunnel URL from your webhook settings when you're done. Tunnel URLs change, and anyone with the URL can reach your machine while it's open.
The command below simulates an SMSTAKE DLR. It runs in any Bash shell with OpenSSL, including Git Bash on Windows:
SMSTAKE_WEBHOOK_SECRET='test_secret_change_me'
BODY='{"message_id":"msg_test_0001","recipient":"+91XXXXXX3210","status":"DELIVERED","err_code":null,"delivered_at":"2026-10-03T10:15:04+05:30"}'
SIG=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac "$SMSTAKE_WEBHOOK_SECRET" | sed 's/^.* //')
curl -i -X POST http://localhost:3000/webhooks/smstake/dlr \
-H "Content-Type: application/json" \
-H "X-SMSTAKE-Signature: sha256=$SIG" \
--data "$BODY"
When signatures don't match
- The body was parsed and re-serialised before hashing. This is the most common cause.
- The secret is wrong, or the environment variable has a trailing space or newline.
- The encoding differs: hex vs Base64, or a missing or extra
sha256=prefix. - A proxy or middleware changed the body, for example by decompressing it or converting its character set.
Log the message ID, status and whether the signature was valid. Never log the secret, and mask recipient numbers in logs.
Go-live checklist
- HTTPS endpoint with a valid certificate
- Signature verified on the raw body with a constant-time comparison
- Duplicates skipped on
message_idplusstatus, backed by a database unique key 200 OKwithin your time budget, with slow work in a queue- Final statuses never overwritten by a late
SUBMITTED - Monitoring for error responses, queue depth and messages stuck in
SUBMITTED - Recipient numbers masked in logs, and no OTP content stored
Need enterprise gateway access?
Send high-volume SMS through SMSTAKE's REST API or SMPP, with DLR webhooks, sandbox guidance and DLT onboarding assistance.
View the Bulk SMS GatewaySetting up webhooks for the first time? Our DLR webhook guide covers the business side: setup steps and statuses by channel.
Sources
Checked on 3 October 2026. Library APIs change, so check the documentation for the versions you use.
- SMPP Protocol Specification v3.4 - message states and delivery receipts (PDF)
- RFC 2104 - HMAC: keyed-hashing for message authentication
- Node.js documentation - crypto: createHmac and timingSafeEqual
- Python documentation - hmac, including compare_digest
- Express API reference - express.raw()
- FastAPI documentation - background tasks
- BullMQ documentation - retrying failing jobs
- BullMQ documentation - job IDs
- Celery documentation - tasks and automatic retries
- ngrok documentation
- localtunnel on GitHub