SMPP gateway for high-volume SMS
When you need to push 100k+ SMS per day with tight latency — OTPs, transactional alerts, mass campaigns — REST is the wrong protocol. MsgHub exposes a fully-featured SMPP 3.4 gateway. Persistent TCP, sliding-window submit, real-time DLR, India DLT-compliant. This guide covers everything from bind types to throughput tuning to working code in Java, Python and Node.
Overview
SMPP (Short Message Peer-to-Peer) is the binary protocol that runs underneath every Indian SMS aggregator and telco. Instead of one HTTP request per SMS, you open a single TCP connection, bind once, and stream submit_sm PDUs. The gateway responds with submit_sm_resp and pushes back deliver_sm PDUs for delivery receipts. Latency drops, throughput multiplies, and you can handle backpressure properly.
MsgHub's SMPP layer is built on Jasmin. It sits between your account on MsgHub and your DLT-registered SMS vendor. There are two ways SMPP shows up in the platform:
- Flow A — Vendor SMPP for your own outbound: you (the MsgHub tenant) configure your aggregator's SMPP credentials in MsgHub Settings. Your dashboard / REST / chatbot sends become SMPP submits to your vendor via Jasmin. Recommended once you exceed ~50 SMS/sec on HTTP. Up to 1,000 TPS supported.
- Flow B — Expose SMPP to your enterprise clients: if you resell SMS to enterprise customers, you can also expose Jasmin's port 2775 to those clients. They bind directly to your tenant's SMPP server URL (shown in Settings after you provision Jasmin). Per-client accounts, sender-ID whitelists, and per-bind TPS caps are managed in the SMPP Accounts sidebar page.
This guide covers both flows. Most readers will care about Flow A. Flow B is for resellers / agency tenants.
When to use SMPP vs REST
| Use case | Recommended |
|---|---|
| OTP / 2FA at > 50/sec sustained | SMPP |
| Transactional alerts < 50/sec | REST |
| Marketing broadcasts (50k+ recipients) | SMPP (or REST batch) |
| One-off transactional with rich payload | REST (simpler) |
| Need delivery receipt within 2 seconds | SMPP (push) vs REST + webhook (push too — equivalent) |
| Mobile / web app with no backend | REST |
Rule of thumb: if your SMS load can be served by a single thread of REST calls, use REST. If you're already worrying about HTTP keep-alive and concurrent connections, switch to SMPP.
Prerequisites
- An active MsgHub tenant with SMS channel configured (a DLT-registered aggregator). See DLT SMS setup if you haven't done that.
- Your vendor's SMPP credentials — host, port, system ID, password — from your aggregator (MSG91, Kaleyra, Gupshup, etc.). This is the SMPP endpoint MsgHub will connect to, not the one your application connects to.
- An SMPP client library (only for Flow B — enterprise clients binding directly to MsgHub). jSMPP, smpplib, node-smpp. Don't write SMPP from scratch — the PDU encoding is fiddly.
Flow A · Configure vendor SMPP (most readers)
In MsgHub, open Settings (bottom of sidebar) → scroll to the SMPP (High-Volume SMS) section.
- Toggle Enable SMPP on. ("Route SMS campaigns through SMPP instead of the HTTP vendor API.")
- Fill the fields from your vendor's dashboard:
- Vendor SMPP Host (e.g.
smpp.kaleyra.com) - Port (typically 2775)
- System ID (your login)
- Password
- Vendor SMPP Host (e.g.
- Set the TPS Target slider — 10 to 1000 TPS. MsgHub provisions one Jasmin connector per 100 TPS automatically.
- Set Jasmin User Password — used internally by Jasmin to authenticate the per-tenant user named
t-{your-tenant-id}. - Click Save SMPP Settings.
- Click Apply to Jasmin — this provisions the Jasmin connector on the backend. Wait for the success confirmation.
After this, all SMS dispatched from your account — REST API, dashboard sends, campaigns, chatbot flows — route through Jasmin to your vendor over SMPP. Nothing changes from the API caller's perspective.
You're done if you only need Flow A. Skip to the "Bind types", "submit_sm", "encoding" sections below if you want the protocol-level details — they describe what Jasmin sends to your vendor on your behalf.
Flow B · Expose SMPP to your enterprise clients
This is for tenants who resell SMS access. After completing Flow A, MsgHub also exposes its own SMPP server (Jasmin's port 2775) to enterprise clients. Each client gets their own bind credentials managed via the SMPP Accounts page in the sidebar.
Create an enterprise client account
- Sidebar → SMPP Accounts → + New Account.
- Fill:
- Account Label — friendly name for tracking
- Password — what the client uses to bind
- Max Bindings — concurrent connections allowed (default 2)
- TPS Limit — per-client throughput cap
- Allowed Sender IDs — whitelist of headers this client may use
- Enabled — toggle
- Save.
Connection details to share with the client
After Jasmin is provisioned (Flow A above), Settings shows an Enterprise SMPP Server URL. Copy it. Share with the client:
| SMPP Host | Shown as Enterprise SMPP Server URL in your tenant's Settings — depends on your MsgHub deployment |
| SMPP Port | 2775 |
| SMPP version | 3.4 (interface_version = 0x34) |
| System ID | The account label / username from the SMPP Accounts page |
| Password | The password you set when creating the account |
| Bind Type | Transceiver (recommended) or Transmitter |
SMPP port 2775 is plaintext. If your enterprise clients aren't on the same private network as the MsgHub deployment, run the SMPP connection through a TLS tunnel (stunnel, ssh -L) or a VPN. There is no native TLS port currently.
Bind types
SMPP defines three bind types. Pick based on what you need to do:
| Type | Sends | Receives | Use when |
|---|---|---|---|
| Transmitter (TX) | ✓ | ✗ | Send only — DLR via webhook instead of SMPP |
| Receiver (RX) | ✗ | ✓ | DLR-only or inbound-SMS-only listener |
| Transceiver (TRX) | ✓ | ✓ | Default for most apps. Single connection does both. |
TRX is what 90% of production setups use. The other two exist for cases where you want different processes handling send and receive (e.g. sender pod and DLR-consumer pod scaled independently).
Sending: submit_sm PDU
The workhorse PDU is submit_sm. Key fields:
| Field | Notes |
|---|---|
source_addr | Your DLT-registered Header (6 chars, e.g. MSGHUB) |
destination_addr | Recipient phone in international format without +, e.g. 919876543210 |
source_addr_ton | 5 (alphanumeric) for Header sender |
source_addr_npi | 0 |
dest_addr_ton | 1 (international) |
dest_addr_npi | 1 (E.164) |
short_message | Up to 140 bytes (GSM 7-bit ~ 160 chars / UCS-2 ~ 70 chars) |
data_coding | 0 = GSM 7-bit, 8 = UCS-2 (Unicode) |
registered_delivery | 1 to request DLR |
DLT-specific TLVs
For DLT compliance, include the approved Template ID in an optional TLV parameter:
0x1402 · entity_id (PE ID) | Optional; defaults to tenant's primary PE |
0x1403 · template_id | Required — your DLT-approved 18-19 digit template ID |
MsgHub validates these on every submit. Wrong / missing template_id → submit_sm_resp with status 0xFF (custom: DLT template mismatch).
Encoding & message length
SMS is constrained to 140 bytes per PDU. That maps to different character counts depending on encoding:
| data_coding | Encoding | Single SMS | Per-segment in concat |
|---|---|---|---|
| 0 | GSM 7-bit (default) | 160 chars | 153 chars |
| 8 | UCS-2 (Hindi, Gujarati, emoji) | 70 chars | 67 chars |
Pure-ASCII message containing a single non-GSM char (e.g. ' typographic apostrophe instead of ') drops you to UCS-2 — and from 160 to 70 chars. Watch for smart-quote substitution in templates copied from Word. Strip or sanitise before submit.
Concatenated (multi-part) SMS
Messages longer than the single-SMS limit must be split into segments. Two standard methods:
Method A · UDH (User Data Header)
Set esm_class = 0x40 and prepend each segment with a 6-byte UDH:
# UDH layout for concatenation 05 00 03 {ref_num} {total_parts} {part_number} # │ │ │ │ │ └─ 1-indexed (1, 2, 3...) # │ │ │ │ └──────────────── total segment count # │ │ │ └─────────────────────────────── arbitrary ID, same for all segments of one message # │ │ └────────────────────────────────────── IEI length (3) # │ └───────────────────────────────────────── IEI: concatenated SMS, 8-bit ref # └──────────────────────────────────────────── UDH length (5)
Method B · message_payload TLV (recommended)
Use the 0x0424 TLV to send up to 1024 bytes in a single PDU. The gateway handles UDH segmentation downstream. Simpler client code, identical result on the handset.
Most SMPP libraries do this for you automatically — pass the full text, the library splits + UDH-tags appropriately.
Delivery receipts (DLR)
When registered_delivery = 1 on submit_sm, MsgHub pushes a deliver_sm PDU back when the telco confirms delivery (or failure).
DLR short_message follows the standard format:
id:a3b8c9 sub:001 dlvrd:001 submit date:2605141032 done date:2605141033 stat:DELIVRD err:000 text:first 20 chars of message
Key fields:
id— matches themessage_idreturned in yoursubmit_sm_respstat— DELIVRD / UNDELIV / EXPIRED / REJECTD / UNKNOWN / DELETEDerr— telco-level error code (3 digits, varies by operator)
Respond with deliver_sm_resp within 30 seconds or MsgHub considers the DLR unacknowledged and may retry.
Throughput tuning
Window size
SMPP sliding window controls how many in-flight submits can be unacknowledged. Default: 10. Tune up to 100 if your latency requirements are tight and your client handles ordering properly.
# jSMPP example session.setMaxUnboundedTime(60000); session.setMaxUnboundedTime(60000); session.setEnquireLinkTimer(30000); session.setTransactionTimer(60000); session.setSubmitTimer(60000); session.setMaxUnboundedTime(60000); # Set window size via session.getConnection().setRequestTimeout(...)
Multiple binds
One bind = ~100 SMS/sec under load. Need more? Open more binds. MsgHub allows up to 10 simultaneous TRX binds per system_id (higher on request). Distribute submits across them (round-robin or sticky-by-route).
Enquire_link
Send enquire_link every 30 seconds to keep the connection alive through NAT / firewall idle timeouts. Most libraries do this for you — just configure the interval.
Reconnect strategy
TCP connections die. Plan for it.
- Catch unbind / disconnect events.
- Wait with exponential backoff: 1s → 2s → 5s → 15s → 60s → cap at 5 min.
- Reconnect, rebind, resubmit anything that was in-flight (use unique IDs to dedupe on gateway side).
- Don't reconnect tighter than 1s — you'll get auth-rate-limited.
For HA, run two binds from different boxes and route by primary/secondary. When primary dies, route all new submits through secondary while primary reconnects. This pattern gives sub-second failover compared to ~5–10s of pure reconnect-driven downtime.
Code examples
Java · jSMPP
import org.jsmpp.bean.*; import org.jsmpp.session.*; SMPPSession session = new SMPPSession(); String systemId = System.getenv("MSGHUB_SMPP_SYSTEM_ID"); String password = System.getenv("MSGHUB_SMPP_PASSWORD"); // MSGHUB_SMPP_HOST comes from your tenant's Settings → SMPP → Enterprise SMPP Server URL session.connectAndBind( System.getenv("MSGHUB_SMPP_HOST"), 2775, new BindParameter( BindType.BIND_TRX, systemId, password, "", // system_type TypeOfNumber.UNKNOWN, NumberingPlanIndicator.UNKNOWN, null // address range ) ); // Listen for DLRs session.setMessageReceiverListener(new MessageReceiverListener() { public void onAcceptDeliverSm(DeliverSm dlr) throws ProcessRequestException { DeliveryReceipt r = dlr.getShortMessageAsDeliveryReceipt(); System.out.println("DLR for " + r.getId() + ": " + r.getFinalStatus()); } // alertNotification, onAcceptDataSm omitted }); // Send OptionalParameter templateId = new OptionalParameter.OctetString( (short) 0x1403, "1707XXXXXXXXXXXXXX".getBytes() ); String messageId = session.submitShortMessage( "", // service_type TypeOfNumber.ALPHANUMERIC, NumberingPlanIndicator.UNKNOWN, "MSGHUB", // source = DLT header TypeOfNumber.INTERNATIONAL, NumberingPlanIndicator.ISDN, "919876543210", // destination new ESMClass(), (byte) 0, // protocol_id (byte) 0, // priority "", // schedule_delivery "", // validity_period new RegisteredDelivery(SMSCDeliveryReceipt.SUCCESS_FAILURE), (byte) 0, // replace_if_present new GeneralDataCoding(Alphabet.ALPHA_DEFAULT), (byte) 0, // sm_default_msg_id "Your OTP is 482910. Valid for 5 minutes.".getBytes(), templateId ); System.out.println("submitted: " + messageId);
Python · smpplib
import os, smpplib.client, smpplib.consts, smpplib.gsm # Host comes from your tenant's Settings → SMPP → Enterprise SMPP Server URL client = smpplib.client.Client(os.environ["MSGHUB_SMPP_HOST"], 2775) # DLR handler def on_deliver(pdu): print("DLR:", pdu.short_message.decode()) client.set_message_received_handler(on_deliver) client.connect() client.bind_transceiver( system_id=os.environ["MSGHUB_SMPP_SYSTEM_ID"], password=os.environ["MSGHUB_SMPP_PASSWORD"], ) parts, encoding_flag, msg_type_flag = smpplib.gsm.make_parts( "Your OTP is 482910. Valid for 5 minutes." ) for part in parts: pdu = client.send_message( source_addr_ton=smpplib.consts.SMPP_TON_ALNUM, source_addr="MSGHUB", dest_addr_ton=smpplib.consts.SMPP_TON_INTL, destination_addr="919876543210", short_message=part, data_coding=encoding_flag, esm_class=msg_type_flag, registered_delivery=True, # DLT template_id TLV optional_parameters={0x1403: b"1707XXXXXXXXXXXXXX"}, ) client.listen() # blocks; runs DLR handler
Node.js · smpp package
const smpp = require('smpp'); // Host comes from your tenant's Settings → SMPP → Enterprise SMPP Server URL const session = smpp.connect({ url: `smpp://${process.env.MSGHUB_SMPP_HOST}:2775`, auto_enquire_link_period: 30000, }); session.bind_transceiver({ system_id: process.env.MSGHUB_SMPP_SYSTEM_ID, password: process.env.MSGHUB_SMPP_PASSWORD, }, (pdu) => { if (pdu.command_status !== 0) { console.error('Bind failed:', pdu.command_status); return; } console.log('Bound. Sending...'); session.submit_sm({ source_addr: 'MSGHUB', source_addr_ton: 5, destination_addr: '919876543210', dest_addr_ton: 1, dest_addr_npi: 1, short_message: 'Your OTP is 482910. Valid 5 min.', registered_delivery: 1, data_coding: 0, // DLT TLVs '0x1403': Buffer.from('1707XXXXXXXXXXXXXX'), }, (resp) => { console.log('submit_sm_resp:', resp.message_id); }); }); // DLR listener session.on('deliver_sm', (pdu) => { console.log('DLR:', pdu.short_message.message); session.deliver_sm_resp({ sequence_number: pdu.sequence_number }); });
SMPP error codes (command_status)
| Code | Name | Meaning |
|---|---|---|
0x000 | ESME_ROK | Success |
0x00D | ESME_RBINDFAIL | Wrong system_id / password |
0x00E | ESME_RINVPASWD | Invalid password |
0x00F | ESME_RINVSYSID | Invalid system_id |
0x033 | ESME_RBINDFAIL | Already bound — your client opened too many sessions |
0x058 | ESME_RTHROTTLED | You exceeded the throughput cap. Slow down or open more binds. |
0x065 | ESME_RINVSCHED | Invalid schedule_delivery_time |
0x0FF | (custom: DLT template mismatch) | Template ID missing or doesn't match content |
Troubleshooting
"Bind failed" with ESME_RINVPASWD
- Password copy/paste introduced a whitespace. Regenerate from Settings or trim before passing to your client.
- Wrong system_id — check exact case (some libraries lowercase by default).
submit_sm_resp always returns 0xFF
- DLT template_id TLV is missing. Add the
0x1403optional parameter. - Template_id is correct but the message body doesn't match the approved template byte-for-byte (after substituting variables). Usual culprits: smart quotes, extra spaces, an emoji not in the template.
- Header (source_addr) doesn't match an approved header for this template_id.
Connection drops every ~5 minutes
- NAT/firewall idle timeout. Lower your
enquire_link_periodto 30 seconds. - A network appliance is killing long-lived TCP. Move to a dedicated egress IP or use the TLS port — some appliances handle TLS connections differently.
DLRs never arrive
- You're bound as TX (transmitter only). DLRs go to RX or TRX binds. Switch to TRX.
registered_deliverywas set to 0 on submit. Must be 1.- The destination operator simply didn't send a DLR back (rare in India, more common abroad).
Throughput plateau at ~30 SMS/sec
- Window size is too small. Increase to 50–100.
- You're sending submit_sm synchronously and waiting for resp before next submit. Switch to async pipelining.
- Open additional TRX binds and round-robin across them.
FAQ
Should I use SMPP or REST?
REST is fine up to ~50 SMS/sec per tenant. Beyond that — OTP-scale workloads sending 100k to millions per day with tight latency — SMPP shines because the persistent connection eliminates HTTP overhead. Rule of thumb: REST for transactional, SMPP for high-volume OTP.
What SMPP version does MsgHub support?
SMPP 3.4 (via Jasmin). SMPP 3.3 clients work in compatibility mode. SMPP 5.0 is not supported.
What's the max throughput?
The TPS Target slider in Settings → SMPP goes up to 1,000 TPS per tenant. MsgHub provisions one Jasmin connector per 100 TPS, so 1,000 TPS means 10 connectors fan-out to your vendor. Your vendor's contract typically has its own TPS cap — your effective max is the lower of the two.
Why am I getting ESME_RTHROTTLED on a client bind (Flow B)?
The client exceeded the TPS Limit set on their SMPP Account. Either raise the limit for that client in the SMPP Accounts page, or have the client slow their submit rate. MsgHub doesn't retry the dropped submit — back-pressure is the signal.
Can I send Hindi / Gujarati / regional language SMS?
Yes. Use data_coding = 8 (UCS-2). Note that UCS-2 caps the message at 70 characters per segment (vs 160 for GSM 7-bit), and most Indian aggregators charge per-segment.
How do I send a flash SMS (Class 0)?
Set data_coding low nibble bit 0 — i.e. data_coding = 0x10 for GSM 7-bit Class 0. Not supported on all Indian operators; test before relying on it.
Can I use SMPP for inbound (two-way) SMS?
Yes if your sender Header supports two-way (short codes in particular, some long codes). Inbound MO messages arrive as deliver_sm PDUs without DLR fields — message text is in short_message. Bind as RX or TRX to receive.
What if my vendor's SMPP support is different?
The fields in Settings → SMPP (host, port, system ID, password, TPS) cover the standard SMPP 3.4 bind parameters. If your vendor needs non-standard auth (signed bind, mTLS, IP whitelisting) contact us — Jasmin's connector layer can usually accommodate, but it needs a one-time wiring per vendor.