com.amwal-pay:ecr-sdk drives an Amwal POS terminal from a Kotlin or Android till: sale, void, refund, inquiry, e-receipt, close receipt, settlement, and sign-on. Wi-Fi and USB use the terminal’s ECR port. App to app hands the request to the payment app on the same terminal. Web Service uses HTTPS to the Amwal Hub. Card data stays on the terminal.
Package com.amwal-pay:ecr-sdk 1.0.11. Open one session, call the operation, then read the result.
Install #
- Kotlin/JVM 17 or later, with coroutines. Every operation is a
suspendfunction. - Apache-2.0.
dependencies {
implementation("com.amwal-pay:ecr-sdk:1.0.11")
}
dependencies {
implementation 'com.amwal-pay:ecr-sdk:1.0.11'
}
<dependency>
<groupId>com.amwal-pay</groupId>
<artifactId>ecr-sdk</artifactId>
<version>1.0.11</version>
</dependency>
Pin the version. A patch release can add API.
Open a session #
Open one session for the terminal and keep it, every sale, void, refund, inquiry, e-receipt, close receipt, and settlement can use it. Each call connects for that operation only, then disconnects.
Wi-Fi implementation #
Each terminal shows its IP and port on the idle screen. Use that IP and port to connect from the till and send operations to that terminal. The till and the terminal must be on the same Wi-Fi.
import com.amwalpay.ecr.*
val plan = EcrSessions.plan(
link = EcrLink.Lan(host = "192.168.1.50"), // port defaults to 9100
config = EcrConfig(secureHashKey = wifiKey),
)
if (!plan.isReady) {
show(plan.issues.joinToString("; "))
return
}
val session = EcrSessions.open(terminalSerial = "P653200085189", plan = plan)
Web Service implementation #
val plan = EcrSessions.plan(
link = EcrLink.WebService(merchantId = "13593", terminalId = "101311"),
config = EcrConfig(
secureHashKey = webServiceKey,
environment = EcrEnvironment.UAT, // SIT is the default; PROD for live
),
)
val session = EcrSessions.open(terminalSerial = "P653200008251", plan = plan)
Hub addresses stay inside the SDK. EcrEnvironment.fromName reads SIT, UAT, and PROD, ignoring case. Any other name reads as SIT. Merchant id and terminal id must be numeric.
USB cable implementation #
There is no IP address. The terminal profile is USB cable, and the signing key is the Wi-Fi key. The till is the USB host and the terminal is the accessory. Your application supplies the channel, because the SDK cannot open a USB device on its own: implement EcrChannel over the connection your platform gives you. The bytes are the same framed messages as Wi-Fi.
val plan = EcrSessions.plan(
link = EcrLink.UsbCable,
config = EcrConfig(secureHashKey = wifiKey),
)
if (!plan.isReady) {
show(plan.issues.joinToString("; "))
return
}
val session = EcrSessions.open(
terminalSerial = "P653200085189",
plan = plan,
usbChannel = { myUsbChannel },
)
open requires usbChannel for a cable plan. A cable session uses the same sale, void, refund, inquiry, e-receipt, close receipt, and settlement calls as Wi-Fi.
App to app implementation #
Use app to app when the till is installed on the terminal itself. There is no address to configure. The payment application id is fixed as com.amwalpay.pos (EcrLink.PAYMENT_APP). Your application supplies the channel, because the SDK cannot start the payment app or wait for its result. The signing key is the Wi-Fi key. After the channel is open, the operations follow the same rules as Wi-Fi.
On Android 11 and above the till must declare that it can see the payment app. Without this, the payment app looks missing even when it is installed.
<queries>
<package android:name="com.amwalpay.pos" />
<intent>
<action android:name="com.amwalpay.pos.ecr.TRANSACTION" />
</intent>
<intent>
<action android:name="com.amwalpay.pos.ecr.PRINT" />
</intent>
</queries>
val plan = EcrSessions.plan(
link = EcrLink.PaymentApp,
config = EcrConfig(secureHashKey = wifiKey),
)
val session = EcrSessions.open(
terminalSerial = "P653200085189",
plan = plan,
paymentAppChannel = { myPaymentAppChannel },
)
open requires paymentAppChannel for an app-to-app plan.
Print #
Print is a second handover, after the cashier asks for a receipt. The till draws the receipt as a PNG. The image is 384 pixels wide, any height, and at most 15 MB. Send one print at a time.
The payment app prints that image and answers with two separate fields: amwal.ecr.printStatus is SUCCESSFUL or FAILED, and amwal.ecr.printMessage is the text to show the cashier. Wi-Fi, USB, and Web Service do not use this intent.
val intent = Intent("com.amwalpay.pos.ecr.PRINT")
.setPackage(EcrLink.PAYMENT_APP)
.putExtra("amwal.ecr.merchantReference", sale.merchantReference)
.putExtra("amwal.ecr.receiptImage", png)
activity.startActivityForResult(intent, PRINT_REQUEST)
val status = data?.getStringExtra("amwal.ecr.printStatus")
val message = data?.getStringExtra("amwal.ecr.printMessage").orEmpty()
Operations #
Amounts are BigDecimal in major units (BigDecimal("1.234")). Build them from text so the amount stays exact. merchantReference names this attempt. Save it before the call. Leave it blank and the SDK generates one and returns it on the result.
Sale #
The cardholder presents the card on the terminal.
val reference = "ORDER-1001-1"
save(reference)
val result = session.sale(amount = BigDecimal("1.234"), merchantReference = reference)
Read result with the when in Read the result.
Void #
Cancels an earlier transaction for its full amount. No card. receiptNumber is the receipt number (STAN). originalTerminalId names another terminal when the original was not taken on this one. Web Service ignores it and uses the terminal in the plan. The void has its own merchant reference.
val result = session.void(
receiptNumber = stan,
originalTerminalId = "",
merchantReference = "ORDER-1001-V1",
)
Refund #
Returns some or all of an earlier transaction. The cardholder presents the card. transactionDate is the original day, yyyyMMdd.
val result = session.refund(
amount = BigDecimal("1.000"),
receiptNumber = stan,
transactionDate = "20261019",
originalTerminalId = "",
merchantReference = "ORDER-1001-R1",
)
Inquiry by receipt number #
Reads a transaction. No card, no money. Safe to repeat, and the terminal answers even while another payment is in progress.
val inquiry = session.inquire(
receiptNumber = stan,
transactionDate = "20261019",
)
Inquiry by merchant reference #
Reads the attempt you named originalReference. Use this when the sale, void, or refund never answered: you still have the reference you sent, and you do not yet have a receipt number. transactionDate is optional. On Wi-Fi, USB, and app to app, originalTerminalId names another terminal. merchantReference names the lookup itself; leave it blank and the SDK assigns one. A blank originalReference throws IllegalArgumentException.
val inquiry = session.inquireByReference(originalReference = "ORDER-1001-1")
E-receipt #
Wi-Fi, USB, and app to app. Returns a URL for a QR code. Safe to repeat. Ready carries url. Unavailable means there is no receipt to show. Failed means the exchange failed; repeat the call.
val receipt = session.receipt(
receiptNumber = stan,
transactionDate = "20261019",
)
if (receipt is EcrReceipt.Ready) showQr(receipt.url)
On Web Service the same call returns EcrReceipt.Unavailable without sending anything. session.supportsReceipt is false there.
Close receipt #
Asks the terminal to leave the receipt screen and return to idle. Call it from the till’s Close receipt action when the cashier is finished. Sale, void, refund, and dismissing a dialog do not send it. Safe to repeat: Idle means the terminal is idle, whether a receipt was up or not.
val closed = session.closeReceipt(
merchantReference = "",
originalMerchantReference = "ORDER-1001-1",
)
originalMerchantReference is the sale, void, or refund you are dismissing. Web Service requires it; when you pass only merchantReference, the session uses it as the original. Wi-Fi, USB, and app to app ignore it. Both arguments are strings, so pass them by name.
| Call | Sale’s reference |
|---|---|
EcrOpenedSession.closeReceipt(merchantReference, originalMerchantReference) | Second argument |
EcrWebServiceTerminal.closeReceipt(originalMerchantReference, merchantReference) | First argument |
EcrTerminal.closeReceipt(merchantReference) | None. Wi-Fi, USB, and app to app only. |
when (closed) {
is EcrReceiptClosed.Idle -> Unit
is EcrReceiptClosed.Refused -> show(closed.reason) // firmware has no CLOSE_RECEIPT; leave the receipt up
is EcrReceiptClosed.Failed -> show(closed.failure.message) // nothing changed; the call can be repeated
}
Settlement #
Closes the current batch, the same Manual settlement as the terminal’s merchant menu. Wi-Fi, USB, and app to app send a SETTLEMENT message on the same session as a sale. Confirm with the cashier first: the terminal does not ask, and the close starts as soon as the request arrives. The terminal must be idle. Uses the full response timeout, because a close can wait on pending reversals.
The terminal shows the summary, prints the settlement receipt when its profile prints receipts, and puts the summary away after 15 seconds. Safe to repeat when the answer is lost: a batch that closed answers NothingToSettle the next time, and one that did not settles on the retry.
The terminal also settles over Web Service: the Hub relays /Ecr/settle to it. This SDK does not send that request yet, so supportsSettlement is false on a Web Service session and the call returns NotSettled without sending anything.
when (val settlement = session.settlement()) {
is EcrSettlement.Settled ->
show(settlement.batchId, settlement.netAmount, settlement.totals)
is EcrSettlement.NothingToSettle ->
show(settlement.message)
is EcrSettlement.NotSettled ->
show(settlement.reason)
is EcrSettlement.Failed ->
show(settlement.failure.message) // the batch may or may not have closed
}
Amounts in Settled are major units. totals always has SALE, REFUND, VOID, PRE_AUTH, and CAPTURE, each an EcrSettlementTotal(count, amount), with a zero row where the terminal omitted one. closeReceipt puts the terminal’s settlement summary away sooner; it is the same call as after a sale.
Sign-on #
Wi-Fi, USB, and app to app. Asks the terminal what it will accept before you offer operations to the cashier. Available carries capabilities: the transport, currency, whether e-receipt and physical receipt are on, and the permitted operations with their limits.
when (val signOn = session.signOn()) {
is EcrSignOn.Available -> {
val canRefund = signOn.capabilities.permits(EcrTransactionType.REFUND)
val limits = signOn.capabilities.limitsFor(EcrTransactionType.SALE)
}
is EcrSignOn.Unavailable -> show(signOn.reason)
is EcrSignOn.Failed -> show(signOn.failure.message)
}
On Web Service the call returns Unavailable without sending anything. session.supportsSignOn is false there.
Reachability #
Wi-Fi, USB, and app to app, before a sale. Proves the port or channel answers. It does not prove the terminal is idle.
val probe = session.probeReachability() if (probe != null && !probe.reachable) show(probe.error)
probeTimeout is 3 seconds. probe.endpoint is the text to show the operator. Web Service returns null from probeReachability, so a sale is not held for a Hub probe.
Read the result #
sale, void, and refund return EcrResult.
when (result) {
is EcrResult.Approved -> {
record(result.merchantReference, result.amount, result.rrn, result.authCode)
if (result.partialApproval) collectBalance(result.requestedAmount, result.amount)
}
is EcrResult.Declined -> show(result.reason)
is EcrResult.Failed -> when {
result.settled -> apply((result.recovered as EcrInquiry.Found).transaction)
result.failure.outcomeUnknown -> markUnresolved(result.merchantReference)
else -> show(result.failure.message)
}
}
| Result | Meaning |
|---|---|
Approved | Money moved. amount is what was taken or returned. responseCode is 00 on a plain approval. maskedPan is masked. |
Approved with partialApproval | The issuer authorised less than you asked. requestedAmount is what you asked. Collect the difference before you hand over the goods. |
Declined | The terminal answered and refused. reason is the text to show. nextStep is NONE. |
Failed and Failure.Unreachable | Nothing was sent. Wi-Fi, USB, and app to app only. |
Failed and failure.outcomeUnknown | The request went out and the answer did not settle it: timeout, connection lost, malformed answer, or a signature that does not match. The payment may have completed. |
Failed.settled | Wi-Fi, USB, or app to app already inquired. recovered is EcrInquiry.Found. Record that transaction. |
IllegalArgumentException | The call was rejected before anything was sent. A merchant reference longer than 32 characters, or one that contains a space, &, or =, throws here. |
CancellationException | Your coroutine was cancelled while waiting. The terminal may still complete a request that already left. |
Failed.nextStep is always INQUIRE_BY_MERCHANT_REFERENCE. That includes a Web Service timeout, a lost HTTP call, an empty or unreadable body, and an HTTP 5xx with nothing readable. failure.outcomeUnknown is false only for Unreachable.
Inquiry #
| Result | Meaning |
|---|---|
Found | transaction is the record. status is the outcome (Approved, Declined, Voided, or other text from the backend). Compare it ignoring case. |
NotFound | This lookup returned no record. reason is the backend’s text when it sent any. |
Failed | The lookup itself failed. Repeat the inquiry. An inquiry does not move money. |
On Found, transaction.stan is the receipt number. canVoid and canRefund say whether the terminal would accept that operation now. When partialApproval is set, amount is what was asked and authorizedAmount is what was charged.
Important notes #
Resolve an unknown outcome #
failure.outcomeUnknown means the payment may have completed. Inquire by the merchant reference of that attempt. Charge the order again only after transaction.status is Declined, or after the terminal confirms the attempt did not complete.
- Save the reference before
sale,void, orrefund. - On Wi-Fi, USB, and app to app, read
Failed.recoveredfirst. WithautoInquireOnFailureleft on (the default), the SDK has already calledinquireByReferenceonce.EcrInquiry.Foundsettles the attempt.NotFound,Failed, or null leaves it unknown. Web Service setsrecoveredto null. - Call
inquireByReferencewith the saved reference. - Read
Found.transaction.status, ignoring case.Approved— the attempt completed. On a void or refund, the void or refund completed.Declined— it did not. A new attempt needs a new reference.- Any other value, including
VoidedandReversed— leave the order unresolved.
NotFoundafter a lost answer means this lookup returned no record. The same answer follows a terminal reset, or a record that has aged out. Confirm on the terminal, or with Amwal, before you take payment again.EcrInquiry.Failedmeans the lookup failed. Inquire again.- On startup, inquire every attempt still marked unresolved.
Failure.Unreachable means the connection was never made. There is no attempt to look up.
Merchant reference #
32 characters maximum, with space, &, and = excluded. The SDK trims the value. Use one reference per attempt; an order number plus an attempt suffix fits. A void and a refund each get a new reference.
Receipt number #
Approved does not carry it. Read transaction.stan from an inquiry by merchant reference, or read the printed receipt. The SDK sends the number padded to 6 digits. Pass that receipt’s day as yyyyMMdd to refund, inquiry by receipt number, and e-receipt. A receipt number is unique on one terminal for one day.
Amounts and currency #
currencyCode defaults to "512" and minorUnitDigits to 3 (OMR). Another currency needs both. minorUnitDigits accepts 0 to 4. The SDK sends minor units and reads amounts back in major units, as text.
Timeouts #
| Transport | Wait |
|---|---|
| Wi-Fi | connectTimeout 10 s, then responseTimeout 120 s. A lost answer can add one more response wait for the automatic inquiry. |
| USB and app to app | The till waits responseTimeout, 120 seconds, for the answer, the same as Wi-Fi. |
Web Service through plan | A responseTimeout of 120 s becomes EcrConfig.WEB_SERVICE_RESPONSE_TIMEOUT (130 s). Any other value is kept. |
EcrWebServiceTerminal(...) built by hand skips the 130 s raise. Set responseTimeout = EcrConfig.WEB_SERVICE_RESPONSE_TIMEOUT on that config.
The default wait covers card present and PIN. Cancelling the coroutine ends the wait in your process. A request that already reached the terminal can still complete.
Keys #
Even-length hex, at least 16 characters. Building an EcrConfig with an invalid key throws IllegalArgumentException. Pass operator-typed text as rawSecureHashKey to plan and the same problem is listed in issues.
open requires a ready plan, and a plan with no key is not ready. A terminal you build yourself with an empty key sends unsigned requests and skips the answer check.
Wi-Fi, USB, and app to app sign the field secureHash. Web Service signs secureHashValue. The two secrets are issued separately. plan.signingKeyLabel names the one this plan expects.
Logging #
val logger = EcrLogger { line -> Log.d("ECR", line) }
val session = EcrSessions.open(terminalSerial = serial, plan = plan, logger = logger)
Logging is off until you pass a logger. EcrLogger.None discards every line. An exception thrown by the logger escapes the call.
When you build the message yourself #
EcrTransactionType is SALE, VOID, REFUND, INQUIRY, RECEIPT, SIGN_ON, CLOSE_RECEIPT, SETTLEMENT. EcrTransactionType.menuOptions is the operator menu: sale, void, refund, inquiry. Close receipt, sign-on, and settlement are actions the till takes, not menu choices. requiresAmount, requiresOriginalStan, requiresOriginalDate, movesMoney, and allowsOtherTerminal say which fields that operation needs. EcrTerminal.run accepts only the three that move money.
EcrWebServiceSaleRequest, EcrWebServiceVoidRequest, EcrWebServiceRefundRequest, EcrWebServiceTransactionStatusRequest, and EcrWebServiceCloseReceiptRequest are for a caller that posts the JSON. EcrWebServiceRequestJson writes each body and signs it when you pass the key. The session methods do this themselves.
