A duplicate payment almost always starts the same way: the ECR loses the answer to a payment it already sent, then sends it again or lets the customer pay another way. This article gives the rules your ECR must implement and the recovery logic to run with the Local API (Nexo Retailer 3.1, JSON over HTTP, default port 8080).
Why duplicates happen
The Payment Terminal and the acquirer only know about the requests they received. They cannot tell whether a second payment for the same basket is a mistake or a new purchase. Only the ECR knows, so duplicate prevention is an ECR responsibility. Typical causes:
- The card payment is approved, the response is lost (network cut, ECR timeout or crash) and the cashier presses Card payment again, or the customer pays cash. Nobody knows the card was charged, so nobody reverses it.
- The cashier double-clicks, or the HTTP library silently retries a
POSTafter a connection error: the same request reaches the terminal twice. - The ECR restarts during a payment and starts the next sale without reading the outcome of the interrupted one.
Note
The one rule: never re-send, replace or abandon a payment until the ECR knows its outcome. Every other rule in this article serves that one.
What the Payment Terminal does and does not do for you
Two components answer your requests: the HTTP server of the terminal, which holds one exchange per endpoint (a POST opens it, the final 200 on GET closes it), and the Payment Application, which runs the transaction. They can be in different states, and the recovery logic checks both.
| Situation | Terminal behaviour | What it means for the ECR |
|---|---|---|
Second POST /payment while an exchange is open on that endpoint | 403 Forbidden, whatever the ServiceID | Parallel duplicates are blocked. A 403 is not a decline: the first payment is still open. Run the recovery logic. |
POST /payment re-sent with an already used ServiceID, after the first payment finished | 202 Accepted: processed as a new payment, with a new POITransactionID | The terminal does not deduplicate. Unique ServiceID values are your job. |
GET /payment after the final 200 was read, or when nothing was started | 423 Locked: no open exchange on this endpoint | A result can be read once on GET /payment. After that, use POST /status. |
GET /payment when the terminal's endpoint timeout expired before the final response | 408 Request Timeout, sent by the terminal (not your HTTP client). The exchange is closed | The payment may still be running in the Payment Application. Run the recovery logic; the result will come from /status once the terminal is no longer busy. |
GET /blocked | Lists the endpoints with an open exchange. It reflects the HTTP server only, not the Payment Application, and triggers nothing on the terminal | Use it to find a result you have not read yet. It does not tell you whether the terminal is free: that is /diagnosis. |
POST /diagnosis | Answers even during a payment. POIStatus.GlobalStatus is OK, Busy, Maintenance or Unreachable | Busy with an empty /blocked list means the Payment Application is still processing (endpoint timed out, or the application is stuck). No ECR request can force it free. |
POST /status (TransactionStatusRequest) | Always HTTP 200. Returns the last completed Payment, CardAcquisition or Reversal in RepeatedMessageResponse. A MessageReference in the request is ignored | One result only, possibly another sale's. Always run the consistency check. |
POST /status while a payment is still running | Returns the previous completed transaction with Result: Success, not InProgress | Success on /status does not mean your payment succeeded. Check /blocked and /diagnosis first. |
| Terminal restarted during a payment | Result: Failure, ErrorCondition: LoggedOut until a new /login. After login, /status can answer NotFound or return an older transaction | After a restart, NotFound means unknown, not not paid. |
POST /abort during a payment | 2xx, no GET /abort. The payment ends a few seconds later with Result: Failure, ErrorCondition: Aborted on GET /payment, under the payment's ServiceID | Keep polling the payment endpoint after an abort. |
Note
Nexo Retailer defines a ServiceID uniqueness check on the receiver side and an InProgress answer to TransactionStatus. The Payment Terminal does not apply them today. An ECR built on the rules below works either way: if /status ever answers ErrorCondition: InProgress, treat it as "still running".
Rules to implement on the ECR
1. Write a transaction journal before you send
Before every POST of a Payment, CardAcquisition or Reversal, write one entry to persistent storage: ServiceID, SaleTransactionID (TransactionID and TimeStamp), endpoint, amount, currency and state IN_FLIGHT. On the final 200, store POIData.POITransactionID and the result, then close the entry. After a restart, an entry still IN_FLIGHT is a payment with an unknown outcome: resolve it before anything else on that lane.
2. Use a new ServiceID for every request
- Every request gets a new
ServiceID(10 characters maximum), including/status,/diagnosis,/abort,/loginand a retry of a payment confirmed not processed. Persist the counter; never use a constant such as"0". - The
SaleTransactionIDidentifies the sale: unique per basket, unchanged across attempts for the same basket, always sent with itsTimeStamp.
3. Lock the sale while its outcome is unknown
Disable the Card payment button as soon as it is pressed. While an entry is IN_FLIGHT for a basket: no new payment request, no other tender (cash, voucher, second card), no new sale on that lane. The lock always ends with a final result, a controlled abort or the unresolved procedure below.
4. Never let the HTTP layer retry a POST
Turn off automatic retries in your HTTP client and in any proxy between the ECR and the terminal. A POST that fails at transport level goes to the recovery logic, not to a retry.
5. Poll until 200, with timers that fit a real payment
POST /payment answers 202. Then call GET /payment (no body) until it answers 200.
GET /payment answers | Meaning | ECR action |
|---|---|---|
200 | Final PaymentResponse | Store the result, close the journal entry, unlock the lane. |
201 | Intermediate message (for example a DisplayRequest) | Show the text to the cashier, keep polling, re-arm the payment timer. |
204 | Payment running, nothing to read yet | Keep polling. |
206 | A message is returned and more are waiting | Process it and call GET /payment again immediately. |
408 | The terminal's endpoint timeout expired; the exchange is closed, the payment may still be running | Go to the recovery logic. |
423 | No open exchange on this endpoint | Go to the recovery logic. |
| Timer | Value |
|---|---|
| Overall payment window (PIN, tip, currency choice, online authorisation) | 120 s, never below 90 s |
Pause between two GET polls | 0.5 to 1 s |
Single HTTP request timeout (GET poll, /blocked, /diagnosis, /status) | 10 s |
Wait for the final 200 after an abort | 30 s, then unresolved procedure |
| Recovery attempts before declaring the terminal unreachable | 3, then unresolved procedure |
The terminal applies its own timeout per endpoint, set in its HTTP server configuration: when it expires before the final response, GET answers 408. It must be longer than your payment window; ask Market Pay for the value set on your terminals. When the payment window expires, do not release the screen and do not re-send: go to the recovery logic.
6. Reverse with the POITransactionID if two tenders happened
If a card payment turns out approved after the customer already paid another way, send a ReversalRequest quoting OriginalPOITransaction.POITransactionID from the journal or from /status. Never reverse with the SaleTransactionID.
The recovery logic
Run it when the ECR starts with an IN_FLIGHT entry, when a poll fails or the payment window expires, when a POST answers 403 or fails at transport level, and when GET /payment answers 408 or 423 with an entry still IN_FLIGHT. Use a new ServiceID for every call. The three steps answer three different questions: is an exchange still open, is the Payment Application still busy, what was the last result.
Step 1: is an HTTP exchange still open?
GET /blocked lists every endpoint with an open exchange, as plain JSON:
GET http://<terminal-ip>:8080/blocked
200 {"blocked":["/payment"]} a result is waiting, or still being produced
200 {"blocked":[]} no open exchange/paymentis listed: resumeGET /paymentuntil200. That response is the outcome. Do not call/statusnow: it would return the previous sale. If the drainingGETanswers408or423, go to step 2.- The list is empty: the result was already read, the payment never started, or the endpoint timed out while the payment was running. Step 2 tells these apart.
Step 2: is the Payment Application still busy?
POST http://<terminal-ip>:8080/diagnosis
{ "MessageHeader": { ..., "MessageCategory":"Diagnosis", "ServiceID":"1042" },
"DiagnosisRequest": {"POIID":"POI_01"} }
200
{ "DiagnosisResponse": { "Response": {"Result":"Success"},
"POIStatus": {"GlobalStatus":"OK"}, ... } }GlobalStatus | Meaning | ECR action |
|---|---|---|
OK | The Payment Application is idle | Go to step 3. |
Busy | A transaction is still running inside the Payment Application, with no open exchange to read it from | Re-check every 5 s within the payment window. Still Busy beyond the window: send one /abort referencing the in-flight ServiceID, re-check. Still Busy: mark the entry UNRESOLVED and ask the cashier to restart the terminal, then run the recovery logic again. |
Maintenance | The terminal is updating | Wait, inform the cashier, retry later. |
Unreachable, or Result: Failure with LoggedOut | No session or no network | Send /login, then repeat from step 1. |
Step 3: read the last completed transaction
POST http://<terminal-ip>:8080/status
{ "MessageHeader": { ..., "MessageCategory":"TransactionStatus", "ServiceID":"1043" },
"TransactionStatusRequest": {} }
200
{ "TransactionStatusResponse": {
"Response": {"Result":"Success"},
"RepeatedMessageResponse": {
"MessageHeader": {"MessageCategory":"Payment","ServiceID":"1040", ...},
"PaymentResponse": {
"Response": {"Result":"Success"},
"SaleData": {"SaleTransactionID":{"TransactionID":"SALE-1040","TimeStamp":"..."}},
"POIData": {"POITransactionID":{"TransactionID":"9991","TimeStamp":"..."}},
"PaymentResult": {"AmountsResp":{"AuthorizedAmount":"11.99","Currency":"EUR"}}, ... } } } }Warning
The outer Response.Result = Success only says that the terminal found a transaction to return. The outcome of the payment is the inner PaymentResponse.Response, and only after the consistency check.
The returned transaction matches your journal entry only if RepeatedMessageResponse.MessageHeader.ServiceID, SaleTransactionID.TransactionID and SaleTransactionID.TimeStamp are equal, MessageCategory is Payment and the amount is the one requested (or lower, for a partial approval).
What /status returns | Conclusion | ECR action |
|---|---|---|
Match, inner Result: Success | Payment approved | Store POITransactionID, print the PaymentReceipt, close the sale as paid. |
Match, inner Result: Failure (Aborted, Refusal, Cancel...) | Payment not done | Close the attempt as unpaid. A retry uses a new ServiceID. |
No match or NotFound, no LoggedOut seen since the request was sent | The terminal never started your payment | Close the attempt as unpaid. A retry is safe with a new ServiceID. |
Failure with LoggedOut | The terminal restarted | Send /login, then repeat steps 1 to 3 once. |
| Still no match after a terminal restart | Unknown: an authorisation may have left the terminal before the restart | Mark the entry UNRESOLVED. Do not treat it as unpaid. |
| No answer after 3 attempts | Terminal unreachable | Mark the entry UNRESOLVED. |
Unresolved procedure
- Keep the entry in persistent storage with state
UNRESOLVED, plus lane, amount and time, and alert the store supervisor or back office. Another tender is allowed only through this explicit path, never silently. - When the terminal is reachable and idle again, run steps 1 to 3 automatically for every
UNRESOLVEDentry. If the card payment turns out approved and another tender was taken, reverse it (rule 6). - If the result can no longer be read from the terminal, reconcile with the merchant transaction report before refunding the customer.
Abort when the customer gives up
If the payment is still running beyond the payment window (the customer walked away at the PIN prompt, for example), the cashier can cancel: send an AbortRequest whose MessageReference carries the payment ServiceID, then keep polling GET /payment. The final 200 with ErrorCondition: Aborted is the outcome. After a card acquisition (two-step payment), use the Admin variant (AdminRequest with DualTransactionAborted) described in the Local API specification.
Warning
An abort is a request, not a guarantee: if the authorisation is already under way, the payment can still end approved, and on some terminal screens the abort is not processed. Always read the final 200; if none arrives within 30 s, mark the entry UNRESOLVED.
What the cashier or customer sees
| ECR state | Attended lane | Self check-out |
|---|---|---|
| Recovery running (usually a few seconds) | "Checking payment, please wait". The Card payment button stays disabled. | "Confirming your payment" with a progress indicator. The customer cannot go back. |
| Payment still running beyond the window | Two choices only: Keep waiting or Cancel payment (sends the abort). | Automatic abort, then "Payment cancelled, please try again". |
UNRESOLVED | "Payment could not be confirmed". Another tender only after supervisor confirmation; any confirmed double payment will be reversed. | Call the attendant. Do not offer another tender automatically. |
Validate your implementation with the Market Pay POS Test Suite: pos-test-suite.market-pay.com. Need help? Contact Market Pay support at assist.market-pay.com.