Skip to Content
SDKsTaplink SDK (Local Integration)Android (Kotlin)SAF Offline Transactions

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:

FieldTypeDescription
enabledBooleanWhether offline transactions are currently accepted
terminalIdStringTerminal identifier
updatedAtLongState update time
riskNoticeString?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:

FieldTypeDescription
canProcessOnlineBooleanWhether it can currently process online transactions
offlineModeEnabledBooleanWhether Offline mode is enabled
reasonString?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:

FieldTypeRequiredDescription
singleTransactionLimitBigDecimal?NoMaximum total amount of a single transaction (including tip)
cumulativeAmountLimitBigDecimal?NoMaximum cumulative amount of local Pending items
cumulativeCountLimitInt?NoMaximum 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.

ModeMeaning
AUTO (default)After connectivity recovers, the terminal automatically uploads its Pending items one by one; no manual trigger needed
MANUALTriggered 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).

safModeSemantics
ONLINE_ONLYOnline only; an online failure is a failure and it never enters SAF
OFFLINE_ONLYForced 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:

FieldTypeDescription
offlineApprovedBooleanSAF-origin marker; stays true once accepted; false for non-SAF transactions
safStatusSafStatus?SAF lifecycle status; null for non-SAF transactions
safDetailSafDetail?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:

ValueMeaning
PENDINGAccepted locally, awaiting upload
PROCESSINGUploaded and received by Sunbay, final result not returned
APPROVEDOnline authorization succeeded
DECLINEDAuthorization declined
VOIDEDVoided 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:

FieldTypeDescription
transactionRequestIdStringTransaction request ID
referenceOrderIdStringOrder ID
amountBigDecimalFinalized total amount (including tip, tax, custom fee, pricing-strategy amounts)
timeoutSecondsIntRemaining 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:

safStatustransactionStatusMeaning
PENDINGPROCESSINGAccepted locally, Sunbay receipt not confirmed
PROCESSINGPROCESSINGReceived by Sunbay, final result not returned
APPROVEDSUCCESSFinally approved
DECLINEDFAILEDFinally declined
VOIDEDFAILEDVoided 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:

FieldTypeDescription
uploadTaskIdStringTask identifier
statusSafUploadTaskStatusRUNNING / COMPLETED / COMPLETED_WITH_PENDING
totalCount / processedCountIntTotal count / processed count
receivedCount / finalCount / pendingCount / failedCountIntReceived / reached final result / still pending / failed counts
itemsList<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 } })
CallbackTrigger
onUploadedA SAF transaction was uploaded successfully (received by Sunbay)
onFinalResultA 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.

Last updated on