APPROVED, DECLINED, UNKNOWN: designing honest transaction status
A host can approve a payment while the terminal still shows UNKNOWN. That third state is not a bug — it's the whole point.
Every payment engineer has watched this play out. The host approves. Somewhere between middleware and terminal a message is dropped, retried, or reordered. The customer walks away. The receipt printer is silent. The reporting dashboard shows an approval. And nobody — not the acquirer, not the terminal, not the merchant — can honestly say the transaction "succeeded" without qualification.
Based on operational notes from 2024; written September 2026.
The mistake is at the type level
The most common design failure is collapsing all of this into a single boolean: success. Everything downstream — merchant projection, reporting, refund logic — treats that boolean as truth. It isn't. There are at least three states that must be distinguishable in every layer:
| State | Meaning |
|---|---|
APPROVED |
The observing layer saw a positive response with an authorization code. |
DECLINED |
The observing layer saw a negative response. |
UNKNOWN |
The observing layer sent a request and did not observe a response within the timeout. |
The last one is the load-bearing state. If your terminal, your middleware, and your merchant dashboard can't all express UNKNOWN, then somewhere along the path you're going to lie to a human — and someone is going to reconcile that lie with a spreadsheet at 2 AM.
The reversal is not the reversal
The corollary: a reversal request is not a reversal. It's a request. Until you observe a positive response for the reversal, the observed state is:
middleware = REVERSAL_PENDING
merchant = REVIEW_REQUIRED
Flipping to REVERSED before that response arrives is exactly the same mistake as flipping to APPROVED on a request sent. It papers over the uncertainty in a way that will bite you at reconciliation.
What to do about it
- Model
UNKNOWNexplicitly in every schema — terminal, middleware, merchant projection, reporting. Never collapse it into "pending" for compactness. - Preserve the original approval and the reversal as two distinct historical events on the same logical operation. Don't rewrite history.
- Give the merchant view a delivery qualifier. "Approved (delivery not confirmed)" is truthful. "Approved" alone is not, if the terminal never saw it.
- Never let a heartbeat timeout auto-decline. Timeout means uncertainty. The right action depends on the connector contract.
There's a working demonstration at /payment-lab/flow-explorer — pick "Response to terminal lost" to see this exact scenario play out step by step.
The invariant that catches everything
If you write one invariant into your test suite, make it this one:
A host approval event never automatically sets terminal APPROVED.
If your reducer violates that, you have a bug that will eventually cost real money. If it doesn't, you have honest state that reconciliation will thank you for.