Self-checkout is hardware pretending to be software. You write C# (because the payment terminal SDK is C#), but the thing that decides whether a sale goes through is a cheap USB pinpad that resets itself when the power flickers. This post is five lessons from integrating card-present payments — TEF, electronic funds transfer1 — into self-checkout across several large retail deployments.
1. Idempotency is the foundation, not a detail
The question that decides whether your payment integration is amateur: what happens when the customer taps the card, the transaction is approved, and the pinpad drops before you get the response?
Amateur answer: you block the operator, wait for them to hit “try again,” and risk a double charge when the acquirer processes the first attempt that was still pending.
Correct answer: every call into the SDK carries an idempotency key. Not the key the SDK generates — your key, generated before the call. Something like:
var idempotencyKey = $"{store.Id}:{terminal.Id}:{transactionStartedAt:yyyyMMddHHmmss}:{Guid.NewGuid():N}";var transaction = new Transaction{ IdempotencyKey = idempotencyKey, Amount = total, StartedAt = DateTimeOffset.UtcNow,};
await _localRepo.SaveAsync(transaction);var result = await _tefClient.ProcessAsync(transaction);await _localRepo.UpdateAsync(transaction.Id, result);The key is persisted locally before you call the pinpad. If the call fails, local state knows a transaction is in flight. When the operator retries, you first query the status of the previous transaction with the acquirer before starting a new one. The SDK exposes this through a pending-transaction query.
2. Offline queue on SQLite, not a List<T> in memory
The physical store doesn’t have 24/7 internet. You know this in your head, but the whole team forgets it when writing the backoffice integration. When the connection drops, the sale has to continue — pinpad in offline mode (with a value ceiling pre-approved by the acquirer), and the backoffice gets the data when the link returns.
The queue has to be persistent. It has to survive a POS reboot. It has to be idempotent on the consumer side — resend the same transaction and the backoffice ignores it.
Use local SQLite. It’s embedded, reliable, WAL2. Each completed sale becomes a row in a pending_sync table with a status (new | sent | acked). A background worker loops, processing new in creation order. The backoffice endpoint uses the sale’s idempotency key to detect duplicates.
I’ve watched stores run four days offline after heavy rain took out the whole neighborhood’s fiber. When it came back, the queue drained in thirty minutes. Not one lost sale.
3. The terminal stuck mid-state is your worst bug
This is the one that shows up most in a peak-day war room. Customer taps the card. The pinpad blinks. Blinks again. Shows “PLEASE WAIT.” And then it hangs there.
From the SDK’s point of view, the transaction is in progress. From the operator’s, “it’s frozen.” From the customer’s, “I already tapped — where’s the machine?”
There’s no SDK command that distinguishes “pinpad processing” from “pinpad stuck.” You have to assume that past a timeout (we set 90 seconds, from production measurement) the pinpad is in a bad state.
The rule we adopted: after 90s with no event, do not auto-cancel. Show the operator an instruction to call a supervisor. The supervisor has permission to invoke the SDK’s explicit cancel command. Auto-cancel on timeout was our first idea — and the fastest way to create a duplicate transaction when the acquirer processed it server-side but the pinpad never answered.
4. Structured logs, separate from the application log
The payment SDK keeps its own log, in a proprietary format written by the DLL. When something breaks, the acquirer’s support asks for those files. They’re not the same as your app’s.
Make sure that log is collected and attached to every ticket. Better: parse those logs periodically to surface recurring errors. The acquirer won’t warn you that error category X is climbing. You find out when the operator yells.
5. Train the operator alongside the software
This one is meta, but it hurts most when you skip it. The operator — a real person, standing at the POS — is part of the system. The best payment software in the world doesn’t make up for an operator who can’t tell “Card Declined” from “Pinpad Restarting.” They’ll reconcile wrong and create phantom inventory.
In the rollouts I ran, UI trainability became an explicit criterion. Error messages get reviewed by the store manager before they reach code. If the manager says “this will confuse people,” the message goes back to UX. It cost time. It saved months of post-rollout complaints.
This kind of middleware isn’t pretty. It’s old, DLL-based, documented in PDF, with an SDK that still uses out parameters in interfaces. But it’s the system that processes payments for a meaningful slice of physical retail. Learning to operate it well is less about being elegant and more about respecting how many failure modes it hides.
Footnotes
-
TEF — electronic funds transfer — is the local middleware that orchestrates communication between the POS, the pinpad, and the acquirer’s authorization server. It’s installed on the POS itself, and it’s the backbone of card-present payments across most physical retail. ↩
-
Write-Ahead Logging — the SQLite mode where transactions are written to a log file before being applied to the main database. Survives a crash without corrupting data. The recommended default for POS. ↩