Prevent duplicate payments from the ECR

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 POST after 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.

SituationTerminal behaviourWhat it means for the ECR
Second POST /payment while an exchange is open on that endpoint403 Forbidden, whatever the ServiceIDParallel 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 finished202 Accepted: processed as a new payment, with a new POITransactionIDThe terminal does not deduplicate. Unique ServiceID values are your job.
GET /payment after the final 200 was read, or when nothing was started423 Locked: no open exchange on this endpointA 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 response408 Request Timeout, sent by the terminal (not your HTTP client). The exchange is closedThe 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 /blockedLists the endpoints with an open exchange. It reflects the HTTP server only, not the Payment Application, and triggers nothing on the terminalUse it to find a result you have not read yet. It does not tell you whether the terminal is free: that is /diagnosis.
POST /diagnosisAnswers even during a payment. POIStatus.GlobalStatus is OK, Busy, Maintenance or UnreachableBusy 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 ignoredOne result only, possibly another sale's. Always run the consistency check.
POST /status while a payment is still runningReturns the previous completed transaction with Result: Success, not InProgressSuccess on /status does not mean your payment succeeded. Check /blocked and /diagnosis first.
Terminal restarted during a paymentResult: Failure, ErrorCondition: LoggedOut until a new /login. After login, /status can answer NotFound or return an older transactionAfter a restart, NotFound means unknown, not not paid.
POST /abort during a payment2xx, no GET /abort. The payment ends a few seconds later with Result: Failure, ErrorCondition: Aborted on GET /payment, under the payment's ServiceIDKeep 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, /login and a retry of a payment confirmed not processed. Persist the counter; never use a constant such as "0".
  • The SaleTransactionID identifies the sale: unique per basket, unchanged across attempts for the same basket, always sent with its TimeStamp.

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 answersMeaningECR action
200Final PaymentResponseStore the result, close the journal entry, unlock the lane.
201Intermediate message (for example a DisplayRequest)Show the text to the cashier, keep polling, re-arm the payment timer.
204Payment running, nothing to read yetKeep polling.
206A message is returned and more are waitingProcess it and call GET /payment again immediately.
408The terminal's endpoint timeout expired; the exchange is closed, the payment may still be runningGo to the recovery logic.
423No open exchange on this endpointGo to the recovery logic.
TimerValue
Overall payment window (PIN, tip, currency choice, online authorisation)120 s, never below 90 s
Pause between two GET polls0.5 to 1 s
Single HTTP request timeout (GET poll, /blocked, /diagnosis, /status)10 s
Wait for the final 200 after an abort30 s, then unresolved procedure
Recovery attempts before declaring the terminal unreachable3, 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
  • /payment is listed: resume GET /payment until 200. That response is the outcome. Do not call /status now: it would return the previous sale. If the draining GET answers 408 or 423, 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"}, ... } }
GlobalStatusMeaningECR action
OKThe Payment Application is idleGo to step 3.
BusyA transaction is still running inside the Payment Application, with no open exchange to read it fromRe-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.
MaintenanceThe terminal is updatingWait, inform the cashier, retry later.
Unreachable, or Result: Failure with LoggedOutNo session or no networkSend /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 returnsConclusionECR action
Match, inner Result: SuccessPayment approvedStore POITransactionID, print the PaymentReceipt, close the sale as paid.
Match, inner Result: Failure (Aborted, Refusal, Cancel...)Payment not doneClose the attempt as unpaid. A retry uses a new ServiceID.
No match or NotFound, no LoggedOut seen since the request was sentThe terminal never started your paymentClose the attempt as unpaid. A retry is safe with a new ServiceID.
Failure with LoggedOutThe terminal restartedSend /login, then repeat steps 1 to 3 once.
Still no match after a terminal restartUnknown: an authorisation may have left the terminal before the restartMark the entry UNRESOLVED. Do not treat it as unpaid.
No answer after 3 attemptsTerminal unreachableMark 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 UNRESOLVED entry. 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 stateAttended laneSelf 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 windowTwo 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.

Was this article helpful?

0 out of 0 found this helpful