@larvit/smpp

A simplified implementation of the SMPP protocol, in TypeScript. ESM only, types included.

Successor to larvitsmpp 0.4.0. The API is the same shape it has always been — connect, send an SMS, listen for delivery reports — with callbacks replaced by promises and the rough edges taken off.

Not published yet. The implementation is complete and tested, but 1.0.0 has not been released to npm. Until it is, use larvitsmpp 0.4.0. Remaining release steps are in todo.md.

Requirements

Node 18 or later. The only runtime dependency is @larvit/log.

Install

npm install @larvit/smpp

Client

The simplest possible client — connects to localhost:2775 with no credentials and sends a message:

import { client } from '@larvit/smpp';

const { err, session } = await client();
if (err) throw err;

await session.sendSms({
	from:    '46701113311',
	message: 'Hello world',
	to:      '46709771337',
});

await session.unbind();

With connection parameters, a delivery report and logging:

import { Log } from '@larvit/log';
import { client } from '@larvit/smpp';

const log = new Log('debug');

const { err, session } = await client({
	host:     'smpp.somewhere.com',
	log,
	password: 'bar',
	port:     2775,
	username: 'foo',
});
if (err) throw err;

session.on('dlr', dlr => {
	// dlr.smsId, dlr.statusMsg, dlr.statusId
});

const { err: sendErr, smsIds } = await session.sendSms({
	dlr:     true,
	from:    '46701113311',
	message: '«baff»',
	to:      '46709771337',
});

Client options

Every one is optional.

Option Default
host / port localhost / 2775 Where to connect.
username / password user / pass Bind credentials (system_id and password).
bindType transceiver transceiver, transmitter or receiver.
interfaceVersion 0x34 The SMPP version declared at bind. 0x50 for an SMSC that requires SMPP 5.0.
systemType, addressRange, addrTon, addrNpi '', '', 0, 0 The remaining bind fields, for operators that require them.
tls false true for defaults, or a tls.ConnectionOptions object for a private CA or a client certificate.
enquireLinkInterval 20000 How often to send enquire_link on a quiet link.
idleTimeout 2 × enquireLinkInterval Give up on a link the peer has stopped answering; with reconnect set, it re-binds.
responseTimeout 30000 How long to wait for a response before giving up on it; 0 waits forever.
maxOutstanding 10 Requests allowed on the wire at once; further sends queue.
reconnect off { minDelay, maxDelay } to re-bind automatically after a drop or an idle timeout, with exponential backoff.
log silent A @larvit/log instance.
signal An AbortSignal that cancels connecting and tears the session down.

Sending

await session.sendSms({
	dlr:                  true,        // ask for a delivery report
	encoding:             'UCS2',      // override the automatic choice
	flash:                false,
	from:                 'MyBrand',   // alphanumeric -> TON 5, digits -> TON 1
	message:              'Hello world',
	scheduleDeliveryTime: new Date(Date.now() + 3600_000),
	to:                   '46709771337',
	validityPeriod:       3600,        // seconds, or a Date
}, { signal });                       // optional per-call AbortSignal

Messages too long for one SMS are split automatically and sent as a concatenated message. You get one id per segment:

const { err, pduObjs, smsIds } = await session.sendSms({ from, message, to });

err is set when the SMSC refuses a segment, and it names the status it refused with. Because every segment goes on the wire together, pduObjs and smsIds then hold what the SMSC did accept — enough to reconcile against a later receipt, not enough to resend the rest, so treat a partial failure as a failed message. A message needing more than 255 segments is refused before anything is sent, since the concatenation header numbers segments in a single octet.

Server

The simplest possible server — no authentication, listening on port 2775:

import { server } from '@larvit/smpp';

const { err, server: smpp } = await server();
if (err) throw err;

smpp.on('session', session => {
	session.on('sms', async sms => {
		// sms.from, sms.to, sms.message, sms.dlr
	});
});

With authentication and delivery reports:

import { server } from '@larvit/smpp';

const { err, server: smpp } = await server({
	// Replace with your own auth. Returning an object attaches it to session.userData.
	authenticate: async ({ password, systemId }) => {
		if (systemId !== 'foo' || password !== 'bar') return false;

		return { userData: { userId: 123 } };
	},
});
if (err) throw err;

smpp.on('session', session => {
	session.on('sms', async sms => {
		// Responding is part of the protocol, not optional. Without arguments it answers
		// ESME_ROK with a generated id; pass your own, and a status to refuse the message.
		await sms.sendResp();
		// await sms.sendResp({ smsId: yourOwnId, status: 'ESME_RMSGQFUL' });

		if (sms.dlr) {
			await sms.sendDlr(); // same as sms.sendDlr('DELIVERED')
		}
	});
});

console.log(smpp.port);  // the port actually bound, useful when 0 was requested
await smpp.close();      // stop listening and close every live session

sendDlr accepts SCHEDULED, ENROUTE, DELIVERED, EXPIRED, DELETED, UNDELIVERABLE, ACCEPTED, UNKNOWN, REJECTED and SKIPPED.

Server options

Option Default
host / port all interfaces / 2775 Where to listen. Pass 0 for any free port.
authenticate accept everything ({ password, session, systemId, systemType }) => false | { userData }, sync or async.
systemId '' The SMSC identity returned to the ESME in the bind response.
interfaceVersion 0x34 The SMPP version advertised in the bind response. The floor for sending a peer optional parameters stays 0x34, whatever this is set to.
tls false A tls.TlsOptions object with your certificate and key.
idleTimeout 40000 Drop a peer that has been silent this long.
maxReassembly 1000 Incomplete multipart messages held per session.
maxOctets 67108864 Bytes of incomplete multipart messages held per session.
reassemblyTimeout 300000 How long a late segment can still join an incomplete message.
responseTimeout, maxOutstanding, log, signal as for the client

Errors

Nothing in this library throws. Every fallible call returns a result carrying an optional err, so failures are handled in one place instead of two:

const { err, session } = await client({ host: 'smpp.somewhere.com' });
if (err) return;

const { err: sendErr, smsIds } = await session.sendSms({ from, message, to });

Runtime failures on a live connection arrive as sessionError and serverError events. They are deliberately not called error: Node turns an unhandled error event into a thrown exception, which is exactly what this library promises not to do.

Sessions

Events

Event Fires when
sms An SMS arrives, reassembled if it was multipart. Carries sendResp(), sendDlr() and the smsId it was answered with.
dlr A delivery report arrives, one per segment.
messageDlr Every segment of a multipart message sent with dlr: true has been reported on, carrying the worst status of the segments.
close The connection closed.
reconnected The client re-bound after a drop (only with reconnect configured).
sessionError Something failed on a live session, including a hook or listener that threw.
data Raw bytes arrived on the socket.
incomingPdu A complete PDU arrived, as a buffer.
incomingPduObj The same PDU, parsed into an object.

Methods

sendSms(), send(), sendReturn(), unbind() and close(). send() reaches any of the 33 SMPP commands the codec knows, not just the four the session handles natively:

const { err, pduObj } = await session.send({
	cmdName: 'query_sm',
	params: { message_id: smsId },
});

acceptsOptionalParams() answers whether the peer declared SMPP 3.4 or later, which is the version at and above which the spec allows optional parameters to be sent to it; peerInterfaceVersion is the raw value it declared. The library's own senders consult the first before attaching a TLV — a send() you build yourself is passed through as written, so consult it too when you attach TLVs.

Working with PDUs directly

The codec is exported, synchronous, and never throws — handy for inspecting captured traffic:

import { isCommand, objToPdu, pduToObj } from '@larvit/smpp';

const { err, pduObj } = pduToObj(buffer);
if (err) return;

if (isCommand(pduObj, 'submit_sm')) {
	pduObj.params.destination_addr; // typed as a string
}

The spec tables are exported both individually (cmds, consts, encodings, errors, tlvs, types, and the matching *ById maps) and grouped as defs.

Migrating from larvitsmpp 0.4.0

  • The package is now @larvit/smpp and is ESM only. require() no longer works.
  • Callbacks are gone. client, server, sendSms, sendResp and sendDlr are all promises resolving to a result object with an optional err. Nothing rejects.
  • server() resolves once, when it is listening, and gives you a handle with close(), port and a session event. It no longer calls your callback once per incoming connection.
  • The id a message is answered with goes to sendResp({ smsId }), and sms.smsId is read-only: it reports what the response actually carried. Delete any sms.smsId = … line — assigning to it throws a TypeError, since modules are always strict mode — and pass the id to sendResp().
  • checkuserpass is now authenticate, takes { password, session, systemId, systemType } and returns false or { userData }.
  • Renamed options: enqLinkTimingenquireLinkInterval, server timeoutidleTimeout.
  • larvitsmpp.utils is gone. Its contents are named exports: bitCount, decodeMessage, encodeMessage, objToPdu, pduReturn, pduToObj, smppDate, smppTime, splitMessage. The PDU codec is synchronous and returns { err, pduObj } / { err, buffer }.
  • pduObj.isResp() is now the standalone isResp(pduObj), and pduObj.cmdStatus is undefined for a status code the library does not know, with the raw number in pduObj.cmdStatusId.
  • defs.filters is gone. It was declared on every command and TLV but never invoked, so it did nothing. SMPP time formatting, the one part worth keeping, is exported as smppTime.
  • The error event is sessionError (and serverError on the server handle).
  • log takes a @larvit/log instance instead of a larvitutils one, and is silent by default.

Behaviour that changed on the wire

0.4.0 had a number of protocol defects. Fixing them changes the bytes it puts on the wire, so if you have worked around any of these, remove the workaround:

  • Every multipart segment was one character short (152 GSM characters instead of 153, 66 UCS2 instead of 67), so long messages were split into more segments than necessary — and each extra segment costs a message.
  • LATIN1 (data_coding 0x03) was silently decoded as ASCII, corrupting the message.
  • Delivery receipt dates were a month off, and the status field read UNDELIVERABLE where the spec defines the 7-character UNDELIV.
  • flash: true discarded UCS2, mangling flash messages containing non-GSM characters.
  • The multipart reference counter was shared by every session in the process.
  • tls: true never performed a handshake, so the connection was not actually encrypted.
  • Alphanumeric senders were sent with TON 1 (international) instead of TON 5.
  • Delivery receipts carrying only the standard receipt text, with no TLVs — what Kannel and several other SMSCs send — were rejected outright. They are now parsed.
  • A message whose last octet was 0x00 was allocated one octet short while sm_length still reported the full length, so it went out corrupt. In UCS2 that is any message ending in a character like 一 (U+4E00), which made the bug routine for CJK text.
  • Binary TLVs (message_payload, network_error_code, callback_num and the rest) were parsed into a hex string and written back as the ASCII of that string, so every one that made a round trip went out corrupt. They are Buffers in both directions now, so drop any hex encoding of your own.
  • Short or malformed PDUs threw out of the codec instead of being reported as a parse failure.
  • Binds now declare interface_version 0x34. 0.4.0 declared 0x00, which tells the SMSC the ESME speaks SMPP 3.3 or earlier — and a spec-following SMSC then withholds every optional parameter, including the TLVs delivery receipts are carried in.
  • A response reporting a failure now carries no body, which is what the spec defines and what other implementations send. 0.4.0 filled the body with empty defaults, so a refused submit_sm_resp went out with an empty message_id a caller could mistake for a real one.
  • submit_multi was missing its sm_length field, so its short_message never round-tripped.

The corrected framing is cross-checked against node-smpp, an independent implementation, in both directions and over a live session.

Development

Everything runs in the container; nothing is installed on the host.

docker compose run --rm node npm install
docker compose run --rm node npm test                # lint, typecheck and tests
docker compose run --rm node npm run build
docker compose run --rm node npm run test:compiled   # what CI runs on older Node versions

Tests are TypeScript and run directly under Node's type stripping, so there is no build step in the development loop. CI additionally compiles and runs them on Node 18, 20, 22 and 24 to verify the supported range.

License

MIT

S
Description
SMPP 3.4 client and server for Node.js with the session layer built in: keepalive, reconnect, send window, long messages and delivery receipts
https://www.npmjs.com/package/@larvit/smpp Readme MIT 1.4 MiB
Languages
TypeScript 89.4%
Java 5.1%
Python 2.8%
Dockerfile 1.2%
PHP 1.1%
Other 0.4%