> ## Documentation Index
> Fetch the complete documentation index at: https://docs.orbit.devotel.io/llms.txt
> Use this file to discover all available pages before exploring further.

# SMPP production lifecycle: dlrMode, keepalive, reconnect, and DLR reconciliation

> The runbook for running an Orbit SMPP bind in production: choose a DLR delivery mode, keep the bind alive, reconnect with backoff and jitter, tune throughput, detect a zombie bind, and work the failure table an on-call rotation needs.

# SMPP production lifecycle

[Connect via SMPP](/guides/smpp) gets a bind on the wire and one message sent; the [wire-level recipes](/guides/smpp-recipes) show the exact bytes. This page is the layer after the first successful `submit_sm`: the decisions and discipline that keep a bind healthy at 2 a.m. — which `dlrMode` to run, how to keep the session alive and re-establish it safely, how much to push through one bind, and the symptom-to-fix table for the failures an aggregator on-call actually sees.

## 1. Recap: bind and credentials in one paragraph

Create a credential with `POST /api/v1/messaging/smpp/credentials` (or **Developer → SMPP** in the dashboard), copy the `system_id` and the one-time 8-character password, and bind a transceiver to `smpp.orbit.devotel.io` on `2775` (plain) or `3550` (TLS). The full walkthrough, runnable Python and Node clients, ports, and the diagnostic ladder live in [Connect via SMPP](/guides/smpp); the byte-level bind and submit listings live in [SMPP wire-level recipes](/guides/smpp-recipes). This guide assumes a bind that works and covers what changes when it must keep working.

Two facts from that page carry most of the weight here:

* One credential supports **one live session at a time.** A second bind while the first is alive is rejected with `ESME_RALYBND`. Scaling out means more credentials, not more binds per credential.
* Every `submit_sm` passes the same compliance gates (opt-out suppression, quiet hours, consent) as the REST path. A gate rejection arrives as a nonzero `command_status` in `submit_sm_resp`, never as a silent drop.

## 2. Choose a DLR delivery mode

The `dlrMode` field on the credential decides where each delivery receipt leaves Orbit:

| `dlrMode` | Receipt path |
| - | - |
| `bind` (default) | Returned over your SMPP session as `deliver_sm` PDUs. Nothing is POSTed. |
| `webhook` | POSTed as JSON to `dlrWebhookUrl` (HTTPS only). Nothing returns on the bind. |
| `both` | Both paths, for the same receipt. |

Which one to run:

| Pick | When | What you take on |
| - | - | - |
| `bind` | One long-lived sender that already parses `deliver_sm`, and receipts only matter while that sender is up. | Receipts generated while no session is open queue and wait for the next bind. A downed client means unread receipts, not lost ones. |
| `webhook` | Your receivers restart or autoscale, receipts must arrive while the SMPP client is down, or you want HTTP retries, HMAC verification, and several independent consumers. | A durable HTTP consumer: verify the signature, dedupe on the event id, ack fast. [Build a DLR-only webhook consumer](/guides/dlr-webhook-consumer) is the build guide. |
| `both` | A migration window (run both legs, dedupe on the message id), or two teams consuming the same receipts independently. | Deduplication on both legs; the two encodings carry the same canonical status by design. |

Switching is a `PATCH` on the credential row and propagates within one reconciliation pass (about 30 seconds); receipts generated before the change keep the mode in effect at generation time. If a termination rule moves your traffic off the SMPP ingress (the absorbed-traffic case in [the receipt window guide](/guides/smpp-receipt-timeout-window)), the rule can require `dlrMode` `webhook` or `both` (`require_dlr_mode`) so a bind-only credential never ends up as a receipt black hole.

### The deliver\_sm PDU and its webhook twin

A receipt on the bind is a `deliver_sm` whose `message_id` is the exact string your `submit_sm_resp` returned — that id is the join between your operation table and the delivery world:

```text theme={null}
deliver_sm (edge → client, command_id 0x00000005)
  esm_class=0x04                          (SMSC delivery receipt)
  message_id="msg_01H4XK6D7Y"             (the submit_sm_resp message_id — the join key)
  short_message="id:msg_01H4XK6D7Y stat:DELIVRD err:000"
```

Parse `stat:` from `short_message` and join on `message_id`. On absorbed traffic (say, SMPP submits terminated onto WhatsApp) the same envelope carries the canonical Orbit failure vocabulary in `err:` — `81` (not on destination channel), `88` (re-engagement window lapsed), `89` (rate limit), `00` (unspecified provider error) — per [Send-side DLR model](/concepts/send-side-dlr-model).

With `dlrMode` `webhook` or `both`, the same receipt is POSTed as a lifecycle event carrying the same canonical status, so one table maps the two planes:

| `deliver_sm` stat | Webhook event | `data.status` |
| - | - | - |
| `ACCEPTD` | `message.sent` | `sent` |
| `DELIVRD` | `message.delivered` | `delivered` |
| `UNDELIV` | `message.failed` | `undelivered` (absorbed failures also carry `data.error_code` with the `err:` token) |
| `EXPIRED` | `message.failed` | `expired` |
| `REJECTD` | `message.failed` | `rejected` |

Two asymmetries to code around: `read` has no SMPP stat (DELIVRD already fired on the bind), and `submitted_no_receipt` is a webhook-plane status with no bind-side stat. The bind and webhook encodings are otherwise identical by construction, per [SMPP edge model](/concepts/smpp-edge-model).

## 3. Long-lived bind discipline

**One transceiver per process, one process per credential.** A transceiver carries sends and receipts on one session, which is what almost every integration wants. Split a receiver-only bind out into its own process only when you deliberately scale receipt consumption separately — and give that process its own credential, because `ESME_RALYBND` means two processes can never share one.

The discipline that keeps the session alive:

* **Keepalive.** Send `enquire_link` every 30–60 seconds and treat a missing `enquire_link_resp` after a 10-second deadline as a dead bind. Silence longer than the edge's idle window (30 minutes by default) gets the session torn down server-side.
* **Reconnect with backoff and jitter.** After any drop, rebind on an exponential schedule with full jitter — for example a 1 s base doubling to a 60 s cap, `delay = random(0, min(cap, base * 2^attempt))`, resetting `attempt` on a successful bind. A rapid rebind loop is the bind-abuse pattern every SMPP server throttles and flags; jitter also stops a fleet of clients from re-binding in lockstep after an edge restart.
* **Drain before unbind.** On planned shutdown, stop enqueueing, wait for in-flight `submit_sm_resp` and pending `deliver_sm`, then `unbind`. Receipts in flight across a TCP close do not re-queue.
* **Budget the reconciler.** Credential changes — rotate, suspend, `dlrMode`, `tpsLimit`, `allowedCidrs` — take effect within one reconciliation pass (about 30 seconds). Rotating a live credential invalidates the old password immediately and drops the live bind; schedule it in a maintenance window.
* **The receipt timeout window.** When your submits are absorbed onto another channel, the UNDELIV you fail over on fires at the rule's `receipt_timeout_seconds` (10–120 s, default 30). Set it against your measured confirmation curve, not the default — [SMPP: the no-delivery receipt window](/guides/smpp-receipt-timeout-window) is the how-to.

### Detecting a zombie bind

A zombie bind is a session that your monitoring believes is dead but that still holds the credential's single slot — a half-killed replica, a client whose TCP connection is open but whose read loop is stuck. Three signals expose it:

1. **`ESME_RALYBND` on (re)bind while "nothing else is connected."** Something holds the slot. This is the unambiguous one.
2. **Your side is silent but the dashboard disagrees.** The reconciliation loop stamps each credential's live bind count and status back onto your row about every 30 seconds, and **Developer → SMPP → Credentials** renders it. If every one of your clients is down but the credential still shows a live bind, a zombie holds it.
3. **Your own keepalive telemetry.** Track `enquire_link_resp` misses and `submit_sm_resp` rates per bind in your metrics; a TCP connection that is open but unanswered is a zombie on your side of the wire, even before the edge notices.

The operator escape hatch when a zombie holds the slot: suspend the credential (or rotate it). Existing sessions drop within one reconciliation pass; re-activate and bind fresh. If the zombie is yours, also fix the shutdown path — a process that exits without `unbind` leaves the slot occupied until the edge's idle teardown.

## 4. Throughput tuning

Four knobs, in the order they bind:

* **The credential's `tpsLimit` is the contract** — 50 submissions per second by default, adjustable per organization up to the platform ceiling. The cap counts per second per bind on a sliding window: a burst in one second doesn't taint the next, and exceeding it answers `submit_sm_resp` with `ESME_RTHROTTLED`.
* **The window is your pacing mechanism.** SMPP has no batch PDU; batching *is* pipelining — many `submit_sm` in flight over one bind, drained by their responses. Keep a window of unacknowled submits (start in the low tens), and raise it only while `submit_sm_resp` latency stays flat and no `ESME_RTHROTTLED` / `ESME_RMSGQFUL` appears. A full window means stop enqueueing, not drop.
* **Ramp, don't blast.** On a fresh bind or after a reconnect, ramp your enqueue rate over the first minutes instead of opening at full rate; a cold path absorbs less than a warm one, and `ESME_RMSGQFUL` is the signal you didn't.
* **One bind has a practical ceiling.** If a raised `tpsLimit` still binds your throughput, open several binds (several credentials) and spread load — that is the supported scale-out path, not pushing one session past its limit.

The response-latency budget to hold yourself to:

| Timer | Value | Why |
| - | - | - |
| Connect timeout | 5–10 s | A blocked port or wrong port hangs instead of refusing; an explicit timeout makes it fail fast. |
| `enquire_link` period | 30–60 s | Inside the edge's idle teardown window. |
| `enquire_link_resp` deadline | 10 s | An unanswered keepalive is your dead-bind trigger. |
| `submit_sm_resp` deadline | \~10 s client-side | An unacknowledged submit holds a window slot and leaves the message in "unknown" until reconciled. |
| Reconnect backoff | 1 s doubling, 60 s cap, full jitter | Fast enough to recover, slow enough to not be flagged. |
| Config propagation | \~30 s | One reconciliation tick for rotate/suspend/`dlrMode`/`tpsLimit` changes. |
| Absorbed-receipt window | 10–120 s, default 30 s | When the synthesized UNDELIV fires on absorbed traffic. |

If `submit_sm_resp` p99 climbs toward your deadline while the send rate holds, the window or the upstream path is saturating — back the enqueue rate off before the edge does it for you.

## 5. Failure runbook

Work the matching section, not the whole page.

### Bind rejected

**Symptom:** `bind_transceiver_resp` returns a nonzero `command_status`.

| Status | Probable cause | Fix |
| - | - | - |
| `ESME_RINVPASWD` (`0x0E`) | Wrong or rotated password. | Re-copy exactly (8 characters). Past the 60-second reveal window, rotate and use the new one; rotation drops the live bind, so do it in a window. |
| `ESME_RINVSYSID` (`0x0F`) | Unknown `system_id`, or a revoked credential. | Use the id returned at creation, not your description. A revoked credential cannot be re-bound or rotated — create a fresh one. |
| `ESME_RINVBNDFMT` (`0x15`) | Source IP outside the credential's `allowedCidrs`. | Add your egress CIDR via **Developer → SMPP** or `PATCH`, or clear the list. Remember NAT: allow the egress address, not the private one. |
| `ESME_RALYBND` (`0x0D`) | Another session already holds the credential. | A zombie or a duplicate sender. See [Detecting a zombie bind](#detecting-a-zombie-bind). |
| No response at all | Network, not credential. | Port `2775`/`3550`, DNS, egress firewall, TLS hostname verification — the ladder in [Connect via SMPP](/guides/smpp). |

A suspended credential also rejects new binds and drops live ones within a reconciliation pass — if a bind that worked an hour ago now fails, check the credential status before the network.

### submit\_sm\_resp with nonzero command\_status

**Symptom:** The bind is healthy; individual submits are refused.

| Status | Probable cause | Fix |
| - | - | - |
| `ESME_RTHROTTLED` (`0x58`) | Over the credential's `tpsLimit`. | Jittered backoff on the affected submits; raise `tpsLimit` or add binds if the ceiling genuinely binds. |
| `ESME_RINVDSTADR` (`0x0B`) | Destination not normalized. | E.164 digits; set `dest_addr_ton=1`/`npi=1` only if your client requires it. |
| `ESME_RINVMSGLEN` (`0x04`) | Payload over one segment without concatenation. | Split with the UDH marker (`esm_class=0x40`) or shorten; `data_coding=8` for UCS-2 changes the segment budget. |
| `ESME_RMSGQFUL` (`0x14`) | Edge queue full — often inbound `deliver_sm` flooding the session. | Slow the enqueue; drain receipts first. |
| Other nonzero status, traffic that should send | A compliance gate (opt-out, quiet hours, consent) refused the submit. | The rejection is per-tenant configuration applying on the wire; the **Compliance** tab under Developer → SMPP shows how the gates apply to your binds. The code table is in [SMPP bind and command status codes](/troubleshooting/smpp-bind-and-command-status-codes). |

### Unacknowledged submit\_sm flood

**Symptom:** The window fills with submits that get no `submit_sm_resp`; enqueue stalls.

1. Stop enqueueing immediately; the window is your brake.
2. Read the socket. A client that stopped reading (backpressure bug) sits on a healthy connection with a full window; draining responses and `deliver_sm` can unstick it without a rebind.
3. Probe with `enquire_link`. A response proves the session alive — keep draining. No response inside 10 s means the TCP path is half-dead: rebind with the backoff schedule above.
4. Reconcile, don't blind-resend. Every submit that never got a response is in "unknown": retrying risks duplicates. Record what you have (sequence number, and `message_id` for everything that did ack) and reconcile against the webhook plane or your message history — the statuses eventually land as `delivered`, `undelivered`, or `expired` events.

### DLR loss

**Symptom:** `submit_sm_resp` said accepted; no receipt arrives inside your window plus margin.

* **`dlrMode` is `bind` and your client was down at receipt time.** Receipts queue for the next bind. Bind again and drain, or move the credential to `webhook`/`both` if client downtime is a normal state for you.
* **`dlrMode` is `webhook` and the endpoint is failing.** Delivery retries run for about 4.3 hours before the event dead-letters; check your endpoint's 2xx rate and the DLQ, per [Build a DLR-only webhook consumer](/guides/dlr-webhook-consumer) and [Webhook delivery semantics](/concepts/webhook-delivery-semantics).
* **Absorbed traffic.** The synthesized UNDELIV fires at `receipt_timeout_seconds`, not at carrier speed — a quiet bind on absorbed submits is often just the window still running.
* **The handset never receipts.** Some MNOs confirm slowly or never; the row closes `expired` late. [The no-delivery receipt window](/guides/smpp-receipt-timeout-window) and [submitted-no-receipt](/troubleshooting/submitted-no-receipt) cover the tuning and the diagnosis.

## 6. Observability

**Where to look first.** **Developer → SMPP** is the per-`system_id` console: the **Credentials** tab shows each credential's live bind state and count (stamped by the reconciliation loop about every 30 seconds) beside the connection details; the **Carriers** tab shows each upstream connector's `bindStatus` (`BOUND` / `UNBOUND` / `BIND_FAILED`) and `lastSeenAt`; the **Compliance** tab shows how the tenant gates apply to this wire. Per-message, the message row's status timeline shows how long confirmations actually take on each channel — the curve you tune the receipt window against.

**What to alert on, on your side:**

| Metric | Alerts when | It points at |
| - | - | - |
| `enquire_link_resp` misses / latency | Any miss beyond the 10 s deadline | Dead or zombie bind → Section 3 |
| Nonzero `submit_sm_resp` rate, by code | Any sustained non-zero rate | Throttle / gate / normalization → Section 5 |
| In-flight window depth | Approaching your configured window | Enqueue outpacing the path → Section 4 |
| Receipt rate vs. submit rate | Diverging beyond window + margin | DLR loss → Section 5 |
| Bind state flaps (bound ↔ unbound) | More than a handful per day | Keepalive or rebind-loop discipline → Section 3 |
| `ESME_RALYBND` occurrences | Any | Duplicate sender or zombie → Section 3 |

**Handing a capture to support.** On the SMPP wire the tool is `tcpdump` (sngrep belongs to SIP legs elsewhere in a CPaaS stack, not this one):

```bash theme={null}
tcpdump -i any 'host smpp.orbit.devotel.io and (port 2775 or port 3550)' -s 0 -w smpp.pcap
```

On `3550` the capture shows framing and timing but not payload, since the session runs inside TLS — pair it with your client's PDU log (the `logging.DEBUG` trace in the Python example, the `pdu` event in the Node one). Send support the capture, your `system_id` (never the password), the client library and version, and the failing time window with timezone; the sequence numbers in the capture line the session up against the edge's view.

## See also

* [Connect via SMPP](/guides/smpp) — bind walkthrough, ports, credential lifecycle, diagnostic ladder
* [SMPP wire-level recipes](/guides/smpp-recipes) — the byte-level bind/submit/keepalive listings
* [SMPP: the no-delivery receipt window](/guides/smpp-receipt-timeout-window) — `receipt_timeout_seconds` on absorbed traffic
* [SMPP edge model](/concepts/smpp-edge-model) — the reconciled relay underneath the bind
* [Send-side DLR model](/concepts/send-side-dlr-model) — the canonical receipt vocabulary
* [Build a DLR-only webhook consumer](/guides/dlr-webhook-consumer) — the durable receiver for the webhook leg
* [SMPP bind and command status codes](/troubleshooting/smpp-bind-and-command-status-codes) — the full status table


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.