1. Before you start #
You need three things.
A terminal in ECR mode. Amwal’s TMS decides this, not your application. Ask whoever administers the terminal profile to set:
| Field | Value |
|---|---|
terminalMode | 1 (ECR-attached) |
ecrMode | 1 USB cable, 2 Wi-Fi, or 4 Web Service |
Until TMS sets terminalMode: 1, the terminal does not accept ECR requests. With ecrMode: 1 or 2 it listens on TCP port 9100; with ecrMode: 4 it does not listen on TCP — the till uses HTTPS to the Hub instead (see the table below).
ecrMode | Transport | SDK client |
|---|---|---|
2 Wi-Fi | TCP to terminal IP, port 9100 | EcrTerminal via EcrLink.Lan |
1 USB cable | Android Open Accessory over a cable, no IP | EcrTerminal via EcrLink.UsbCable and an EcrChannel |
4 Web Service | HTTPS to Amwal Hub | EcrWebServiceTerminal via EcrLink.WebService |
3 Bluetooth | Not served over TCP in this SDK | — |
The same network (LAN only). For Wi-Fi ECR the till and the terminal must be able to reach each other on the LAN. Web Service ECR only needs internet access from the till.
The terminal’s address. With ecrMode: 2 the terminal shows its IP and port under the card scheme logos on its idle screen. Read it off and store it against the terminal’s serial number.
Store terminals by serial number and look the address up when you send. A terminal that reconnects to Wi-Fi can come back on a different IP, and the serial is the thing that does not change.
Requirements: Java 17, Kotlin coroutines. #
2. Add the SDK #
The SDK is on Maven Central, so it resolves like any other public library — no private repository, no credentials.
// build.gradle.kts (or settings.gradle.kts, if you declare repositories there)
repositories {
mavenCentral()
}
dependencies {
implementation("com.amwal-pay:ecr-sdk:1.0.5")
}
Groovy:
repositories { mavenCentral() }
dependencies {
implementation 'com.amwal-pay:ecr-sdk:1.0.5'
}
Maven:
<dependency>
<groupId>com.amwal-pay</groupId>
<artifactId>ecr-sdk</artifactId>
<version>1.0.5</version>
</dependency>
The current version is listed at https://central.sonatype.com/artifact/com.amwal-pay/ecr-sdk. Pin it — do
not use a version range. Published versions never change, so a fix arrives as a new version and you decide when to take it; a range would let a payment client change behaviour without a commit on your side.
kotlinx.serialization is an implementation detail; it is not on the public API, so it cannot clash with your own JSON library. Coroutines are on the public API and come in transitively — you do not need to declare them.
3. Address a terminal #
The SDK separates how you reach a terminal (EcrLink) from credentials and till settings (EcrConfig).
Use EcrSessions.plan to validate both before you send — it returns issues rather than throwing, so your UI can show what is missing.
LAN / Wi‑Fi ECR #
val plan = EcrSessions.plan(
link = EcrLink.Lan(host = "192.168.1.50", port = 9100),
config = EcrConfig(
secureHashKey = secrets.lanKeyFor(terminal.serialNumber),
ecrId = "TILL-04",
),
rawSecureHashKey = secrets.lanKeyFor(terminal.serialNumber),
)
if (!plan.isReady) {
screen.show(plan.issues.joinToString("\n"))
return
}
val terminal = EcrSessions.lanTerminal(
terminalSerial = "P653200085189",
plan = plan,
logger = EcrLogger { Log.d("Ecr", it) },
)
One instance addresses one terminal. It holds no connection between calls, so it is cheap to keep and safe to reuse for the life of your application.
Probe before you pay. probeReachability() opens a TCP handshake and returns whether the listener answered, plus the underlying error when it did not:
when (val probe = terminal.probeReachability()) {
probe.reachable -> proceed()
else -> screen.show("${probe.host}:${probe.port} — ${probe.error ?: "not reachable"}")
}
isReachable() is a convenience wrapper around probe.reachable.
Everything else has a working default. To change the till’s identity, the currency, or the timeouts, pass them in EcrConfig:
EcrConfig(
ecrId = "TILL-04", // appears on the terminal's records
currencyCode = "512", // ISO 4217 numeric — 512 is OMR
minorUnitDigits = 3, // decimal places the currency has
port = 9100,
responseTimeout = 120.seconds,
)
minorUnitDigitsmust match your currency. It decides where the decimal point falls when the amount goes on the wire. Setting840(USD) while leaving3sends every amount ten times too large, and neither the SDK nor the terminal will object. SeeEcrConfig.
What the app owns vs what the SDK owns #
| Your application | SDK |
|---|---|
| Terminal registry (serial, IP, mode, merchant/terminal IDs) | Transport choice, signing, wire format |
| Secure hash keys (from Amwal, per mode) | Validation via EcrSessions.plan() |
| UI and credential storage | Hub URLs (EcrEnvironment), REST paths |
Mapping stored records → EcrLink | Building EcrTerminal / EcrWebServiceTerminal |
Do not duplicate transport rules in the app — pass a link and config, let EcrSessions validate and assemble the client.
4. Web Service ECR #
When TMS sets ecrMode: 4, the till talks to the Amwal Hub over HTTPS instead of TCP to the terminal’s IP. The SDK resolves Hub URLs from EcrEnvironment; your app only picks SIT, UAT, or PROD and supplies backend IDs plus the Web Service secure hash key — which is not the same secret as LAN ECR.
val plan = EcrSessions.plan(
link = EcrLink.WebService(
merchantId = "13593",
terminalId = "101311",
),
config = EcrConfig(
secureHashKey = secrets.webServiceKey,
environment = EcrEnvironment.UAT,
),
rawSecureHashKey = secrets.webServiceKey,
)
if (!plan.isReady) return screen.show(plan.issues.joinToString("\n"))
val terminal = EcrSessions.webServiceTerminal(
terminalSerial = "P653200008251-Web",
plan = plan,
logger = EcrLogger { Log.d("EcrWebService", it) },
)
when (val result = terminal.sale(BigDecimal("1.234"))) {
is EcrResult.Approved -> completeSale(result)
is EcrResult.Declined -> screen.show(result.reason)
is EcrResult.Failed -> handleUnknownOutcome(result.failure)
}
Web Service signing uses field secureHashValue and the same sorted key=value&… payload as LAN ECR, but under the Web Service key Amwal issued for REST. Using the LAN key produces valid-looking code that the Hub rejects.
Receipt fetch is LAN-only today — use Web Service for sale, void, refund, and inquiry by receipt number (STAN + date).
LAN vs Web Service — what the SDK supports today #
| Capability | LAN (EcrTerminal) | Web Service (EcrWebServiceTerminal) |
|---|---|---|
| Sale / void / refund | Yes | Yes |
| Inquiry by receipt (STAN + date) | Yes | Yes |
| Inquiry by merchant reference | Yes — inquireByReference() | Not wired — method exists but does not send originalMerchantReference on the wire |
| Receipt (e-receipt URL) | Yes — receipt() | No |
originalTerminalId (other till) | Yes — void/refund/inquiry/receipt | No — not on the public API or JSON body |
| Reachability probe | Yes — probeReachability() | No — use Hub connectivity instead |
| Auto-inquire after unknown failure | Yes — EcrConfig.autoInquireOnFailure (default true) | No |
| Request signing field | secureHash + nonce | secureHashValue (no nonce) |
| Response signature check | Yes (LAN) | No — Hub JSON envelope only |
Use separate signing keys per row. EcrSessions.plan validates the right credentials for each mode before you send.
5. Your first sale #
suspend fun takePayment(amount: BigDecimal) {
if (!terminal.isReachable()) {
screen.show("Terminal not reachable — check it is on and on this network")
return
}
when (val result = terminal.sale(amount)) {
is EcrResult.Approved -> {
receipt.print(amount = result.amount, rrn = result.rrn, auth = result.authCode)
}
is EcrResult.Declined -> screen.show(result.reason)
is EcrResult.Failed -> screen.show(result.failure.message)
}
}
sale suspends while the cardholder pays. That is normally a few seconds and can be ninety; keep your progress UI up and do not impose a shorter timeout of your own.
Use BigDecimal, never Double. 0.1 + 0.2 is not 0.3 in binary floating point, and this is money.
terminal.sale(BigDecimal("1.234")) // correct
terminal.sale(BigDecimal(1.234)) // wrong — the Double is already imprecise
Check first (LAN only). isReachable() and probeReachability() apply to EcrTerminal TCP connections only — not Web Service ECR. They turn a wrong address or a terminal on another network into an immediate answer rather than a wait the cardholder sits through. They prove the port is open, not that the terminal is idle — a terminal already taking a payment answers the probe and then rejects the request as busy.
when (val probe = terminal.probeReachability()) {
probe.reachable -> proceed()
else -> screen.show("${probe.host}:${probe.port} — ${probe.error ?: "not reachable"}")
}
6. Handling the three outcomes #
EcrResult has three cases and the difference between the last two is the whole game:
| Meaning | What a till should do | |
|---|---|---|
Approved | Money was taken | Complete the sale |
Declined | The terminal answered and refused | Ask for another payment method |
Failed | Nothing was learned | Do not retry blindly — see §9 |
when (val result = terminal.sale(amount)) {
is EcrResult.Approved -> {
result.amount // "1.234" — what was actually taken, major units
result.responseCode // "00"
result.rrn // retrieval reference number
result.authCode // authorisation code; placeholder on a void
result.maskedPan // "543173xxxx5785" — may be empty
result.partialApproval // see §7
result.raw // the terminal's full answer, as JSON text
}
is EcrResult.Declined -> {
result.responseCode
result.reason
result.nextStep // NONE, or INQUIRE_BY_MERCHANT_REFERENCE
result.raw
}
is EcrResult.Failed -> {
result.failure
result.recovered // LAN auto-inquiry finding, when configured
result.nextStep // always INQUIRE_BY_MERCHANT_REFERENCE
result.settled // true when recovered is Found
}
}
result.raw is the terminal’s answer verbatim, as text. It is there so you never lose a field the SDK does not surface — log it with the transaction.
Do not decide from responseCode. A partial approval that the operator then voids carries the bank’s own 00. EcrResult already accounts for this: an outcome that is not an approval never arrives as Approved.
Signing what you send #
Anything on the shop network can reach the terminal’s port. Signing is what separates your till from everything else on that network.
Amwal issues secrets per terminal and per transport. Put them in the config and the SDK signs every LAN request and verifies LAN responses (when a key is configured). Web Service requests are signed; Hub responses are read from the JSON envelope without a local signature check.
// LAN ECR — field secureHash, Wi‑Fi and USB cable key
val lanPlan = EcrSessions.plan(
link = EcrLink.Lan(host = terminal.ipAddress),
config = EcrConfig(secureHashKey = secrets.lanKeyFor(terminal.serialNumber)),
rawSecureHashKey = secrets.lanKeyFor(terminal.serialNumber),
)
// Web Service ECR — field secureHashValue, REST key (different secret)
val wsPlan = EcrSessions.plan(
link = EcrLink.WebService(
merchantId = terminal.merchantId,
terminalId = terminal.terminalId,
),
config = EcrConfig(
secureHashKey = secrets.webServiceKey,
environment = EcrEnvironment.UAT,
),
rawSecureHashKey = secrets.webServiceKey,
)
Both transports use the same algorithm — HMAC-SHA256 over a sorted key=value&… string, key decoded from hex — but different field names and different secrets. Using the LAN key against the Hub (or the Web Service key over TCP) fails with authentication errors that look like network problems.
Validate keys with EcrConfig.isValidSecureHashKey() in your settings UI before persisting them.
Three things signing buys you, and one it does not:
- Nothing on the network can issue a refund on your terminal
- Nothing can change an amount in flight
- Nothing can replay a captured sale, and nothing can fake an approval
back to your till - It does not hide amounts from a passive listener — the traffic is
authenticated, not encrypted. There is no card number in it to protect
Treat keys like passwords: not in your repository, not in a log, and not shared between customers. If one leaks, ask Amwal to rotate it.
Until a terminal has been issued a key it accepts unsigned messages, so you can configure this before or after Amwal switches it on without a flag day.
If every call comes back Failure.Unauthenticated, the key here and the key on the terminal (or Hub) do not match — and the wrong key type for the mode is the first thing to check.
Naming the transaction as your system does #
Every outcome carries a merchantReference, and every operation takes one.
If your system already numbers what it sells — an order number, a basket id, a ticket number — pass that, and the same string identifies the transaction in your books, in the terminal’s records and in any later inquiry. There is nothing to map and nothing to reconcile by timestamp:
val result = terminal.sale(amount, merchantReference = order.number)
// result.merchantReference == order.number
Leave it out and the SDK generates one, and reports it back on the result. Store it with the sale either way — it is what names the transaction afterwards:
val result = terminal.sale(amount)
orders.record(order.id, reference = result.merchantReference)
The reference is 1–32 characters of printable ASCII, without spaces and without & or =. Anything else throws IllegalArgumentException before a connection is opened, so a bad reference fails at the till rather than at the terminal.
Uniqueness is yours to decide: the terminal carries the reference and does not check it. Reusing one is allowed and is occasionally what you want — a retry of the same order — but two genuinely different sales sharing a reference will be indistinguishable in your own records later.
7. Partial approval #
The bank may authorise less than was asked for. That is an approval, not a refusal, and the customer still owes the difference:
is EcrResult.Approved -> {
if (result.partialApproval) {
val short = result.requestedAmount.toBigDecimal() - result.amount.toBigDecimal()
screen.show("Only ${result.amount} was approved. Collect $short by other means.")
// Do not release the goods until the balance is settled.
} else {
completeSale(result.amount)
}
}
amount is always what was actually taken. Ignoring partialApproval means handing over goods for money you did not receive.
8. Void, refund, inquiry, receipt #
Void — cancel a transaction in full #
val result = terminal.void(receiptNumber = "215")
No amount: a void returns exactly what the original took. No card is presented for a transaction taken on the same terminal, so it completes in about a second.
Voids can be refused before anything reaches the backend — the original was not found, it is outside the void window TMS configured, or it cannot be voided (usually because it already was). Those arrive as Declined with the reason.
Do not print authCode on a void. It reverses an authorisation rather than making one, so the terminal returns a placeholder; printing it reads as an approval that never happened.
Refund — return money against an earlier transaction #
val result = terminal.refund(
amount = BigDecimal("0.500"),
receiptNumber = "208",
transactionDate = "20260809", // yyyyMMdd
)
The date is required because a receipt number is only unique within a terminal’s day. The cardholder must present their card to receive the money.
Whether the refund is allowed, and for how much, is the backend’s decision. Do not pre-validate it in your till; send it and report the answer.
Inquiry — ask what became of a transaction #
when (val inquiry = terminal.inquire(receiptNumber = "208", transactionDate = "20260809")) {
is EcrInquiry.Found -> {
val txn = inquiry.transaction
screen.show("${txn.type} ${txn.status} ${txn.amount} ${txn.currency}")
if (txn.canVoid) enableVoidButton()
}
is EcrInquiry.NotFound -> screen.show(inquiry.reason)
is EcrInquiry.Failed -> screen.show(inquiry.failure.message)
}
status is the transaction’s outcome, not the inquiry’s — finding a declined sale is a successful inquiry. canVoid and canRefund have already had the void window and the backend’s rules applied.
Safe to repeat, and answered even while the terminal is taking a payment.
Inquiry by merchant reference (LAN only). After a lost sale, you often have the merchantReference but not yet a receipt number:
when (val inquiry = terminal.inquireByReference(
originalReference = order.number,
transactionDate = "20260809", // optional on LAN; narrows the search
)) {
is EcrInquiry.Found -> reconcile(inquiry.transaction)
is EcrInquiry.NotFound -> // safe to retry the sale
is EcrInquiry.Failed -> escalate()
}
On Web Service, use inquire(receiptNumber, transactionDate) with a STAN until reference lookup is wired on the Hub JSON body.
Receipt — the e-receipt as a QR code (LAN only) #
// EcrTerminal only — not available on EcrWebServiceTerminal
when (val receipt = terminal.receipt(receiptNumber = "215", transactionDate = "20260809")) {
is EcrReceipt.Ready -> screen.showQrCode(receipt.url)
is EcrReceipt.Unavailable -> screen.show(receipt.reason)
is EcrReceipt.Failed -> screen.show(receipt.failure.message)
}
The customer scans the code and reads the receipt on their own phone. Render the QR locally — it then appears the moment the URL arrives and survives the network dropping afterwards.
Acting on another terminal’s transaction (LAN only) #
On LAN ECR, a void, refund, inquiry or receipt can target a transaction taken on a different till. EcrWebServiceTerminal does not expose originalTerminalId today.
terminal.void(receiptNumber = "215", originalTerminalId = "31629")
Leave it out and the terminal assumes its own.
9. When the answer never comes #
This is the most important section in this guide.
is EcrResult.Failed -> when (val failure = result.failure) {
is Failure.Unreachable -> // nothing is listening: safe, nothing happened
is Failure.Timeout -> // UNKNOWN — the payment may have completed
is Failure.ConnectionLost -> // UNKNOWN — the payment may have completed
is Failure.Malformed -> // an answer the SDK could not read
}
Unreachable is safe: the connection was refused, so nothing was sent. The other three are unknown outcomes. The card flow on the terminal does not depend on the socket staying up, so the terminal may have taken the money and simply been unable to say so.
Never treat an unknown outcome as a decline. Resolve it:
suspend fun resolveUnknownOutcome(receiptNumber: String, date: String): Boolean {
// Safe to call as often as you like, and answered even mid-transaction.
return when (val inquiry = terminal.inquire(receiptNumber, date)) {
is EcrInquiry.Found -> inquiry.transaction.status.equals("Approved", true)
is EcrInquiry.NotFound -> false // it never happened; retrying is safe
is EcrInquiry.Failed -> {
escalateToOperator() // still unknown — a human must look
false
}
}
}
With EcrConfig.autoInquireOnFailure = true (the default, LAN only), EcrTerminal follows an unknown failure with inquireByReference() using the sale’s merchantReference and attaches the
finding to EcrResult.Failed.recovered. Check result.recovered and result.settled before prompting the operator to inquire manually. EcrWebServiceTerminal does not auto-inquire.
When you resolve manually:
- By reference (after a lost sale where you stored
merchantReference) —inquireByReference()on LAN only today. - By receipt number —
inquire(receiptNumber, transactionDate)on either
transport, when you already have the STAN from a partial answer or the terminal
display.
Retrying a sale blind is how a customer gets charged twice. Reconcile against the terminal’s batch if both routes still leave the outcome unknown.
10. Building your own form #
EcrTransactionType declares what each operation needs, so drive your UI from it instead of repeating the rules:
val type = EcrTransactionType.REFUND
if (type.requiresAmount) showAmountField()
if (type.requiresOriginalStan) showReceiptNumberField()
if (type.requiresOriginalDate) showDateField()
if (type.allowsOtherTerminal) showOtherTerminalSwitch()
terminal.run(type, amount = amount, originalStan = receipt, originalDate = date)
requiresAmount | requiresOriginalStan | requiresOriginalDate | movesMoney | |
|---|---|---|---|---|
SALE | ✔ | ✔ | ||
VOID | ✔ | ✔ | ||
REFUND | ✔ | ✔ | ✔ | ✔ |
INQUIRY | ✔ | ✔ | ||
RECEIPT | ✔ | ✔ |
run() accepts only the types that move money. INQUIRY and RECEIPT go through inquire() and receipt(), because they answer with something they found rather than something they performed — conflating the two is how a lookup gets booked as a payment.
EcrTransactionType.menuOptions is the subset an operator picks from: sale, void, refund, inquiry. RECEIPT is absent by design — a receipt follows a transaction already on screen, so it belongs on that result rather than in a list of operations.
Validate only what you can answer. Whether an amount is within the terminal’s limits, whether a transaction may be voided, whether a refund is allowed — all of that needs the payment backend and the terminal’s TMS configuration. Send the request and report the answer.
11. Threading and lifecycle #
Every call is a suspend function and does its blocking I/O on Dispatchers.IO. You do not need a thread of your own.
class TillViewModel : ViewModel() {
private val terminal = EcrTerminal(host = "192.168.1.50", serialNumber = "P653…")
fun pay(amount: BigDecimal) {
viewModelScope.launch {
val result = terminal.sale(amount) // suspends; does not block the UI
_state.value = result
}
}
}
Cancellation stops the SDK waiting; it does not stop the transaction. If your screen closes mid-payment the terminal carries on with the cardholder. Treat a cancelled call exactly like Failure.Timeout — an unknown outcome.
Hold the in-flight call somewhere that survives a configuration change (a ViewModel, not an Activity), or a rotation mid-payment loses the result.
Pass your own dispatcher when you need to control it, for instance in tests:
EcrTerminal(host = "…", serialNumber = "…", io = testDispatcher)
12. Logging #
Silent by default — the SDK never writes to a log you did not choose.
// LAN ECR — Logcat tag "Ecr" in the sample app
EcrSessions.lanTerminal(
terminalSerial = "P653200085189",
plan = lanPlan,
logger = EcrLogger { message -> Log.d("Ecr", message) },
)
// Web Service ECR — separate tag so mixed logs stay readable
EcrSessions.webServiceTerminal(
terminalSerial = "P653200008251-Web",
plan = wsPlan,
logger = EcrLogger { message -> Log.d("EcrWebService", message) },
)
When signing is enabled, diagnostic output includes:
- The formula:
HMAC-SHA256(sorted key=value payload, Hex.parse(secret)) → uppercase hex - A fingerprint of the key (length and first/last four hex characters) — never the full secret
- The payload string being signed (excluding the hash field itself)
- The computed hash value
Request and response payloads may contain masked PANs and transaction references. They are safe from a PCI perspective but are still transaction records — store them accordingly and never forward them to analytics or crash reporters unchanged.
EcrConfig.toString() redacts secureHashKey as <redacted> if you log config objects during debugging.
13. Testing without hardware #
A stand-in listener that speaks the protocol is published alongside the SDK:
curl -O https://raw.githubusercontent.com/amwal-pay/ECR-simulator/main/tools/fake_pos_server.py
python fake_pos_server.py --port 9100 # approves everything
python fake_pos_server.py --port 9100 --decline 51 # declines everything
python fake_pos_server.py --port 9100 --delay 8 # slow cardholder
python fake_pos_server.py --port 9100 --not-found # inquiries answer 25
Register a terminal in your till pointing at your machine’s LAN IP and that port.
--delay is the case worth testing and the one most often skipped: what your UI does while it waits, and what happens when the operator walks away mid-sale.
Also test, before you ship:
- Terminal switched off →
Failure.Unreachable - Wi-Fi dropped mid-sale (turn the machine’s Wi-Fi off after sending) →
Failure.ConnectionLost, and confirm you do not book it as a decline - A second request while one is running →
96, terminal busy - A void of an unknown receipt number →
25
14. Going to production #
A checklist worth walking before the first live terminal:
minorUnitDigitsmatches the currency — check an amount on the wire- Amounts are
BigDecimalend to end, neverDouble approveddecides the outcome, notresponseCodepartialApprovalis handled, and goods are held back for the balanceTimeout/ConnectionLost/Unauthenticatedare never treated as declines- On LAN, check
EcrResult.Failed.recovered/settledbefore retrying a sale - LAN and Web Service keys are stored separately and mapped to the correct mode
secureHashKeycomes from configuration rather than source code- Keys are not written to any log, crash report or analytics event
EcrSessions.plan()is used (or equivalent validation) before sendingauthCodeis not printed on a void- Terminals are stored by serial number, address looked up at send time
result.rawis logged with each transaction for dispute resolutionresult.merchantReferenceis stored with the sale, generated or your own- The in-flight call survives a rotation or a backgrounded app
- TMS has the correct
terminalModeandecrModeon every terminal - Someone other than the developer has run the failure cases in §13
