Sending your first SMS through an API takes ten minutes. Running SMS in production — where a duplicate OTP confuses a customer, a silent failure blocks a signup, and a leaked API key burns through your credit balance overnight — is a different discipline. This guide covers the production concerns that apply in any language or framework. If you are still at the first-request stage, start with a basic quickstart for your language and come back here before you go live.
1. Separate Sandbox From Production
Never point development code at your live route. A test loop gone wrong can send hundreds of real messages to real customers in seconds, and in India each one costs money and burns sender reputation.
- Use separate API keys for development, staging and production, and drive the base URL and key from environment configuration — never from constants in code.
- If your provider offers a sandbox or test mode, use it: requests validate and return message IDs without hitting operators.
- If there is no sandbox, maintain a whitelist of team phone numbers in your non-production config and hard-refuse any other recipient.
2. Keep API Keys Out of Your Code
SMS keys are a direct line to your wallet. Treat them like payment credentials:
- Load keys from environment variables or a secrets manager, never from source control. A key committed to a public repository will be found and abused.
- Never call the SMS API from a browser or mobile app — the key ships to every user. Always proxy through your own backend.
- Enable IP whitelisting on the provider dashboard if available, restrict the key to send-only permissions, and rotate keys on a schedule and immediately when a team member leaves.
- Set a low-balance alert so abuse is caught within hours, not at the end of the month.
3. Retry With Exponential Backoff — But Only on the Right Failures
Networks flake and gateways have brief outages, so a single failed HTTP call must not mean a lost OTP. But blind retries are worse than none. The rule: retry transport-level and server-side failures; never retry client-side rejections.
for attempt in 1..5:
response = http_post(sms_url, payload, timeout = 10s)
if response.status is 2xx:
return response.message_id
if response.status in (400, 401, 403, 422):
log_and_fail(payload) # bad request, bad key, bad template
return
# timeout, 429 or 5xx: wait and retry
sleep((2 ^ attempt) seconds + random jitter)
move_to_dead_letter_queue(payload)
The jitter matters: without it, a thousand workers that failed together all retry together, hammering the gateway in synchronized waves. On HTTP 429, honour the Retry-After header if the API sends one.
4. Make Sends Idempotent
The nastiest failure mode in SMS is the ambiguous one: your request times out after the gateway accepted it. A naive retry then delivers the message twice — two different OTPs, or two copies of a payment confirmation.
- Generate a unique client reference (a UUID or your own transaction ID) for every logical message and store it before the first attempt.
- Pass it in the API call if the provider supports a client-reference or custom ID field; the gateway can then deduplicate retries on its side.
- On your side, record the returned message ID against the reference. A retry loop should check for an existing message ID before resending.
- For OTPs, also make the code itself idempotent: a resend within the validity window should deliver the same code, not invalidate the previous one.
5. Handle Error Codes Explicitly
A generic "SMS failed" log line helps nobody at 2 AM. Map the codes you will actually see in India to distinct actions:
| Failure | Typical cause | Correct handling |
|---|---|---|
| 401 / invalid key | Wrong or rotated credentials | Alert immediately; do not retry |
| Insufficient credits | Balance exhausted | Alert billing contact; queue messages until top-up |
| 429 rate limited | Submitting above provisioned TPS | Back off and slow the worker pool |
| DLT template mismatch | Content differs from registered template | Fix the template mapping; retrying is pointless |
| DND / blocked number | Promotional message to a DND subscriber | Suppress the number from promotional lists |
| Invalid number | Malformed or non-existent MSISDN | Validate 10-digit Indian format (starting 6-9) before submission |
6. Queue High-Volume Traffic
Looping over 50,000 recipients with a synchronous HTTP call per iteration ties up your web process for an hour and collapses the moment anything hiccups. Put a queue between your application and the gateway:
- The web request only enqueues; background workers drain the queue at a rate matched to your provisioned throughput (messages per second).
- Use batch endpoints where the API offers them — one call carrying hundreds of recipients beats hundreds of calls.
- Run OTPs through a separate high-priority queue so a marketing blast never delays a login code.
- Give campaign messages an expiry: a "sale ends today" message still sitting in the queue tomorrow should be dropped, not delivered.
7. Handle Delivery Report Webhooks Properly
The API response only means the gateway accepted your message. Whether it reached a handset arrives minutes later as a DLR (delivery report) webhook. Common integration mistakes:
- Doing work inside the webhook handler. Acknowledge with HTTP 200 immediately and push the payload onto a queue; slow handlers cause the gateway to time out and re-deliver.
- Assuming order and uniqueness. Callbacks arrive out of order and sometimes twice. Update message status idempotently, keyed on message ID, and never let an older status overwrite a final one such as DELIVERED.
- Leaving the endpoint open. Verify a shared secret token or signature on every callback; an unauthenticated endpoint lets anyone mark your messages delivered.
- Ignoring gaps. Some statuses (handset off, BSNL rural circles) arrive very late or never. Run a reconciliation job that polls status for messages with no DLR after 30 to 60 minutes.
8. Map Sender IDs and DLT Templates in Configuration
Under TRAI DLT rules, every message needs a registered header (sender ID) and content template ID, and operators reject content that deviates from the registered text — down to punctuation. Hard-coding these strings across your codebase guarantees breakage the day a template is edited.
- Keep a single configuration map: message type → header, DLT template ID, and the template text with placeholders.
- Render outbound messages from the stored template so content and template ID can never drift apart.
- Keep variable values within DLT limits (30 characters per variable) and validate before submission.
- Version the map: when a new template is approved, ship the change as config, not a code hunt.
Full request formats, DLR payloads and error codes for our gateway are documented in the SMS developer API reference, including sample callbacks you can test against before going live.
Frequently Asked Questions
Should I retry a failed SMS automatically?
Retry only transient failures — timeouts, HTTP 429 and 5xx responses — with exponential backoff, jitter and a retry cap of around five attempts. Never retry validation failures such as bad credentials, malformed numbers or DLT template mismatches; the result will not change and the retries waste credits and rate limit.
How do I test an SMS integration without sending real messages?
Use the provider sandbox or test mode if one exists, which validates requests without operator submission. Otherwise, restrict non-production environments to a whitelist of team numbers, stub the SMS client in automated tests, and assert on the exact payload — recipient format, template ID, variable values — rather than actually calling the network.
Why does the gateway accept my message but the operator rejects it?
Gateway acceptance means the request was well-formed and billable; operator rejection usually means DLT scrubbing failed. The most common causes in India are content that differs from the registered template (even by punctuation or spacing), a wrong or inactive template ID, a header not registered on that specific operator, or a promotional message submitted outside the 9 AM to 9 PM window.