Skip to main content

Transaction Recovery — Windows SDK

In the Windows SDK, the final transaction result is delivered via the EndOfTransaction callback on your Events.Required implementation. If the connection between your application and the device is interrupted after the card is tapped but before EndOfTransaction fires, the result may not arrive.

GetTransactionStatus(transactionReference) lets you query the Handpoint Cloud for the outcome of any transaction at any point after it was initiated. The same TransactionReference also enables direct REST API queries against transactions.handpoint.com for server-side reconciliation and back-office recovery.

Prerequisite: CloudApiKey

GetTransactionStatus calls the Handpoint Cloud REST API directly. Your CloudApiKey must be configured in the SDK settings before calling this method, otherwise it throws SettingsPropertyNotFoundException.

Applicable operations

This recovery pattern applies to operations where a card is physically read and an acquirer authorization may have occurred: Sale, Refund (on-device), MOTO Sale (on-device), MOTO Refund (on-device), and Pre-Auth.

It does not apply to: reversals, tip adjustment, pre-auth capture, or pre-auth increase. If a result is lost for those operations, it is safe to retry them — no card is charged on your behalf without an explicit authorization.


When recovery is needed​

Recovery is required whenever your application might have missed the transaction outcome. Specific scenarios include:

ScenarioWhy recovery is needed
Connection dropped mid-saleThe device processes the card autonomously. Even if your application loses the USB/Bluetooth/network connection, the terminal may complete the authorization with the acquirer. You must query the outcome rather than assume the transaction failed.
App crash or process killIf the application crashes between the card tap and EndOfTransaction, the result is never received. On restart, query any pending TransactionReference saved to durable storage.
FinStatus: UNDEFINED receivedThe SDK received a response from the terminal but could not resolve the final status. Do not accept and do not reverse — query the status API first.
No callback / UI timeout reachedThe UI timeout you set fires before EndOfTransaction arrives. Treat the sale as unresolved and poll in the background until a final status is confirmed.
Mobile wallet "See Phone"Apple Pay / Google Pay requires the cardholder to verify on their device. This can take up to 120 seconds. Do not treat a UI timeout as a non-charge during this window.

How results are normally delivered​

hapi.Sale() returns an OperationStartResult synchronously. The TransactionReference property is the UUID identifying this transaction — persist it immediately. The actual result arrives later via EndOfTransaction.

var opResult = hapi.Sale(amount, currency, new Options());

if (!opResult.OperationStarted)
{
// SDK rejected the call — handle here, safe to retry
return;
}

// Persist before the result arrives
string transactionRef = opResult.TransactionReference;
await db.SavePendingTransactionAsync(transactionRef);

finStatus values​

FinancialStatusMeaningWhat to do
IN_PROGRESSTransaction still processing on device or hostKeep polling
UNDEFINEDResult received but status could not be resolvedDo not act. Query status API — see UNDEFINED handling
AUTHORISEDApproved — card chargedFinal. Do not retry.
DECLINEDDeclined by issuerFinal. Card not charged. Safe to retry.
PROCESSEDCompleted (tokenization, MOTO)Final. Do not retry.
FAILEDTechnical failureFinal. Card not charged. Safe to retry.
CANCELLEDCancelled by cardholder or merchantFinal. Card not charged. Safe to retry.
PARTIAL_APPROVALPartial amount approved (US only)Final. Wait 60 s before acting — see note below.
REFUNDEDRefund processedFinal. Do not retry.
CAPTUREDPre-authorisation capturedFinal. Do not retry.

Recovery flow​

The recommended pattern has two layers: a UI timeout that unblocks the clerk immediately, and background polling that resolves the outcome and auto-reverses if needed.

T+0s OperationStarted — TransactionReference persisted to DB
T+Ns ISV UI timeout fires (your choice — e.g. 90 s with no card interaction)
└─ Display FAILED to clerk so the POS is not blocked
└─ Mark DB record as pending/unresolved
└─ Continue polling GetTransactionStatus in background

Background polling (every 10 s):
IN_PROGRESS or UNDEFINED → keep polling
AUTHORISED found → send automatic reversal via Cloud API (no reader needed)
update DB to REVERSED
Any other final finStatus → clear pending record — card was not charged

Implementation​

Step 1 — Implement Events.TransactionResultReady​

GetTransactionStatus delivers its result through TransactionResultReady rather than EndOfTransaction. Implement this interface alongside Events.Required:

public class PaymentHandler : Events.Required, Events.TransactionResultReady
{
public void EndOfTransaction(TransactionResult result, Device device)
{
PaymentService.OnTransactionResult(result, recovered: false);
}

public void TransactionResultReady(TransactionResult result, Device device)
{
// Recovery path — result from GetTransactionStatus
PaymentService.OnTransactionResult(result, recovered: true);
}
}

Register both interfaces when building the Hapi instance:

var handler = new PaymentHandler();
hapi = HapiFactory.GetAsyncInterface(handler, device);

Step 2 — Start a sale and persist TransactionReference​

private const int UI_TIMEOUT_MS = 90_000; // 90 seconds — adjust to your UX requirements
private CancellationTokenSource _uiTimeoutCts;
private bool _uiTimedOut = false;

public async Task StartSaleAsync(int amount, Currency currency)
{
var opResult = hapi.Sale(amount, currency, new Options());

if (!opResult.OperationStarted)
{
// SDK rejected the request — safe to retry
return;
}

// Persist BEFORE the result can arrive
string transactionRef = opResult.TransactionReference;
await db.SavePendingTransactionAsync(transactionRef);

_uiTimedOut = false;
_uiTimeoutCts = new CancellationTokenSource();

_ = Task.Run(async () =>
{
try
{
await Task.Delay(UI_TIMEOUT_MS, _uiTimeoutCts.Token);
_uiTimedOut = true;
ShowFailedToClerk(); // unblock clerk immediately
await db.MarkPendingAsync(transactionRef);
StartBackgroundRecovery(transactionRef);
}
catch (TaskCanceledException) { /* EndOfTransaction arrived normally */ }
});
}

Step 3 — Handle results and drive background recovery​

public async void OnTransactionResult(TransactionResult result, bool recovered)
{
if (_uiTimedOut && !recovered) return; // background recovery is handling this

_uiTimeoutCts?.Cancel();

if (result.FinStatus == FinancialStatus.UNDEFINED)
{
// Treat UNDEFINED identically to a timeout — do not accept or reverse yet
ShowFailedToClerk();
await db.MarkPendingAsync(result.TransactionReference);
StartBackgroundRecovery(result.TransactionReference);
return;
}

if (recovered)
{
await HandleRecoveredResultAsync(result);
}
else
{
await HandleFinalResultAsync(result);
}
}

private static readonly TimeSpan PollInterval = TimeSpan.FromSeconds(10);
private static readonly TimeSpan ResultWait = TimeSpan.FromSeconds(60);

private async Task HandleRecoveredResultAsync(TransactionResult result)
{
if (result.FinStatus == FinancialStatus.IN_PROGRESS ||
result.FinStatus == FinancialStatus.UNDEFINED)
{
// Keep polling
await Task.Delay(PollInterval);
hapi.GetTransactionStatus(result.TransactionReference);
return;
}

// Final status — wait 60 s for EndOfTransaction to arrive naturally
await Task.Delay(ResultWait);

if (result.FinStatus == FinancialStatus.AUTHORISED)
{
// Card was charged but POS already showed FAILED — reverse automatically
await SendReversalAsync(result.TransactionID, result.TransactionReference);
await db.MarkReversedAsync(result.TransactionReference);
}
else
{
await db.ClearPendingAsync(result.TransactionReference);
}
}

private void StartBackgroundRecovery(string transactionRef)
{
_ = Task.Run(async () =>
{
await Task.Delay(PollInterval);
hapi.GetTransactionStatus(transactionRef);
// Result arrives in TransactionResultReady() → HandleRecoveredResultAsync()
});
}

private async Task SendReversalAsync(string originalTransactionID, string newRef)
{
using var client = new HttpClient();
client.DefaultRequestHeaders.Add("Authentication", "YOUR_API_KEY");

var payload = new
{
operation = "saleReversal",
originalTransactionID,
terminal_type = "PAXA920", // terminal type of the original transaction
serial_number = "0821599465", // serial number of the original terminal
transactionReference = Guid.NewGuid().ToString(),
};

await client.PostAsync(
"https://cloud.handpoint.com/transactions",
new StringContent(JsonSerializer.Serialize(payload), Encoding.UTF8, "application/json")
);
}

Step 4 — Surface a cancel option to clerks​

If the spinner is still running and the clerk needs to act, call StopCurrentTransaction. If it returns false, the transaction is still being processed.

cancelButton.Click += (s, e) =>
{
bool stopped = hapi.StopCurrentTransaction();
if (!stopped)
{
ShowMessage("Transaction cannot be cancelled — it is still being processed.");
}
};

Direct status query via REST API​

In addition to the SDK's GetTransactionStatus method, you can query the transaction outcome directly via the Handpoint Cloud REST API. This is useful for server-side reconciliation, back-office tooling, or startup recovery before the SDK is fully initialized.

Different base URL

The status endpoint uses https://transactions.handpoint.com (production) or https://transactions.handpoint.io (staging) — not cloud.handpoint.com.

Query the full transaction chain​

Use /status/all to retrieve the complete chain of operations linked to a TransactionReference. This accounts for cases where the original sale was followed by a reversal or refund.

private static readonly HttpClient StatusClient = new HttpClient
{
BaseAddress = new Uri("https://transactions.handpoint.com")
};

public static async Task<List<TransactionStatusEntry>> QueryTransactionStatusAsync(
string transactionRef,
string apiKey,
CancellationToken ct = default)
{
using var request = new HttpRequestMessage(
HttpMethod.Get,
$"/transactions/{transactionRef}/status/all");

request.Headers.Add("ApiKeyCloud", apiKey);

var response = await StatusClient.SendAsync(request, ct);
response.EnsureSuccessStatusCode();

var json = await response.Content.ReadAsStringAsync(ct);
return JsonSerializer.Deserialize<List<TransactionStatusEntry>>(json)
?? new List<TransactionStatusEntry>();
}

Define a minimal record for deserialization:

public record TransactionStatusEntry(
string Type,
string FinStatus,
int TotalAmount,
string Currency,
string TransactionID,
string TransactionReference
);

Response structure​

The /status/all endpoint returns a JSON array of operations, ordered from oldest to newest. Each entry represents one operation in the chain (original sale, reversal, refund, etc.).

[
{
"type": "SALE",
"finStatus": "AUTHORISED",
"totalAmount": 1000,
"currency": "USD",
"transactionID": "01236fc0-8192-11eb-9aca-ad4b0e95f241",
"transactionReference": "2bfde1fc-23b1-4c67-93d9-1d4a557f4d4f",
"authorisationCode": "155884",
"cardSchemeName": "VISA",
"maskedCardNumber": "************1456"
}
]

An empty array ([]) means the gateway has no record of this TransactionReference. The terminal may still be processing (cardholder still interacting), or no card was read at all. Continue polling — do not treat empty as a safe non-charge until your active-recovery timeout expires.

Parsing the response and deciding what to do​

public enum RecoveryOutcome { Authorised, NotCharged, StillResolving, NotFound }

public static async Task<(RecoveryOutcome Outcome, string? TransactionID)>
EvaluateTransactionStatusAsync(string transactionRef, string apiKey)
{
var operations = await QueryTransactionStatusAsync(transactionRef, apiKey);

if (operations.Count == 0)
return (RecoveryOutcome.NotFound, null);

var first = operations[0];

return first.FinStatus switch
{
"AUTHORISED"
=> (RecoveryOutcome.Authorised, first.TransactionID),

"DECLINED" or "CANCELLED" or "FAILED"
=> (RecoveryOutcome.NotCharged, null),

"IN_PROGRESS" or "UNDEFINED"
=> (RecoveryOutcome.StillResolving, null),

_ => (RecoveryOutcome.StillResolving, null)
};
}

Decision tree​

Query /status/all for TransactionReference
│
├─ Empty array (not found)
│ ├─ Within active-recovery timeout → wait and retry query
│ └─ After timeout expires → treat as non-charge; safe to retry sale
│
├─ FinStatus: AUTHORISED
│ └─ Card charged. Do NOT retry the sale.
│ └─ If POS already showed FAILED to clerk → send reversal
│
├─ FinStatus: DECLINED / CANCELLED / FAILED
│ └─ Card not charged. Safe to retry the sale (new TransactionReference).
│
└─ FinStatus: IN_PROGRESS / UNDEFINED
└─ Still resolving. Wait and retry query with backoff.

UNDEFINED finStatus — critical behavior​

UNDEFINED is not a safe non-charge. It means the Handpoint Cloud could not determine the final outcome at the time of the query. The most common causes are:

  • The terminal is still processing (cardholder interacting, awaiting host response)
  • A network disruption between the terminal and the gateway is resolving
  • The transaction reached the acquirer but the response has not propagated yet

Rules when you see FinancialStatus.UNDEFINED:

  1. Do not record the transaction as failed. The card may have been charged.
  2. Do not issue a reversal yet. There is no confirmed TransactionID to reverse against.
  3. Do not retry the sale. You could double-charge the cardholder.
  4. Continue polling — both GetTransactionStatus via the SDK and /status/all via the REST API will eventually resolve to a final status.
// Correct UNDEFINED handling in OnTransactionResult
if (result.FinStatus == FinancialStatus.UNDEFINED)
{
ShowFailedToClerk(); // unblock clerk — do not show "charged"
await db.MarkPendingAsync(result.TransactionReference); // record as unresolved
StartBackgroundRecovery(result.TransactionReference); // poll until final status
// Do NOT call HandleFinalResultAsync or accept the transaction
return;
}

After your active-recovery timeout (recommended: 6 minutes from sale initiation), if the status is still UNDEFINED or the array is empty, flag the transaction for manual reconciliation rather than auto-reversing or auto-accepting.


When is it safe to retry?​

ConditionSafe to retry?Action
FinStatus: AUTHORISED foundNoDo not retry. Card was charged.
FinStatus: DECLINED foundYesRetry — new call to hapi.Sale() produces a new TransactionReference
FinStatus: CANCELLED foundYesRetry — new hapi.Sale() call
FinStatus: FAILED foundYesRetry — new hapi.Sale() call
Empty result after active-recovery timeoutYesRetry — new hapi.Sale() call
FinStatus: UNDEFINEDNoContinue polling — do not retry yet
FinStatus: IN_PROGRESSNoContinue polling — do not retry yet

Each call to hapi.Sale() generates a fresh TransactionReference. Do not cache and reuse a reference from a previous attempt — the SDK generates a new one automatically on each call.


Common failure scenarios​

ScenarioWhat to do
App crashed; no result received; TransactionReference was savedOn startup, load the saved reference from persistent storage and call StartBackgroundRecovery(savedRef)
App crashed; TransactionReference was NOT savedNo recovery possible via SDK. Log the incident for manual reconciliation. Always persist TransactionReference immediately after OperationStarted.
FinStatus: UNDEFINED received in EndOfTransactionQuery /status/all and continue polling. Do not accept. Do not reverse. Do not retry.
Network dropped; sale may have processedQuery /status/all. If AUTHORISED found and POS showed failure, reverse. If empty after timeout, safe to retry.
FinStatus: DECLINED receivedCard not charged. Safe to retry the sale (new hapi.Sale() call).
UI timeout fired; clerk needs the POS backShow FAILED to clerk. Continue background recovery. If AUTHORISED found later, reverse automatically.
EndOfTransaction arrives after UI timeoutIgnore the late result if _uiTimedOut is true — background recovery is already handling it.
Cardholder using Apple Pay / Google Pay ("See Phone")Terminal waits up to 120 s. Do not cancel. Do not assume failed. Extend your active-recovery timeout accordingly.

Recovering on application restart​

If the application restarts before EndOfTransaction arrived, query any saved pending reference on startup:

protected override async void OnStartup(StartupEventArgs e)
{
base.OnStartup(e);
// ... initialise SDK ...

string? pendingRef = await db.GetPendingTransactionAsync();
if (pendingRef != null)
{
StartBackgroundRecovery(pendingRef);
// Result arrives in TransactionResultReady() → HandleRecoveredResultAsync()
}
}

For recovery before the SDK is available (e.g., a server-side process checking payment records), query the REST API directly:

public static async Task RecoverOnStartupAsync(string apiKey)
{
string? pendingRef = await db.GetPendingTransactionAsync();
if (pendingRef == null) return;

var (outcome, txId) = await EvaluateTransactionStatusAsync(pendingRef, apiKey);

switch (outcome)
{
case RecoveryOutcome.Authorised:
// Card was charged — reverse if POS showed failure, or reconcile manually
await SendReversalAsync(txId!, pendingRef);
await db.MarkReversedAsync(pendingRef);
break;

case RecoveryOutcome.NotCharged:
// Safe — clear the pending record
await db.ClearPendingAsync(pendingRef);
break;

case RecoveryOutcome.NotFound:
case RecoveryOutcome.StillResolving:
// Re-initialize SDK and poll via GetTransactionStatus
StartBackgroundRecovery(pendingRef);
break;
}
}
Partial approvals (US only)

If FinStatus is PARTIAL_APPROVAL, the device may be showing an accept/decline prompt to the cardholder with approximately a 30-second timeout. The 60-second wait built into the recovery flow above covers this window. Do not act on a partial approval result immediately — always allow the full wait period to elapse.