Testing Edge Cases
Step-by-step scenarios for verifying that your integration handles the full range of real-world edge cases.
Prerequisites
| Requirement | Notes |
|---|---|
| Simulator merchant | The merchant must be configured against the Simulator (ViscusDummy) acquirer in Handpoint TMS — this is what enables trigger amounts, prevents real settlement, and allows expired cards. The device itself (production or debug hardware) does not determine this — only the merchant's acquirer configuration does. Contact your Handpoint integration engineer to have a test merchant provisioned. |
| Trigger amounts | Specific amounts that force a particular gateway response. Only work when the merchant is on the Simulator acquirer — see Development hardware. |
| Expired cards | Accepted on the Simulator acquirer — test cards can be past their expiry date. |
transactionReference log | A store (DB, log) where you record every UUID v4 you send before sending it. |
These amounts force specific responses on a ViscusDummy/Simulator merchant. All amounts are in minor currency units (e.g. 3784 = $37.84 if sending USD).
| Amount | Forced finStatus | statusMessage |
|---|---|---|
3779 | DECLINED | Refer to card issuer |
3784 | DECLINED | Transaction failed |
3793 | DECLINED | Pick-up card |
3757 | PARTIAL_APPROVAL | Approved (partial) |
3768 | FAILED | Error connecting to authorization provider |
3741 | FAILED | Processing error |
Any other amount: AUTHORISED (normal approval). → Full table including cancellation amounts: Development hardware
Sale
Declined transaction
- Cloud API
- Android (PAX)
- Android (HiLite)
- iOS (HiLite)
- Cordova
- Send
POST /transactionswith a trigger amount forDECLINED. - Verify the response body contains
"finStatus": "DECLINED". - Confirm your POS surfaces the decline message to the operator.
- Verify no charge appears in your test merchant portal.
- Call
hapi.sale()with a trigger amount forDECLINED. - Verify
finStatus == FinancialStatus.DECLINEDin yourendOfTransactioncallback. - Confirm the terminal displays "Declined".
- Verify no charge in your test merchant portal.
Same steps as Android (PAX) — trigger amounts work identically over the HiLite Bluetooth path.
- Call
heftClient.saleWithAmount()with a trigger amount forDECLINED. - Verify
statusCode != EFT_PP_STATUS_SUCCESSin your delegate. - Confirm terminal shows "Declined".
- Call
handpoint.sale()with a trigger amount forDECLINED. - Verify
result.finStatus === "DECLINED"in your success callback.
Network drop — recover via /status
Tests your ability to recover a transaction result when your server missed the callback.
- Cloud API
- Android (PAX)
- Android (HiLite)
- iOS (HiLite)
- Cordova
- Generate a UUID v4
transactionReferenceand write it to your database before sending the request. - Send
POST /transactionswith thattransactionReference. - Immediately after sending — before the result arrives — disconnect your server from the network or stop your callback listener.
- Wait 15 seconds, then reconnect.
- Query
GET https://transactions.handpoint.com/transactions/{transactionReference}/status. - Expect
IN_PROGRESSwhile the terminal processes, thenAUTHORISEDorDECLINEDonce complete. - Verify your reconciliation flow correctly records the result from the status endpoint.
- Verify you do not send a second transaction with the same
transactionReferenceduring the wait.
- Start a sale on the PAX terminal.
- While the terminal is at the card-reading screen, force-close the POS application.
- Restart the POS application and reconnect to the terminal.
- Verify the SDK calls
startRecovery()(or call it explicitly in youronCreate/reconnect handler). - Verify
endOfTransactionfires withrecoveredTransaction == trueand the correctfinStatus.
- Start a sale via HiLite.
- While the terminal is processing, disable Bluetooth on the Android device.
- Re-enable Bluetooth and reconnect.
- Verify the SDK recovers and delivers the result via
endOfTransaction.
- Start a sale via HiLite.
- While processing, disable Bluetooth on the iOS device.
- Re-enable Bluetooth and reconnect to the HiLite reader.
- Verify the result is delivered via the delegate callback.
Same as the underlying native platform (Android HiLite or iOS HiLite depending on device). Follow the Bluetooth drop steps above.
Duplicate transactionReference (idempotency)
- Cloud API
- Android (PAX)
- Android (HiLite)
- iOS (HiLite)
- Cordova
- Send
POST /transactionswithtransactionReference = "your-test-uuid". Note the result. - Send the exact same request again with the same
transactionReference. - Verify the gateway returns the original result — not a second charge.
- Verify only one charge appears in your test merchant portal.
Idempotency via transactionReference is a gateway-level feature. Test via Cloud API. Android SDK does not expose transactionReference as a call parameter directly — the SDK assigns one internally.
Same note as Android PAX — test idempotency via Cloud API.
Same note — test idempotency via Cloud API.
Same note — test idempotency via Cloud API.
Card fallback path — contactless → chip → swipe
Tests that the full fallback chain works end-to-end and that your app handles the extended time (~2–5 min) without timing out or sending a duplicate request.
- Cloud API
- Android (PAX)
- Android (HiLite)
- iOS (HiLite)
- Cordova
Card fallback is handled entirely by the terminal. From the Cloud API side:
- Send
POST /transactionswithtransactionReference. - Do not poll or retry — wait for the result via
callbackUrlorGET /transaction-result/{id}. - Verify your polling loop stays active for at least 6 minutes without sending a second request.
- On the terminal, allow the fallback chain to complete (tap fail × 3 → chip fail × 3 → swipe success).
- Verify the result arrives and is accepted by your callback handler.
Requires a physical PAX device. This test may take up to 5 minutes.
- Start a sale (
hapi.sale()). - Contactless fails: tap a card that triggers a contactless retry (e.g. tap at wrong angle or use an unsupported card). Repeat until the terminal exhausts retries (3 attempts × 30s = up to 90s).
- Chip fails: when prompted to insert, remove the card immediately after partial insertion to trigger
ICC_RESET_ERR. Repeat for up to 3 attempts (up to 90s). - Swipe: when prompted, swipe successfully.
- PIN: enter wrong PIN twice, then correct PIN on the third attempt (up to 90s).
- Verify
endOfTransactionfires withfinStatus == AUTHORISED. - Verify your app did not time out or send a duplicate request during the wait.
HiLite does not support chip fallback. Test contactless-only edge cases:
- Start a sale and tap a card that produces a read error.
- Verify the terminal retries and eventually succeeds or returns
DECLINED.
Same as Android HiLite — contactless only, no chip fallback on HiLite.
Same as Android (PAX) if running on PAX hardware. Same as HiLite paths if running on a Bluetooth reader.
Worst-case timeout — 6-minute integration test
Verifies your POS does not give up before the terminal has finished.
- Cloud API
- Android (PAX)
- Android (HiLite)
- iOS (HiLite)
- Cordova
- Send
POST /transactionswithtransactionReference. Start a stopwatch. - On the terminal, deliberately take the longest path (3 contactless fail → 3 chip fail → swipe → 3 wrong PINs → correct PIN).
- Verify your polling/callback listener is still active at 5 minutes.
- Verify the result is accepted when it arrives (anywhere up to ~6 minutes after send time).
- Verify
GET /statusreturnsIN_PROGRESSthroughout andAUTHORISEDafter completion.
Same scenario as "Card fallback path" above, but explicitly measure the elapsed time. Verify endOfTransaction fires within 6 minutes and your app's timeout does not fire before the result arrives.
Run the maximum contactless retry sequence. Verify no app-level timeout fires before the SDK delivers the result.
Same as Android HiLite.
Same as the underlying native platform.
Refund
Linked refund — same card (card-present paths)
- Cloud API
- Android (PAX)
- Android (HiLite)
- iOS (HiLite)
- Cordova
- Complete a sale — note the
transactionIDfrom the result. - Send
POST /transactionswith"operation": "refund"and"originalTransactionId": "{transactionID}". - On the terminal, present the same card used for the original sale.
- Verify
finStatus: AUTHORISED.
- Complete a sale — note the
transactionID. - Call
hapi.refund(amount, currency, originalTransactionID). - Present the same card.
- Verify
finStatus == AUTHORISEDinendOfTransaction.
Same as Android PAX.
- Complete a sale — note the
transactionIDfrom the XML response. - Call
heftClient.refundWithAmount:currency:transaction:. - Present the same card.
- Verify success in your delegate.
- Complete a sale — note
result.transactionID. - Call
handpoint.refund()withoriginalTransactionID. - Present the same card.
- Verify
result.finStatus === "AUTHORISED".
Linked refund — mismatched payment method
Tests the physical-card vs mobile-wallet PAN token mismatch. Requires a merchant configured for same-card verification.
- Cloud API
- Android (PAX)
- Android (HiLite)
- iOS (HiLite)
- Cordova
- Complete a sale using a physical card (chip insert) — note
transactionID. - Initiate a linked refund with
originalTransactionId. - On the terminal, present the same account via Apple Pay or Google Pay (wallet tap).
- Verify the refund is declined — wallet generates a different PAN token than the physical card.
- Repeat in reverse: original sale via wallet, refund via physical insert — same decline expected.
- Verify your UI instructs the cardholder to use the same payment method as the original purchase.
Same scenario as Cloud API — the mismatch check happens at the gateway. Follow the same steps using the Android SDK for the sale and refund calls.
Same as Android PAX.
Same scenario — test via HiLite. Physical card PAN ≠ wallet token.
Same scenario as Cloud API — mismatch check is gateway-level.
Refund amount exceeds original
- Cloud API
- Android (PAX)
- Android (HiLite)
- iOS (HiLite)
- Cordova
- Complete a sale for
1000(minor units). - Send a linked refund with
amount: "1500"andoriginalTransactionId. - Verify the gateway declines with
AMOUNT_EXCEEDS_ORIGINAL.
- Complete a sale for
BigInteger("1000"). - Call
hapi.refund(BigInteger("1500"), currency, originalTransactionID). - Verify
finStatus == DECLINEDand the error code in the result.
Same as Android PAX.
Call heftClient.refundWithAmount:currency:transaction: with an amount exceeding the original. Verify declined response.
Call handpoint.refund() with an amount exceeding the original. Verify result.finStatus === "DECLINED".
Reversal
Before cut-off — no card required
- Cloud API
- Android (PAX)
- Android (HiLite)
- iOS (HiLite)
- Cordova
- Complete a sale — note
transactionID. - Within the same business day (before batch cut-off), send
POST /transactionswith"operation": "saleReversal"and"originalTransactionId". - Verify no card interaction is required on the terminal.
- Verify
finStatus: AUTHORISED(reversal approved, hold released). - Verify no charge appears in your test merchant portal.
- Complete a sale — note
transactionID. - Call
hapi.saleReversal(amount, currency, originalTransactionID)same day. - Verify no card prompt appears on the terminal.
- Verify
finStatus == AUTHORISEDinendOfTransaction.
Same as Android PAX.
- Call
heftClient.saleVoidWithAmount:currency:transaction:same day. - Verify no card prompt and
EFT_PP_STATUS_SUCCESSin delegate.
- Call
handpoint.saleReversal()same day. - Verify
result.finStatus === "AUTHORISED"and no card prompt.
After cut-off — should fail
- Cloud API
- Android (PAX)
- Android (HiLite)
- iOS (HiLite)
- Cordova
- Complete a sale — note
transactionID. - Manually close the batch (or wait until after the scheduled cut-off time).
- Attempt a reversal with the same
originalTransactionId. - Verify the error
ORIGINAL_NOT_FOUNDor equivalent batch-closed error. - Verify your POS routes the operator to a post-settlement Refund flow instead.
Same scenario using hapi.saleReversal() after batch close. Verify finStatus == DECLINED or equivalent error in endOfTransaction.
Same as Android PAX.
Same scenario via saleVoidWithAmount after batch close. Verify failure in delegate.
Same via handpoint.saleReversal() after batch close.
Pre-Authorization lifecycle
Full lifecycle: create → capture
- Cloud API
- Android (PAX)
- Android (HiLite)
- iOS (HiLite)
- Cordova
- Send
POST /transactionswith"operation": "preAuthorization"and atransactionReference. Note thetransactionIDfrom the result. - Verify
finStatus: AUTHORISED(hold placed — no funds captured). - Send
POST /preauthorization/capturewith{ "originalGuid": "{transactionID}", "capturedAmount": "{amount}" }. - Verify capture result
finStatus: AUTHORISED. - Query
GET /transactions/{transactionReference}/status/all— verify two operations appear (Pre-Auth + Capture).
- Call
hapi.preAuthorization(amount, currency)— notetransactionIDfrom result. - Call
hapi.preAuthorizationCapture(captureAmount, currency, originalTransactionID). - Verify both results come through
endOfTransactionasAUTHORISED.
Pre-auth is not available on HiLite — use Cloud API.
Pre-auth is not available on iOS HiLite — use Cloud API.
- Call
handpoint.preAuthorization()— noteresult.transactionID. - Call
handpoint.preAuthorizationCapture()withoriginalTransactionID. - Verify both
finStatus === "AUTHORISED".
Create → void (release hold without capturing)
- Cloud API
- Android (PAX)
- Android (HiLite)
- iOS (HiLite)
- Cordova
- Send a Pre-Auth create — note
transactionID. - Send
POST /transactionswith"operation": "preAuthorizationReversal"and"originalTransactionId". - Verify
finStatus: AUTHORISED(hold released). - Verify no amount was captured in your test merchant portal.
- Call
hapi.preAuthorization()— notetransactionID. - Call
hapi.preAuthorizationVoid(amount, currency, originalTransactionID). - Verify
finStatus == AUTHORISEDand no capture in your portal.
Not available on HiLite.
Not available on iOS HiLite.
- Call
handpoint.preAuthorizationVoid()withoriginalTransactionID. - Verify
finStatus === "AUTHORISED"and no capture.
Signature (Android PAX only)
Single 30-second window — clearing does not reset the timer
- Cloud API
- Android (PAX)
- Android (HiLite)
- iOS (HiLite)
- Cordova
Signature is a terminal-side CVM — not applicable for Cloud API testing directly. Test via Android PAX.
- Configure a test card profile that uses Signature as the CVM (requires a test card that triggers signature rather than PIN).
- Complete a sale — when the signature screen appears, do not draw and wait.
- Verify the screen closes automatically after 30 seconds and the transaction completes (or is declined, depending on SDK configuration).
- Repeat: draw a signature, press Clear, draw again, press Clear — verify the screen still closes at the 30-second mark from when it first appeared. The Clear button does not reset the timer.
Not applicable — HiLite does not render a signature screen.
Not applicable on iOS HiLite.
Same as Android PAX if running on PAX hardware.
Mobile wallet — "See Phone" (120-second window)
Triggered when Apple Pay / Google Pay / Samsung Pay requires on-device biometric verification (Face ID, Touch ID, fingerprint) before the tap is finalised. The terminal shows "Verification Required – Please check your mobile device" and waits up to 120 seconds.
- Cloud API
- Android (PAX)
- Android (HiLite)
- iOS (HiLite)
- Cordova
- Send
POST /transactionswithtransactionReference. - On the terminal, tap with a mobile wallet card that triggers on-device CVM.
- When the "See Phone" screen appears, do not complete biometric auth — wait and observe.
- Verify the screen times out after 120 seconds and the transaction is declined/cancelled.
- Verify your
callbackUrlreceives the result within your 6-minute recovery window.
- Start a sale.
- Tap with an Apple Pay / Google Pay card that requires biometric CVM.
- When the terminal shows "See Phone", do not complete authentication on the phone — observe the 120s timeout.
- Verify
endOfTransactionfires withfinStatus == DECLINEDafter timeout. - Verify the terminal accepts the transaction when biometric auth IS completed on the phone within 120s.
Same as Android PAX for mobile wallet CVM.
Same test via iOS HiLite.
Same as the underlying native platform.
transactionReference rules — validation checklist
Run these on any integration path that supports transactionReference (Cloud API only; not applicable on HiLite paths).
| Check | Expected result |
|---|---|
Send transactionReference on a Sale | Accepted; queryable via /status |
Send transactionReference on a Remote Sale | Accepted; queryable via /status |
Send transactionReference on a Pre-Auth create | Accepted; links all lifecycle operations |
Send transactionReference on an unlinked Refund | Accepted; queryable via /status |
Send transactionReference on a Reversal | Should be ignored or rejected — do not send |
Send transactionReference on a linked Refund | Should be ignored or rejected — do not send |
Send transactionReference on a Pre-Auth Capture | Should be ignored or rejected — do not send |
Send transactionReference on a Pre-Auth Void | Should be ignored or rejected — do not send |
Query /status/all after a Sale + Refund | Both operations appear in the array |
Query /status/all after a Sale + Reversal | Both operations appear in the array |
Query /status immediately after sending (terminal still processing) | IN_PROGRESS |
Query /status after result delivered | AUTHORISED or DECLINED |
Query /status with a random UUID that was never sent | UNDEFINED |
EMV Forced Reversal — card removed mid-chip
When a chip card is removed from the reader after the gateway has authorized the transaction but before the full EMV flow completes (or when the card's internal application declines after online authorization), the SDK sends a forced-reversal to release the hold and then delivers a DECLINED result.
The dangerous moment: /status briefly shows AUTHORISED while the forced-reversal is in flight. An integration that saves the /status result immediately will record an incorrect approved transaction that was actually reversed.
This edge case is easy to reproduce on real hardware — no trigger amount required.
- Cloud API
- Android (PAX)
- Android (HiLite)
- iOS (HiLite)
- Cordova
- Send
POST /transactionswith"operation": "sale"and any non-trigger amount. Note thetransactionResultId. - On the terminal, insert the chip card.
- As soon as the terminal shows "Processing..." or "Please wait" (after PIN entry), quickly pull the card out of the reader.
- Immediately poll
GET /transaction-result/{transactionResultId}— you may seefinStatus: AUTHORISEDbriefly. - Do not record this result. Continue polling.
- Within 5–30 seconds, the result changes — the final
transaction-resultwill havefinStatus: DECLINEDandstatusMessagesimilar to "card declined the online authorization." - Verify your integration records
DECLINED, not the intermediateAUTHORISED.
What this tests: That your polling loop waits for transaction-result delivery rather than acting on the first /status poll.
- Call
hapi.sale(amount, currency). - Insert the chip card when prompted.
- After PIN entry (or as the terminal shows "Processing..."), pull the card out.
- Observe the terminal — it will show processing, then a decline message.
- Verify
endOfTransactionfires withfinStatus == DECLINEDandstatusMessagecontaining "card declined the online authorization" or similar. - Verify your app does not record an approval based on any intermediate state.
HiLite is a contactless/swipe reader — chip insertion is not applicable. This test does not apply.
Not applicable on HiLite.
Same steps as Android (PAX) if running on PAX hardware with chip capability.
Display the statusMessage to the merchant. The standard instruction is: re-insert the card and leave it in the reader until the terminal confirms completion. Do not remove the card during processing.
Partial Approval (US only)
Partial approvals only occur on US acquirer configurations (EPI, Interac). The trigger amount 3757 is only active when the merchant is provisioned on the Simulator acquirer with a US MCC. Do not test this on EU or non-US configurations.
Accept flow — cardholder accepts the partial amount
- Cloud API
- Android (PAX)
- Android (HiLite)
- iOS (HiLite)
- Cordova
- Send
POST /transactionswith"amount": "3757"and"currency": "USD". Save thetransactionResultId. - The terminal will display a partial approval prompt (e.g. "Approved $11.00 of $37.57 — Accept?").
- While the prompt is active,
GET /transactions/{ref}/statusreturnsfinStatus: AUTHORISED— this is not final. The amount fields reveal the partial:totalAmount < requestedAmountanddueAmount > 0. Do not save theAUTHORISEDresult. - On the terminal, press Accept.
- Poll
GET /transaction-result/{id}— the final result showsfinStatus: PARTIAL_APPROVALwithtotalAmountas the approved partial amount. - Verify your integration records
totalAmountas the settled amount — notrequestedAmount. - Verify your POS prompts for split tender (remaining
requestedAmount − totalAmount) or surfaces a "partial payment accepted" message.
Polling requirement: Continue polling transaction-result for at least 60 seconds — the accept/decline prompt on the terminal can take up to 60 seconds to resolve. From /status, use dueAmount > 0 as a secondary signal that the partial approval prompt is still active.
- Call
hapi.sale(BigInteger("3757"), currency). - The terminal shows the partial approval prompt.
- Press Accept on the terminal within 60 seconds.
- Verify
endOfTransactionfires withfinStatus == PARTIAL_APPROVALandtotalAmountreflecting the partial. - Verify your app saves
totalAmount, notrequestedAmount, as the settled amount. - Verify your app prompts for split tender or surfaces an appropriate message for the remaining balance.
Partial approval is a US-only feature and requires a US acquirer configuration. If using HiLite in the US, follow the same steps as Android PAX.
Same as Android HiLite — US acquirer required.
Same scenario as Android (PAX) using handpoint.sale(). Verify result.finStatus === "PARTIAL_APPROVAL" and result.totalAmount reflects the partial amount.
Decline flow — cardholder declines the partial amount
- Cloud API
- Android (PAX)
- Android (HiLite)
- iOS (HiLite)
- Cordova
- Send
POST /transactionswith"amount": "3757"and"currency": "USD". - On the terminal, press Decline when the partial approval prompt appears.
- The SDK automatically sends a reversal for
totalAmount— no action from your integration is required. - Continue polling
GET /transaction-result/{id}— the final result must showfinStatus: CANCELLED. - Verify your integration does not save the transaction as a sale.
- Verify your POS prompts the cardholder to use a different payment method.
- Optionally: query
GET /transactions/{transactionReference}/status/alland verify the array contains two entries — theCANCELLEDsale and anAUTHORISEDreversal.
Key verification: While the prompt was active, /status showed AUTHORISED with dueAmount > 0. If your integration saved that AUTHORISED as a completed sale before the decline resolved, this test will expose that bug. The final transaction-result is the authoritative outcome.
- Call
hapi.sale(BigInteger("3757"), currency). - Press Decline on the terminal when the partial approval prompt appears.
- Verify
endOfTransactionfires withfinStatus == CANCELLED. - Verify no sale is recorded in your portal.
- Verify your app does not treat an intermediate
PARTIAL_APPROVALstate as a final result before the cardholder accepts or declines.
US acquirer required. Same steps as Android PAX.
US acquirer required. Same steps as Android HiLite.
Same scenario using handpoint.sale(). Verify result.finStatus === "CANCELLED" after the decline resolves, and verify your integration does not record the intermediate PARTIAL_APPROVAL state as a sale.
ISV reversal — integration does not accept partial approvals
If your integration policy is to never accept partial approvals, test the reversal path:
- Cloud API
- Android (PAX)
- Android (HiLite)
- iOS (HiLite)
- Cordova
- Send
POST /transactionswith"amount": "3757". - On the terminal, press Accept to produce a
finStatus: PARTIAL_APPROVALresult with atransactionID. - Immediately send
POST /reversalwith{ "originalGuid": "{transactionID}" }(noamountneeded for a full reversal). - Verify the reversal
finStatus: AUTHORISED. - Verify no net charge appears in your test merchant portal (the hold is released).
- Verify your POS shows "Insufficient funds on this card" and prompts for an alternative payment method.
totalAmount if sending amount on the reversalThe reversal endpoint requires the amount that was actually authorized — totalAmount from the partial approval result — not requestedAmount. If your acquirer requires an explicit amount, use totalAmount. Using requestedAmount will fail or cause a settlement mismatch.
- After receiving
PARTIAL_APPROVALinendOfTransaction, callhapi.saleReversal(totalAmount, currency, originalTransactionID). - Verify the reversal
finStatus == AUTHORISED. - Verify the hold is released.
US acquirer required. Same steps as Android PAX.
US acquirer required. Same steps.
- After
PARTIAL_APPROVAL, callhandpoint.saleReversal()withtotalAmountandoriginalTransactionID. - Verify
result.finStatus === "AUTHORISED"and no net charge.
Related pages
- Development hardware — trigger amounts, test card PANs, Interac test cards
- Validate integration — pre-launch checklist covering the full integration surface
- Transaction recovery — Cloud API — implementation guide for UNDEFINED and network-drop recovery
- Error codes — full error code reference with recovery steps