Amwal’s Flutter package drives a POS terminal from an Android or iOS till through one Dart API. It is a typed bridge over the native Amwal ECR SDKs — the Kotlin ecr-sdk on Android and AmwalECR on iOS — and implements no payment protocol of its own, so a Flutter till and a native till behave identically against the same terminal.
Sale, void, refund, inquiry and e-receipt, with the same types, the same units and the same outcomes on both platforms. No card data passes through your application: you receive a masked PAN at most.
A failed exchange is not a decline. A timeout or a lost connection can happen after the terminal accepted the request and completed the payment. When outcomeIsUnknown is true, do not send the sale again — ask the terminal what happened.
Why this SDK #
A payments SDK is judged on what it does when things go wrong, so that is where this one starts.
An outcome you cannot misread #
EcrApproved, EcrDeclined and EcrFailed are separate types, so “the terminal refused” and “we never found out” cannot be confused. That confusion is the bug that charges a customer twice, and the type system will not let you write it.
switch (result) {
case EcrApproved(): // money moved
case EcrDeclined(): // the terminal said no
case EcrFailed(): // nobody knows yet — do not retry
}
It never retries a payment #
There is no retry setting, and no amount of configuration will add one. A request whose answer went missing may already have taken the money.
It recovers the answer for you #
When a reply goes missing, the package sends one inquiry using the reference the sale carried, and hands you what it found on EcrFailed.recovered. Most unknown outcomes are resolved before your code sees them.
A partial approval arrives as an approval #
When the bank authorises less than was asked for, you get the approval and both figures — so the goods are not released for money that never arrived.
case EcrApproved(:final bool partialApproval, :final String amount,
:final String requestedAmount):
if (partialApproval) {
showMessage('Only $amount of $requestedAmount was approved');
}
The answer is signed, not just the request #
Anything on a shop network can reply on port 9100. Every answer is checked against this terminal’s key and against the request it claims to answer, so nothing can claim an approval for a payment that never happened. A reply that fails either check is EcrUnauthenticated — unknown, never an approval.
Four transports, one API #
Wi-Fi, USB cable, Web Service, and the payment app on the terminal itself. The terminal’s TMS profile decides; your code changes one enum and nothing else.
Amounts are decimals, never floats #
EcrAmount throughout. 0.1 + 0.2 is not 0.3 in binary floating point, and this is money.
No card data reaches your app #
A masked PAN at most, so integrating does not pull your application into PCI scope the way handling card numbers would.
Installation #
Add the package to pubspec.yaml:
dependencies:
amwal_ecr: ^0.2.1
Or from the command line:
flutter pub add amwal_ecr
The native SDKs come with it. On Android it resolves com.amwal-pay:ecr-sdk from Maven Central; on iOS it resolves AmwalECR from CocoaPods trunk, or from Swift Package Manager when the project is built with flutter config --enable-swift-package-manager.
Requirements #
| Component | Minimum |
|---|---|
| Dart SDK | >=3.5.0 — the API uses sealed classes and pattern matching |
| Flutter | >=3.22.0 |
| Android | API 21 and above; JVM target 17 |
| iOS | 12.0 and above |
Android #
The plugin declares INTERNET itself. Nothing is required of the app.
iOS: local network permission #
Add to ios/Runner/Info.plist:
<key>NSLocalNetworkUsageDescription</key>
<string>Connects to the payment terminal to take card payments.</string>
iOS asks the user before an app may talk to devices on the local network, and a refusal is indistinguishable from a terminal that is switched off — every call answers EcrUnreachable. Treat a first-run EcrUnreachable on iOS as a permission question before a networking one.
Other platforms #
On macOS, Windows, Linux and Web every call answers EcrUnsupported rather than throwing MissingPluginException, so an app that runs somewhere unexpected degrades instead of crashing.
Terminal setup #
import 'package:amwal_ecr/amwal_ecr.dart';
final EcrOpenedSession session = EcrSessions.open(
host: '192.168.1.50',
serialNumber: 'P653200085189',
transport: EcrTransport.wifi,
config: EcrConfig(
ecrId: 'TILL-01',
currencyCode: '512',
minorUnitDigits: 3,
port: 9100,
connectTimeout: Duration(seconds: 10),
responseTimeout: Duration(seconds: 120),
probeTimeout: Duration(seconds: 3),
secureHashKey: secretFromSecureStorage,
autoInquireOnFailure: true,
),
);
final EcrTerminal terminal = session.terminal;
Prefer EcrSessions.open over building an EcrTerminal directly, so sale, inquiry and receipt all share one transport. An EcrTerminal holds no connection between calls, so it is cheap to build and safe to keep for the life of the app.
minorUnitDigits is the setting to get right: 3 for OMR, 2 for USD, 0 for JPY. Nothing cross-checks it against currencyCode, so a mismatch sends every amount off by a factor of ten and neither side objects.
The signing key is issued by Amwal per terminal. Keep it in the platform keystore (flutter_secure_storage), never in source control or an app bundle.
Common tasks #
Each task below is complete on its own.
Take a payment #
final EcrResult result = await terminal.sale(
EcrAmount.parse('1.234'),
merchantReference: order.number,
);
switch (result) {
case EcrApproved(:final String amount, :final String rrn):
completeSale(amount, rrn);
case EcrDeclined(:final String reason):
showMessage(reason);
case EcrFailed():
await reconcile(order.number); // never retry
}
The call takes as long as the cardholder takes. Ninety seconds is not unusual — show a spinner, not a countdown.
Cancel a transaction that has not been settled yet #
A void returns exactly what the original took. No amount, and no card for a transaction taken on the same terminal, so it completes in about a second.
final EcrResult result = await terminal.voidTransaction('215');
Refund one that has #
The cardholder presents their card to receive the money.
final EcrResult result = await terminal.refund(
EcrAmount.parse('0.216'),
receiptNumber: '208',
transactionDate: '20260809',
);
Whether a 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.
Find out what happened #
The one operation the terminal answers while it is busy, which is exactly when a till needs it.
switch (await terminal.inquire(
receiptNumber: '208',
transactionDate: '20260809',
)) {
case EcrInquiryFound(:final EcrTransaction transaction):
// Found is not paid — read the status.
showMessage('${transaction.status}: ${transaction.amount}');
voidButton.enabled = transaction.canVoid;
refundButton.enabled = transaction.canRefund && !transaction.isRefunded;
case EcrInquiryNotFound(:final String reason):
showMessage(reason);
case EcrInquiryFailed():
// Harmless — an inquiry changes nothing. Try again.
}
canVoid and canRefund already have the void window and the backend’s rules applied, so a till can enable its buttons from them rather than guessing.
Hand the customer a receipt without a printer #
switch (await terminal.receipt(
receiptNumber: '215',
transactionDate: '20260809',
)) {
case EcrReceiptReady(:final String url):
showQrCode(url); // the customer scans it and reads it on their phone
case EcrReceiptUnavailable(:final String reason):
showMessage(reason);
case EcrReceiptFailed():
// Safe to ask again.
}
Render the QR locally: it then appears the moment the URL arrives, and survives the network dropping afterwards — which is when a customer is standing at the counter waiting.
Check before you charge #
if (!await terminal.isReachable()) {
showMessage('Terminal not reachable');
return;
}
Turns a wrong address into an immediate answer instead of a wait the cardholder sits through. It proves the port is open, not that the terminal is free — a terminal already taking a payment answers a probe too.
Use probeReachability() when you want to show why:
final EcrReachability probe = await terminal.probeReachability();
if (!probe.reachable) {
showMessage('${probe.host}:${probe.port} — ${probe.error}');
}
Transports #
EcrTransport mirrors the ecrMode in the terminal’s TMS profile.
| EcrTransport | ecrMode | Android | iOS | Connection |
|---|---|---|---|---|
usbCable | 1 | Yes | No | USB cable; carries no IP address |
wifi | 2 | Yes | Yes | TCP to the terminal, port 9100 |
bluetooth | 3 | No | No | Defined in TMS, not served |
webService | 4 | Yes | Yes | HTTPS to the Amwal Hub |
appToApp | 5 | Yes | No | The Amwal payment app on this same device |
A terminal does not accept ECR requests until its TMS profile carries terminalMode 1. On a transport a platform cannot serve, every operation answers EcrUnsupported immediately without opening anything.
When your till runs on the terminal (app-to-app) #
appToApp is the one transport with no address, because there is nothing between the two ends: your app hands the transaction to the Amwal payment app installed beside it and waits for the answer.
final EcrTerminal terminal = EcrTerminal.appToApp(
serialNumber: 'P653200085189',
config: EcrConfig(ecrId: 'TILL7', currencyCode: '512', minorUnitDigits: 3),
);
final EcrResult result = await terminal.sale(
EcrAmount.parse('10.500'),
merchantReference: 'ORDER-91',
);
Everything else on this page is unchanged — the same operations, outcomes and response codes. What you do differently, in order:
- Nothing in your manifest. The package declares the payment app in
<queries>, and the manifest merge gives that to your app. - Where you register a terminal, offer ecrMode 5 and hide the IP and port fields. There is nothing to put in their place — which application takes a payment is fixed. The serial number still has to be the terminal the payment app is provisioned as.
- Open it with
EcrTerminal.appToApp(serialNumber: …), or passEcrTransport.appToAppwithEcrPaymentApp.packageNamewhere an address would go. - Call
probeReachability()before you take an amount, if you like: it launches nothing and answers whether the payment app is there and will accept a request. - Run the sale. The payment app comes to the front, takes the card, and steps back when the operator has finished with the receipt.
What is specific to this transport:
- Always send a
merchantReference, because either app can be destroyed mid-payment and it is the only handle left to ask what happened. - The merchant must be signed in on the payment app, and is told so plainly if not.
- Fetching an e-receipt works but costs another handover, so print from the answer where you can.
Operations not sent over app-to-app #
signOn() and closeReceipt() answer EcrUnsupported here without launching anything, and neither refusal is about capability — the terminal answers both perfectly well over a socket.
- A sign-on would spend a handover, opening the payment app the moment an operator picks a terminal, to learn what the next refusal carries anyway: a request the profile does not permit comes back with the current profile attached.
- A close-receipt asks for something that has already happened. The answer to an app-to-app transaction is held until the operator closes the receipt — that is what the handover is for — so by the time your till has a result the terminal is back on its idle screen.
Both are one more EcrUnsupported outcome, which your till already handles, so there is nothing mode-specific to write.
Supported operations #
Amounts #
Amounts are EcrAmount, never double — a binary float cannot hold 1.234, and an amount off by a thousandth of a rial reconciles against nothing.
EcrAmount.parse('1.234'); // from a text field
EcrAmount.tryParse(controller.text); // null while half-typed
EcrAmount.fromMinorUnits(1234, minorUnitDigits: 3); // 1.234
Operations #
| Method | What it does |
|---|---|
isReachable() | Whether the terminal is answering |
probeReachability() | The same probe, plus the underlying error when it fails |
sale(amount, merchantReference: …) | Takes a payment; the cardholder presents their card |
voidTransaction(receiptNumber, …) | Cancels an earlier transaction in full. No card, no amount |
refund(amount, receiptNumber: …, transactionDate: …) | Returns money against an earlier transaction |
inquire(receiptNumber: …, transactionDate: …) | What became of an earlier transaction. Reads only |
inquireByReference(originalReference, …) | The same question, by the reference you sent |
signOn() | What the terminal is and what it will accept. Reads only |
closeReceipt() | Tells the terminal the cashier has finished with the sale |
receipt(receiptNumber: …, transactionDate: …) | The e-receipt as a URL, to show as a QR code |
final EcrResult result = await terminal.sale(
EcrAmount.parse('1.234'),
merchantReference: 'ORDER-1001',
);
final EcrResult refund = await terminal.refund(
EcrAmount.parse('0.216'),
receiptNumber: '208',
transactionDate: '20260809', // yyyyMMdd — a receipt number is only
); // unique within a terminal's day
inquire, inquireByReference and receipt authorise nothing, present no card and change nothing, so they are safe to repeat — and the terminal answers them even while it is taking a payment. That is what makes an unknown outcome recoverable rather than a guess.
Merchant reference #
Every money-moving call takes merchantReference: your own name for the transaction. Pass an order number and the same string identifies the sale in your books, in the terminal’s records and in any later lookup. Leave it out and one is generated; either way it comes back on EcrResult.merchantReference, and it is the only handle you hold before the terminal answers.
Cancelling #
Cancelling stops the app waiting; it does not reverse anything at the terminal. A cardholder mid-PIN carries on. A cancelled money-moving request is an unknown outcome, exactly like a timeout, and the next step is an inquiry rather than a retry. Cancelling a read-only operation is harmless.
Ask the terminal what it will accept #
The merchant’s TMS profile decides which operations a terminal serves and the amounts each one allows, and it can change at any moment — the terminal picks that up on its next heartbeat. A till that never asks keeps offering a button that will now be refused, and finds out in front of a cardholder.
switch (await terminal.signOn()) {
case EcrSignOnAvailable(:final EcrTerminalCapabilities capabilities):
final bool canRefund = capabilities.permits(EcrTransactionType.refund);
final EcrPermittedTransaction? sale =
capabilities.limitsFor(EcrTransactionType.sale);
print('Sales from ${sale?.minAmount} to ${sale?.maxAmount}');
case EcrSignOnUnavailable(:final String reason):
// The terminal answered and cannot serve: not on its idle screen, or
// nobody signed in on it. Show the reason — it is the difference between
// "wait a moment" and "go and sign in on the terminal".
print(reason);
case EcrSignOnFailed(:final EcrFailure failure):
// Nothing answered. Leave the till as it was and let the refusal teach it.
print(failure.message);
}
EcrSignOnUnavailable is an answer, not a failure. “It is there and cannot take a payment” and “nothing answered” call for different things from a till, and reporting the first as the second sends somebody to look at a network that is working perfectly.
Sign-on reads only, so ask as often as you like — at start of day, and whenever you want to check your picture of the terminal is current. The answer is a snapshot and goes stale the moment TMS changes anything, which is safe: the terminal checks its own profile on every request, and a refusal carries the current profile with it. A till that never signs on twice still corrects itself.
An ecrMode this package has never heard of keeps its number on capabilities.ecrMode and leaves capabilities.transport null. An operation it cannot name is left out of permitted rather than guessed at — a till cannot offer a button it has no code for.
Put the receipt away when the cashier is done #
The terminal leaves its receipt on screen until somebody dismisses it, because normally the operator is standing there. When your till drove the transaction they are at the till instead, nobody dismisses it, and the terminal refuses the next transaction until somebody walks over and presses back.
// The moment the cashier closes your outcome dialog.
await terminal.closeReceipt();
Only you know when that moment is, which is why the terminal does not do it on a timer. An earlier build did, and took receipts off merchants who were still reading them.
Safe to repeat: an already-idle terminal answers EcrReceiptClosedIdle just the same, because the request asks for a state rather than an action. Nothing is lost either — receipt() fetches the e-receipt again and inquire() the transaction. A terminal too old to know the request answers EcrReceiptClosedRefused; treat that as “cannot be asked” and carry on.
Neither sign-on nor close-receipt reaches a Web Service terminal, and neither is sent over app-to-app — see Transports.
Result outcomes #
EcrResult ── EcrApproved money moved
├─ EcrDeclined the terminal answered and refused
└─ EcrFailed no trustworthy answer — see EcrFailure
switch (await terminal.sale(EcrAmount.parse('1.234'))) {
case EcrApproved(:final String amount, :final String rrn, :final String authCode):
completeSale(amount, rrn, authCode);
case EcrDeclined(:final String reason):
showMessage(reason);
case EcrFailed(:final EcrFailure failure) when failure.outcomeIsUnknown:
// Do not retry. Ask the terminal what happened.
await reconcile();
case EcrFailed(:final EcrFailure failure):
showMessage(failure.message);
}
EcrApproved #
Carries amount, rrn, authCode, maskedPan, partialApproval and requestedAmount. A partial approval is an approval: the bank authorised less than was asked for, and the goods go out only once the difference is collected by other means.
EcrDeclined #
Carries responseCode and reason. Codes worth reading:
| Code | Helper | Meaning |
|---|---|---|
| 96 | isTerminalBusy | A transaction is already running. Nothing was attempted |
| 17 | isCancelledAtTerminal | The operator or cardholder cancelled |
| 25 | isOriginalNotFound | No such receipt number for that day |
| 63 | — | Unsigned, signed with the wrong key, or the clock is too far out |
| 91 | — | outcomeIsUnknown is true — the one decline that is not a decision |
EcrFailed #
Carries a typed failure and, when the package has already followed up, the inquiry it found:
| EcrFailure | Outcome known? | Means |
|---|---|---|
EcrUnreachable | Known — nothing happened | Nothing is listening |
EcrTimeout | Unknown | Accepted, never answered |
EcrMalformed | Unknown | Answered, illegibly |
EcrConnectionLost | Unknown | Broke part way through |
EcrUnauthenticated | Unknown | The answer could not be shown to have come from the terminal |
EcrCancelled | Unknown | You gave up waiting |
EcrUnsupported | Known — nothing happened | This platform cannot do it |
Resolving an unknown outcome #
final EcrResult result = await terminal.sale(
amount,
merchantReference: order.number,
);
if (result.outcomeIsUnknown) {
// Do not retry. Ask the terminal, quoting the reference you sent — a receipt
// number arrives *in* the answer, which is what went missing.
switch (await terminal.inquireByReference(order.number)) {
case EcrInquiryFound(:final EcrTransaction transaction):
reconcile(transaction);
case EcrInquiryNotFound():
// Nothing was taken. Only now is it safe to send the sale again.
case EcrInquiryFailed():
// Still unknown. Ask again later; do not retry the sale.
}
return;
}
Most of the time the package has already asked for you. With autoInquireOnFailure (on by default), a money-moving request whose answer never arrived is followed by one inquiry, and what it found is on EcrFailed.recovered. When settled is set, the delivery failed but the outcome is known, and outcomeIsUnknown is false.
Nothing in this package retries. Not on a timeout, not on a lost connection, not on a host error. There is no setting that turns retrying on, because there is no failure worth charging a customer twice for.
What goes over the wire #
You do not need this to use the package — it signs, frames and parses for you. It is here for the afternoon you are looking at a log and want to know whether the terminal was ever asked.
Wi-Fi and USB cable carry the same JSON. On Wi-Fi it is TCP to port 9100, framed with a 2-byte big-endian length header; on the cable the same bytes travel with no IP involved.
A sale #
terminal.sale(EcrAmount.parse('0.216'), merchantReference: 'A1B2C3D4E5F6')
Request #
{
"version": 1,
"messageType": "SALE",
"merchantReference": "A1B2C3D4E5F6",
"terminalSerial": "P653200085189",
"currencyCode": "512",
"transactionDateTime": "20260809172826",
"ecrId": "TILL-01",
"amount": "000000000216",
"nonce": "9F86D081884C7D659A2FEAA0C55AD015",
"secureHash": "A3F1…"
}
amount is 12 zero-padded digits of minor units — 0.216 OMR at three decimal places. nonce and secureHash are added when a signing key is configured.
Response — approved #
{
"responseCode": "00",
"responseMessage": "Approved",
"approved": true,
"merchantReference": "A1B2C3D4E5F6",
"terminalSerial": "P653200085189",
"amount": "000000000216",
"rrn": "622113155340",
"authCode": "517842",
"maskedPan": "543173xxxx5785",
"ecrResponse": {
"success": true,
"responseCode": "00",
"message": "Approved",
"data": {
"transactionId": "318873a0-93f6-11f1-94a1-29b67ff8c66c",
"amount": "0.216",
"systemTraceNr": "000215",
"batchId": "00000003",
"rrn": "622113155340",
"authCode": "517842",
"cardMask": "543173xxxx5785",
"isPartialApprove": false
},
"errorList": []
}
}
That arrives as:
EcrApproved(
amount: '0.216', // major units — converted for you
rrn: '622113155340',
authCode: '517842',
maskedPan: '543173xxxx5785',
partialApproval: false,
merchantReference: 'A1B2C3D4E5F6',
)
Response — declined #
{
"responseCode": "909",
"responseMessage": "Insufficient funds",
"approved": false,
"merchantReference": "A1B2C3D4E5F6",
"amount": "000000000216",
"ecrResponse": {
"success": false,
"responseCode": "909",
"message": "A business exception occurred",
"errorList": ["Insufficient funds"]
}
}
reason on EcrDeclined is “Insufficient funds” — the package prefers the backend’s errorList over its message, because the message is often a generic string while the list is the sentence a cashier can act on.
Response — partial approval #
{
"responseCode": "00",
"responseMessage": "Approved",
"approved": true,
"amount": "000000000500",
"partialApproval": true,
"requestedAmount": "000000002000",
"rrn": "622113155340",
"authCode": "517842"
}
2.000 was asked for and 0.500 was taken. Note the response code is 00 — this is why the package reads approved and never decides from the code alone.
An inquiry #
The one to know, because it is how an unknown outcome gets resolved.
terminal.inquire(receiptNumber: '208', transactionDate: '20260809')
Request #
{
"version": 1,
"messageType": "INQUIRY",
"merchantReference": "D4E5F6A1B2C3",
"terminalSerial": "P653200085189",
"currencyCode": "512",
"transactionDateTime": "20260809171259",
"ecrId": "TILL-01",
"stan": "000208",
"originalTransactionDate": "20260809"
}
Response — found #
{
"responseCode": "00",
"responseMessage": "Transaction found",
"approved": true,
"merchantReference": "D4E5F6A1B2C3",
"amount": "000000000258",
"rrn": "7862802964726844904806",
"maskedPan": "543173******5785",
"ecrResponse": {
"success": true,
"responseCode": "00",
"message": "Transaction found",
"data": {
"transactionId": "e970c800-93f1-11f1-9485-e7dd858253ff",
"systemTraceNr": "000208",
"transactionType": "Purchase",
"status": "Approved",
"amount": 0.258,
"authorizeAmount": 0.258,
"isPartialApprove": false,
"currencyId": "512",
"transactionTime": "20260809165816",
"cardMask": "543173******5785",
"rrn": "7862802964726844904806",
"batchId": "00000003",
"terminalId": 31629,
"isRefunded": false,
"canVoid": true,
"canRefund": true
}
}
}
approved: true here means found, not paid. What became of the transaction is status. An inquiry that finds a declined sale is a successful inquiry — which is why the package returns EcrInquiryFound and puts the outcome on transaction.status rather than collapsing the two.
Response — not found #
{
"responseCode": "25",
"responseMessage": "No transactions found for the provided STAN and Terminal",
"approved": false,
"merchantReference": "D4E5F6A1B2C3",
"ecrResponse": {
"success": false,
"responseCode": "25",
"data": null,
"errorList": ["No transactions found for the provided STAN and Terminal"]
}
}
EcrInquiryNotFound — nothing was recorded, so nothing happened. This is the only answer on which retrying a sale is safe.
A receipt #
terminal.receipt(receiptNumber: '215', transactionDate: '20260809')
Response #
{
"responseCode": "00",
"responseMessage": "Receipt ready",
"approved": true,
"merchantReference": "E5F6A1B2C3D4",
"ecrResponse": {
"success": true,
"responseCode": "00",
"data": {
"receiptUrl": "https://test.amwalpg.com:25446/Transaction/DownloadReceipt?transactionId=318873a0-93f6-11f1-94a1-29b67ff8c66c",
"transactionId": "318873a0-93f6-11f1-94a1-29b67ff8c66c",
"systemTraceNr": "000215"
}
}
}
An empty or absent receiptUrl is EcrReceiptUnavailable, whatever the response code claims. A receipt with no URL is not a receipt.
A sign-on #
terminal.signOn()
Response #
{
"success": true,
"responseCode": "00",
"data": {
"available": true,
"ecrMode": 2,
"ecrModeName": "WIFI",
"terminalName": "Counter 3",
"currencyCode": "512",
"minorUnitDigits": 3,
"eReceipt": true,
"physicalReceipt": false,
"permittedTransactions": [
{ "messageType": "SALE", "minAmount": "0.100", "maxAmount": "5.000" },
{ "messageType": "INQUIRY" }
]
}
}
available: false still arrives as a successful answer, carrying a reason — that is EcrSignOnUnavailable, not EcrSignOnFailed. Amount limits are major units, as every answer reports an amount, and an operation with no limits carries neither field.
A close-receipt #
terminal.closeReceipt()
Response #
{
"success": true,
"responseCode": "00",
"message": "The terminal is on its idle screen"
}
The same answer whether a receipt was on screen or not: the request asks for a state, and that state now holds either way. A terminal too old to know the request answers 12.
Field names in an answer #
Every answer spells the same thing the same way, so a till learns one vocabulary:
| Meaning | Name |
|---|---|
| System trace number | systemTraceNr |
| Masked card number | cardMask |
| Currency | currencyId |
| Retrieval reference number | rrn |
| Amwal’s transaction id | transactionId |
| The reference you sent | merchantReference |
The request side keeps stan for the trace number, and always has.
Response codes #
| Code | Meaning | Money moved |
|---|---|---|
| 00 | Approved | Yes |
| 12 | Unsupported operation | No |
| 13 | The amount is not valid | No |
| 17 | Cancelled at the terminal | No |
| 25 | The original transaction was not found | No |
| 63 | Unsigned, signed with the wrong key, or the clock is too far out | No |
| 91 | The terminal could not say what happened | Unknown |
| 94 | Already answered — this request was sent before | Ask |
| 96 | A transaction is already running on this terminal | No |
Anything else is the backend’s own code passed through — 51, 909, 05. Read the typed outcome rather than matching on codes.
Seeing what is actually sent #
A debug build logs every call and every answer. Nothing needs turning on:
adb logcat -s AmwalEcr
On iOS the same lines go to the Xcode console, prefixed [AmwalEcr]. Release builds stay quiet; to make one talk for a session:
adb shell setprop log.tag.AmwalEcr DEBUG
