A clean, dependency-free TypeScript SDK for the SSP1126 POS terminal (Saman Electronic Payment). It speaks the terminal's native ISO-8583:1987 over TCP protocol, reverse-engineered from the legacy Delphi component.
npm install --save saman-payment-pos
import { Ssp1126Client } from 'saman-payment-pos';
// TCP (default channel)
const pos = new Ssp1126Client({ host: '192.168.14.105', port: 1197 });
const conn = await pos.connectionTest();
if (conn.ok) {
// No purchaseId — that would request an *identified purchase*; see below.
const res = await pos.purchase({ mainAmount: 10000, referenceData: 'ORDER-1001' });
console.log(res.ok, res.responseCode, res.responseMessage, res.rrn);
}
pos.close();
The same client works over TCP or Serial — pick the channel in the constructor options.
// TCP: { host, port? }
const tcp = new Ssp1126Client({ transport: 'tcp', host: '192.168.14.105', port: 1197 });
// `transport` may be omitted; TCP is the default.
// Serial: { path, baudRate? } — 8 data bits, 1 stop bit, no parity (fixed)
const serial = new Ssp1126Client({ transport: 'serial', path: '/dev/ttyUSB0', baudRate: 19200 });
// Windows: path: 'COM3'
Serial uses the optional serialport package; install
it only if you use the serial channel (npm install serialport). TCP has zero runtime
dependencies. Both channels frame, buffer partial reads, time out and surface errors
identically (shared FrameTransport base), so they are fully interchangeable.
You can also build a transport directly:
import { createTransport, TcpTransport, SerialTransport } from 'saman-payment-pos';
const t1 = createTransport({ host: '192.168.14.105' }); // TcpTransport
const t2 = createTransport({ transport: 'serial', path: 'COM3' }); // SerialTransport
Construct with new Ssp1126Client(options):
| option | default | meaning |
|---|---|---|
transport |
"tcp" |
channel: "tcp" or "serial" |
host |
— | terminal IP (TCP) |
port |
1197 |
terminal TCP port (TCP) |
path |
— | serial device, e.g. "COM3" / "/dev/ttyUSB0" (serial) |
baudRate |
19200 |
serial baud rate (serial) |
connectTimeoutMs |
10000 |
connect / open timeout |
currency |
"364" |
DE49 currency (IRR) |
componentVersion |
"1.4.3.0" |
DE57 for purchases |
verifyMac |
true |
verify DE64 MAC on responses |
logger |
— | (line) => void raw frame / step trace |
minReconnectGapMs |
500 |
reconnect gap after a transaction that ended cleanly (final 17) |
reconnectGapAfterPartialMs |
1500 |
reconnect gap after one that ended without a final 17 |
firstAckTimeoutMs |
3000 |
timeout for the opening 15 acknowledgement only |
retryOnFirstTimeout |
true |
retry once if the opening request of a transaction times out on a fresh connection |
retryDelayMs |
500 |
delay before the retry above |
Each method runs a full transaction (connect → multi-step dialog → dispose → close) and
resolves to a TransactionResult (or a typed result for reports/ops):
| method | purpose |
|---|---|
connectionTest() |
reachability / handshake |
getAuthorizedOperations() |
which operations the terminal is provisioned for |
balance() |
card balance inquiry |
purchase({ mainAmount, … }) |
PC-initiated purchase (amount known up front) |
posStarterPurchaseInit() |
step 1 of a POS-initiated purchase: read the card, list routing options |
posStarterPurchaseFin({ mainAmount, segment?, … }) |
step 2: complete a purchase started with posStarterPurchaseInit() |
posStarterPurchaseCancel() |
abort a posStarterPurchaseInit() you're not completing |
billPayment({ billId, paymentId, … }) |
pay a bill |
billRequest() |
read a bill from the inserted card |
pinCharge({ … }) |
buy a PIN charge voucher |
topupCharge({ mobileNumber, … }) |
direct mobile top-up |
mciBillInquiry({ mciNumber, billType }) |
MCI (Hamrah) bill inquiry |
tciBillInquiry({ tciNumber, billType }) |
TCI (telecom) bill inquiry |
totalReport({ fromDate, toDate, posPin? }) |
aggregate totals |
report({ filter, filterValues, reportType, posPin? }) |
detailed rows |
Bill identifier helpers (exported separately, no terminal needed):
validateBill(billId, paymentId), billAmountRials(paymentId), billCategory(billId).
TransactionResult fields: ok, responseCode, responseMessage, terminalId,
traceNumber, serialId, rrn, amount, effectiveAmount, transactionDate,
cardMask, cardHash1, cardHash2, charge voucher fields, plus fields (every raw
DE harvested), timedOut (true if a step never got a response at all, as opposed to
a decline — see "Connection behavior" below).
purchaseId / DE63 — the usual cause of 07Do not set purchaseId for an ordinary sale. It populates DE63, which asks the
terminal for an identified purchase (خرید شناسهدار) — a distinct transaction type
the vendor added in v1.0.1 of the web service, provisioned separately from ordinary
purchase. A merchant without that entitlement answers responseCode: '07' ("No
permission for this operation"), immediately and before it even prompts for a card.
Because DE63 has no bit of its own in DE48, getAuthorizedOperations() cannot warn
you: a terminal can report every flag true and still refuse every DE63-bearing
purchase. Verified end-to-end against a live terminal — four purchase variants
carrying a purchaseId all returned 07; the same terminal completed a normal sale
(00) the moment DE63 was omitted.
If you want your own order number on the transaction, use referenceData (DE56) or
additionalData (DE48):
await client.purchase({
mainAmount: 10000,
referenceData: 'ORDER-1234', // your reference — NOT purchaseId
});
Reserve purchaseId for the case where you actually mean an identified purchase and
know the merchant is entitled to it.
Both flows exist and are independently provisioned, so
getAuthorizedOperations().purchase is only a hint — let DE39 on the real request be
the authority. If you get 07, check purchaseId first (above); that is far more
often the cause than a genuinely disabled flow.
purchase() — PC-started. The PC sends the amount immediately; the
customer taps/inserts their card only after that, within the same request's
financial step.posStarterPurchaseInit() + posStarterPurchaseFin() — POS-started. The
customer taps/inserts their card first (Init), the terminal reports back
which payment routing options that card supports (usually just one), and only
then does the PC send the amount to complete the sale (Fin). This mirrors a
point-of-sale flow where the cashier doesn't know the amount will be finalized
until the card is already presented.const init = await pos.posStarterPurchaseInit(); // prompts customer to tap/insert card
if (init.ok) {
// init.segments is usually a single { code, label } pair; if there's more than
// one, let the customer/cashier pick and pass its `code` as `segment` below.
const segment = init.segments.length > 1 ? init.segments[0].code : undefined;
const fin = await pos.posStarterPurchaseFin({ mainAmount: 10000, segment });
console.log(fin.ok, fin.responseCode, fin.rrn);
} else {
console.log('card read failed:', init.responseCode, init.responseMessage);
}
posStarterPurchaseInit() deliberately leaves the connection open on success
(mirroring the reference component) so posStarterPurchaseFin() can complete the
same transaction on it — don't call any other method on this client in between, or
call posStarterPurchaseCancel() first. If Init succeeds but you decide not to
complete the sale (customer backs out, UI abandoned, timeout in your own app),
call posStarterPurchaseCancel() to dispose and close cleanly rather than leaving
the connection open indefinitely.
The framing is asymmetric, which is the easiest thing to get wrong:
PC → POS: [ISO-8583 message] (raw, nothing added)
POS → PC : [2-byte big-endian length][5-byte header 60 00 00 00 00][ISO-8583 message]
On responses the length counts the header + ISO bytes. The SDK strips that envelope
on receive and adds nothing on send; you always work with the bare
Iso8583Message.
Both reference implementations agree: the Delphi SendCommand transmits the
MACPack output verbatim — every capture in SSP1126 PC to POS 2007/log/*.txt
logs Send Command With Result : N with N equal to the message length exactly —
and the C# SSP1126.PcPos.dll (NetworkChannel) makes sendMessageHeader a no-op,
never sends a length, and sets setHeader(new byte[0]), while its read path skips
exactly 7 bytes.
Prefixing a request with the response envelope shifts every field by 7 bytes. The
terminal then can't parse the request and answers immediately with a well-formed
rejection — DE39=98 ("Operation cancelled by user"), DE3=000000 — which looks
deceptively like the customer pressed Cancel.
Every transaction opens a fresh TCP connection, per the reference component. The terminal is not ready to serve a new connection immediately after the previous one closes: it completes the TCP handshake but never answers the first request.
How long it needs depends on how the previous transaction ended. A transaction
that received the terminal's final 17 recovers quickly; one that stopped short
(getAuthorizedOperations(), totalReport(), report(), or anything that failed)
needs substantially longer. The SDK tracks this and picks the gap automatically:
minReconnectGapMs (default 500) — after a clean 17. Measured on firmware
10.063.00PO: 0 ms and 250 ms stalled, 500 ms and above did not.reconnectGapAfterPartialMs (default 1500) — after a transaction with no final
17. Measured by running getAuthorizedOperations() then a connection test: 500 ms
and 1000 ms both stalled (~12–13 s end to end), 1500 ms completed in ~3 s.Two more settings bound the cost when it still goes wrong:
firstAckTimeoutMs (default 3000) — the opening 15 arrives in 3–5 ms when it
is coming at all, so waiting 10 s only delays discovering the terminal is not
listening. This covers the 15 only: the terminal sends it immediately and then
waits for a card, so a short value here does not shorten the customer's card window.retryOnFirstTimeout (default true) — safety net. If the opening response
still times out on a freshly-opened connection, the SDK closes, waits retryDelayMs
(default 500 ms), reconnects and resends the opening request exactly once.A timeout on an already-warm connection, or any response after the first, is never
retried — that's a real failure. Check result.timedOut to tell a timeout apart
from a decline.
close() hard-closes the socket once the final Dispose is flushed. A graceful
half-close is not enough: the terminal never sends FIN back, so the connection would
park in FIN-WAIT-2 and the open handle would keep Node's event loop alive — a
script that had finished its work would never exit.
Iso8583Message — setStr/setBin/getStr/getBin, packWithMac(), Iso8583Message.unpack()computeMac(body) / verifyMac(rawMessage) — the DE64 DES CBC-MACTcpTransport / SerialTransport — framing-aware channels behind the Transport
interface (connect, send, receive, clearInbox, close); createTransport(config)
builds eitherdesCbcEncrypt is internal; use computeMac for the MACTransport abstraction and are
selected by the transport option. Serial is COMx//dev/tty*, 8-N-1, default baud
19200 (see PROTOCOL.md §1) and uses the optional serialport package.