SAF Offline Transactions (Store-and-Forward)
SAF (Store-and-Forward) lets the terminal accept a transaction locally when it cannot go online, then upload it to the issuer for authorization once connectivity is restored. This page documents the SAF-related interfaces provided by the Taplink Android SDK: the Offline mode switch, limit configuration, upload mode, the offline fields on SaleRequest, and the query, void, upload, and callback management interfaces.
All interfaces are invoked on the client obtained from TaplinkSDK.getClient(). Transaction interfaces reuse the existing PaymentCallback; control and management interfaces use their own dedicated callbacks.
Risk ownership. The financial and bad-debt risk of SAF offline transactions (final decline by the issuer after offline acceptance, improperly configured limits, fraud during the offline window, etc.) is borne by the integrator / merchant, not by the payment terminal. Whether to enable Offline mode, how to configure limits, and whether to force offline are all the integrator’s decisions. The terminal only executes acceptance, storage, and forwarding per the rules; it is not responsible for the final financial outcome.
Offline mode
Offline mode is the master switch for accepting all SAF transactions on the terminal. When off, the terminal goes online only and the safMode field on SaleRequest is ignored.
Query Offline mode
val client = TaplinkSDK.getClient()
client.getOfflineMode(object : OfflineModeCallback {
override fun onSuccess(result: OfflineModeResult) {
val enabled = result.enabled
val updatedAt = result.updatedAt
}
override fun onFailure(error: PaymentError) {
// handle communication / technical error
}
})Set Offline mode
val request = SetOfflineModeRequest.builder()
.setEnabled(true)
.build()
client.setOfflineMode(request, object : OfflineModeCallback {
override fun onSuccess(result: OfflineModeResult) {
if (result.enabled) {
// enabled successfully; present the offline risk notice to the merchant
showRiskNotice(result.riskNotice)
}
}
override fun onFailure(error: PaymentError) {
// when not connected the switch does not take effect; a clear error is returned here
}
})OfflineModeResult fields:
| Field | Type | Description |
|---|---|---|
enabled | Boolean | Whether offline transactions are currently accepted |
terminalId | String | Terminal identifier |
updatedAt | Long | State update time |
riskNotice | String? | Offline risk notice returned on successful enabling, to present to the merchant |
Setting the switch requires an established connection between the POS and the terminal. If setOfflineMode is called while disconnected, the terminal changes no state and returns a clear error through onFailure. When local records may diverge from the terminal state, treat the state pulled by getOfflineMode as the source of truth. Disabling Offline mode does not affect existing Pending items — accepted transactions can still be queried, uploaded, and voided.
Query terminal online capability
Before initiating a transaction, query whether the terminal can currently go online, and decide whether to process online or rely on offline acceptance.
client.getTerminalOnlineStatus(object : TerminalOnlineStatusCallback {
override fun onSuccess(status: TerminalOnlineStatus) {
when {
status.canProcessOnline -> proceedOnline()
status.offlineModeEnabled -> relyOnOffline()
else -> abortTransaction(status.reason)
}
}
override fun onFailure(error: PaymentError) { }
})TerminalOnlineStatus fields:
| Field | Type | Description |
|---|---|---|
canProcessOnline | Boolean | Whether it can currently process online transactions |
offlineModeEnabled | Boolean | Whether Offline mode is enabled |
reason | String? | A summary of why it cannot go online (e.g. network unavailable, not signed in, keys not ready) |
This interface reflects real-time status only and changes no configuration.
SAF limit configuration
Limits constrain whether a transaction can enter SAF. All three are optional; only provided items are validated, and omitted items are neither validated nor given a default. Limits take effect only after Offline mode is enabled.
Query limits
client.getSafConfig(object : SafConfigCallback {
override fun onSuccess(config: SafConfigResult) {
val single = config.singleTransactionLimit
val cumulativeAmount = config.cumulativeAmountLimit
val cumulativeCount = config.cumulativeCountLimit
}
override fun onFailure(error: PaymentError) { }
})Save limits
saveSafConfig is a full overwrite: the last successfully saved complete config prevails, and an item provided previously but omitted this time is treated as removed.
val request = SaveSafConfigRequest.builder()
.setSingleTransactionLimit(BigDecimal("5000")) // single-transaction cap USD 50.00 (incl. tip)
.setCumulativeAmountLimit(BigDecimal("50000")) // cumulative amount cap USD 500.00
.setCumulativeCountLimit(20) // cumulative count cap
.build()
client.saveSafConfig(request, object : SafConfigCallback {
override fun onSuccess(config: SafConfigResult) {
val savedAt = config.savedAt
}
override fun onFailure(error: PaymentError) { }
})SaveSafConfigRequest fields:
| Field | Type | Required | Description |
|---|---|---|---|
singleTransactionLimit | BigDecimal? | No | Maximum total amount of a single transaction (including tip) |
cumulativeAmountLimit | BigDecimal? | No | Maximum cumulative amount of local Pending items |
cumulativeCountLimit | Int? | No | Maximum cumulative count of local Pending items |
Beyond those three, SafConfigResult also includes savedAt (save time) and terminalId.
Amounts must be > 0 with at most two decimal places, and counts must be integers > 0; when both single and cumulative amounts are provided, the cumulative must not be lower than the single. Changing a limit or removing a restriction affects only subsequent new transactions; existing Pending items are handled by their snapshot at creation time — never deleted and never auto-uploaded.
Upload mode
Controls who triggers the upload of accepted Pending transactions. AUTO is the default mode — when setSafUploadMode is never called, the terminal behaves as AUTO, so with no extra configuration the terminal automatically uploads once connectivity recovers.
| Mode | Meaning |
|---|---|
AUTO (default) | After connectivity recovers, the terminal automatically uploads its Pending items one by one; no manual trigger needed |
MANUAL | Triggered by the integrator via single upload or upload all |
// query the current upload mode
client.getSafUploadMode(object : SafUploadModeCallback {
override fun onSuccess(result: SafUploadModeResult) {
val mode = result.mode // SafUploadMode.AUTO / MANUAL
}
override fun onFailure(error: PaymentError) { }
})
// set the upload mode
val request = SetSafUploadModeRequest.builder()
.setMode(SafUploadMode.AUTO)
.build()
client.setSafUploadMode(request, object : SafUploadModeCallback {
override fun onSuccess(result: SafUploadModeResult) { }
override fun onFailure(error: PaymentError) { }
})SafUploadModeResult fields: mode, terminalId, updatedAt.
The per-item handling rules are identical for both modes; AUTO merely removes the need to call it explicitly, and under AUTO you can still upload manually — the two are idempotent and do not conflict. In either mode, successful upload and successful online authorization are reported through SafResultCallback.
Transaction interfaces
SAF currently supports the sale (SALE) transaction type only. Other transaction types such as refund and pre-authorization do not enter SAF for now and will be provided in a later version.
Offline field on Sale (safMode)
SAF needs no separate payment entry point — it still uses the existing client.sale(request, callback). SaleRequest adds an optional field safMode controlling whether the transaction enters SAF (effective only when the terminal Offline mode is on, otherwise ignored and everything goes online).
safMode | Semantics |
|---|---|
ONLINE_ONLY | Online only; an online failure is a failure and it never enters SAF |
OFFLINE_ONLY | Forced offline; once the security conditions and limits are met it is accepted offline directly, without triggering the confirmation callback |
| Not set (default) | Tries online first; when online fails and the SAF conditions are met, the cashier confirms via the acceptance confirmation callback. Older SDKs without this field behave the same way |
val amount = AmountInfo()
.setOrderAmount(BigDecimal("1000")) // 1000 minor units = USD 10.00
.setPricingCurrency("USD")
val request = SaleRequest.builder()
.setReferenceOrderId("ORDER_${System.currentTimeMillis()}")
.setTransactionRequestId("TXN_${System.currentTimeMillis()}")
.setAmount(amount)
.setSafMode(SafMode.OFFLINE_ONLY) // force this transaction into SAF
.build()
client.sale(request, object : PaymentCallback {
override fun onSuccess(result: PaymentResult) {
if (result.offlineApproved) {
// accepted offline, not a final success; result.safStatus == SafStatus.PENDING
markAsOfflineAccepted(result)
}
}
override fun onFailure(error: PaymentError) { }
override fun onProgress(event: PaymentEvent) { }
})On successful local SAF acceptance, PaymentResult returns these fields in addition to the existing ones:
| Field | Type | Description |
|---|---|---|
offlineApproved | Boolean | SAF-origin marker; stays true once accepted; false for non-SAF transactions |
safStatus | SafStatus? | SAF lifecycle status; null for non-SAF transactions |
safDetail | SafDetail? | Centralizes safStatus, the Sunbay receipt time, the final-result time, and a masked status description |
Do not treat a success code alone as final success. You must detect offlineApproved = true and mark it as “accepted offline, not final success”; do not display it as final Approved, and do not print or generate an issuer authorization code. Credential fields such as auth code, rrn, and batchNo are null during the PENDING phase and are returned per the authoritative result only after a final result is reached (Declined returns no auth code).
SafStatus values:
| Value | Meaning |
|---|---|
PENDING | Accepted locally, awaiting upload |
PROCESSING | Uploaded and received by Sunbay, final result not returned |
APPROVED | Online authorization succeeded |
DECLINED | Authorization declined |
VOIDED | Voided locally before upload, never reached Sunbay |
Offline acceptance confirmation callback
In default mode, when online fails and the SAF conditions are met, and before writing locally, the terminal requests merchant confirmation through PaymentCallback to decide whether to add the transaction to SAF. This callback is optional to implement — if not implemented or unanswered before timeout, the terminal’s default policy applies. OFFLINE_ONLY accepts directly without triggering it, and ONLINE_ONLY never enters the offline flow.
override fun onOfflineAcceptanceRequired(
info: OfflineAcceptanceInfo,
decision: OfflineAcceptanceDecision
) {
// present the amount and risk notice to the cashier; timeout is 60 seconds
showAcceptanceDialog(info) { accepted ->
if (accepted) decision.accept() else decision.reject()
}
}OfflineAcceptanceInfo fields:
| Field | Type | Description |
|---|---|---|
transactionRequestId | String | Transaction request ID |
referenceOrderId | String | Order ID |
amount | BigDecimal | Finalized total amount (including tip, tax, custom fee, pricing-strategy amounts) |
timeoutSeconds | Int | Remaining wait seconds, fixed at 60 |
The callback carries no PAN, encCardData, ARQC, or other replayable fields. On reject or default non-acceptance, nothing is persisted and no limit is reserved; it returns through onFailure with a distinguishable reason.
Query a SAF transaction
Use the original transactionRequestId with the existing client.query(...) for a compensating query. When transactionRequestId is provided, it queries in order: local Pending → Sunbay SAF → normal transaction; when only transactionId is provided, it keeps normal transaction-query semantics.
val query = QueryRequest()
.setTransactionRequestId("TXN_ORIGINAL_ID")
client.query(query, object : PaymentCallback {
override fun onSuccess(result: PaymentResult) {
when (result.safStatus) {
SafStatus.PENDING, SafStatus.PROCESSING -> continueWaiting()
SafStatus.APPROVED -> finalizeApproved(result)
SafStatus.DECLINED, SafStatus.VOIDED -> finalizeFailed(result)
else -> handleNonSaf(result)
}
}
override fun onFailure(error: PaymentError) { }
})Mapping between safStatus and transactionStatus:
safStatus | transactionStatus | Meaning |
|---|---|---|
PENDING | PROCESSING | Accepted locally, Sunbay receipt not confirmed |
PROCESSING | PROCESSING | Received by Sunbay, final result not returned |
APPROVED | SUCCESS | Finally approved |
DECLINED | FAILED | Finally declined |
VOIDED | FAILED | Voided locally before upload |
After reconnecting or when no callback was received, you must reconcile by querying with the original transactionRequestId and must not resubmit the Sale. PENDING and VOIDED return only a summary safe to display, with no gateway credentials.
Void an item awaiting upload
Use VoidRequest with the transactionRequestId of the item to void, calling the existing client.void(...). When it matches a WAIT_UPLOAD Pending on this terminal, it performs a local void without sending an online VOID to Sunbay; when it matches a transaction already delivered to Sunbay, it follows the existing online VOID handling.
val request = VoidRequest.builder()
.setTransactionRequestId("TXN_${System.currentTimeMillis()}")
.setOriginalTransactionRequestId("TXN_ORIGINAL_ID") // the SAF transaction to void
.build()
client.void(request, object : PaymentCallback {
override fun onSuccess(result: PaymentResult) {
// result.offlineApproved == true, result.safStatus == SafStatus.VOIDED
}
override fun onFailure(error: PaymentError) {
// not found / not this terminal / status not allowed — all returned with a distinguishable reason
}
})Validation order: request ID exists → belongs to this terminal → status is awaiting upload. The void is idempotent: repeating it with the same ID returns the same result and does not release the limit again; if the item has already been forwarded or picked up, the void is rejected. After a successful void, the item no longer appears in list results and is not counted in the batch-close block summary.
Batch close and Pending block (error code 317)
Call the existing client.batchClose(...). When the current terminal has items awaiting upload or with unknown receipt status, the terminal does not start the batch close and returns error code 317 through onFailure (it never returns a fabricated success); transactions received by Sunbay but still PROCESSING are not blocked.
client.batchClose(request, object : PaymentCallback {
override fun onSuccess(result: PaymentResult) { }
override fun onFailure(error: PaymentError) {
if (error.code == "317" && error.safPendingBlocked) {
val summary = error.safPendingSummary // Pending count, amount, receiveUnknownCount
// clear Pending by uploading first, then retry with the original batch-close request ID
uploadThenRetryBatchClose(summary)
}
}
})317 is a retryable failure (canRetryWithSameId = true). After clearing Pending by uploading, resubmit with the original batch-close request ID; no release action is needed after a disconnection, timeout, or process restart.
SAF management interfaces
List queries
listSafPending queries this terminal’s masked summary of items awaiting upload; listSafTransactions queries Sunbay’s SAF transactions directly, without backfilling local completion state or rebuilding Pending.
// local Pending list
val pendingReq = SafPageRequest.builder()
.setPageNo(1)
.setPageSize(20)
.build()
client.listSafPending(pendingReq, object : SafPendingCallback {
override fun onSuccess(page: SafPendingPage) {
val items = page.items // masked summaries
val totalCount = page.totalCount
val totalAmount = page.totalAmount
}
override fun onFailure(error: PaymentError) { }
})
// Sunbay SAF transactions list
val txnQuery = SafTransactionsQuery.builder()
.setPageNo(1)
.setPageSize(20)
.setFromTime(fromTimeMillis)
.setToTime(toTimeMillis)
.setStatuses(listOf(SafStatus.PROCESSING, SafStatus.APPROVED, SafStatus.DECLINED))
.build()
client.listSafTransactions(txnQuery, object : SafTransactionsCallback {
override fun onSuccess(page: SafTransactionsPage) { }
override fun onFailure(error: PaymentError) { }
})SafTransactionsQuery.statuses is an optional filter that supports only PROCESSING / APPROVED / DECLINED.
Single upload
Upload a single Pending item by its original transactionRequestId, processing only this terminal’s transaction.
val request = UploadSafTransactionRequest.builder()
.setTransactionRequestId("TXN_ORIGINAL_ID")
.build()
client.uploadSafTransaction(request, object : SafUploadCallback {
override fun onSuccess(result: SafUploadResult) {
val status = result.safStatus // current status or "result unknown"
}
override fun onFailure(error: PaymentError) {
// when communication is interrupted and cannot be confirmed, the original transactionRequestId is preserved; reconcile with query
}
})SafUploadResult fields: transactionRequestId, safStatus, offlineApproved (always true), and a masked result summary.
The response reflects only the reliable result of this operation or “result unknown”; it never misreports “request sent” as “received.” When it cannot be confirmed, reconcile with query — never infer status from “whether it was sent.”
Upload all
startSafUploadAll creates an asynchronous upload task, and getSafUploadTask queries progress. The request must carry an operationId generated by the integrator and unique for the current terminal; the terminal creates it idempotently by terminalId + operationId.
val request = SafUploadAllRequest.builder()
.setOperationId(UUID.randomUUID().toString()) // unique for the current terminal
.build()
client.startSafUploadAll(request, object : SafUploadTaskCallback {
override fun onSuccess(task: SafUploadTaskResult) {
val taskId = task.uploadTaskId // status starts as RUNNING
pollUploadTask(taskId)
}
override fun onFailure(error: PaymentError) {
// when the creation result is unknown, operationId is returned to recover the task
}
})
// query progress: either uploadTaskId or operationId
val query = SafUploadTaskQuery.builder()
.setUploadTaskId(taskId)
.build()
client.getSafUploadTask(query, object : SafUploadTaskCallback {
override fun onSuccess(task: SafUploadTaskResult) {
when (task.status) {
SafUploadTaskStatus.RUNNING -> pollAgainLater()
SafUploadTaskStatus.COMPLETED -> onAllUploaded(task)
SafUploadTaskStatus.COMPLETED_WITH_PENDING -> onPartiallyUploaded(task)
}
}
override fun onFailure(error: PaymentError) { }
})SafUploadTaskResult fields:
| Field | Type | Description |
|---|---|---|
uploadTaskId | String | Task identifier |
status | SafUploadTaskStatus | RUNNING / COMPLETED / COMPLETED_WITH_PENDING |
totalCount / processedCount | Int | Total count / processed count |
receivedCount / finalCount / pendingCount / failedCount | Int | Received / reached final result / still pending / failed counts |
items | List<SafUploadResult> | Each item’s masked result, transactionRequestId, and current status |
startSafUploadAll returns immediately after creation and does not wait synchronously for completion. Only one running task is allowed per terminal at a time; creating again returns the existing uploadTaskId without uploading in parallel. If the creation response is lost, recover the task with the original operationId.
SAF result callback
After registering a SafResultCallback, the terminal actively notifies you on key status changes, avoiding polling. Both AUTO and MANUAL upload modes trigger this callback.
client.registerSafResultCallback(object : SafResultCallback {
override fun onUploaded(result: SafUploadResult) {
// a SAF transaction was uploaded successfully (received by Sunbay)
}
override fun onFinalResult(result: SafUploadResult) {
// a SAF transaction reached a final result: APPROVED / DECLINED
}
})| Callback | Trigger |
|---|---|
onUploaded | A SAF transaction was uploaded successfully (received by Sunbay) |
onFinalResult | A SAF transaction’s online authorization reached a final result (APPROVED / DECLINED) |
The callback carries only a masked result summary, with no replayable fields. It is a best-effort notification and cannot be the sole basis: when the connection drops or the callback is lost, you still need to reconcile with query or the list interfaces.