Zeal App-to-App Integration
Hook into the Zeal payment flow on Android terminals — discounts, rewards, eReceipts, refunds. Two ways to integrate: the typed Communicator SDK, or Direct App-to-App with plain Android broadcasts. Same protocol, same result.
Two ways to integrate app-to-app — same result. Use the Communicator SDK for a typed Kotlin API, or go Direct App-to-App with plain Android broadcasts and no dependency. Both drive the same Zeal payment flow through the same broadcast protocol; pick whichever fits your app.
Communicator SDK
The Third-Party API is a service provided by Zeal to support a Standalone Payment Application on Android terminals during the payment flow. This guide specifies how a payment application integrates with the Standalone Zeal application.
How it works. The Third-Party API uses Android broadcast messages routed through manifest-driven receivers — there are no REST endpoints to call from outside the device.
Installation
Step 1 · Add the Maven repository
Add the following Maven repository to dependencyResolutionManagement in settings.gradle:
dependencyResolutionManagement {
repositories {
maven {
url = uri("https://maven.pkg.github.com/zeal-io/Zeal-POS-App-Communicator-SDK")
credentials {
username = "*USERNAME*"
password = "*PASSWORD*"
}
}
}
}Step 2 · Add the dependency
dependencies {
// ...
implementation "com.zeal.zealmodule:zeal_communicator_sdk:1.2.20"
}Step 3 · Initialize the Zeal app
Call this method at the beginning of the app lifecycle, or before starting the payment flow, to initialize the Zeal app with your merchant data:
FlowHandler() .setTerminalInfo(context, "serialNumber", "terminalId", "merchantId")
Send an action
To perform actions before or after payment steps, follow these five steps.
1 · Import the necessary classes
import com.zeal.zeal_communicator_sdk.*2 · Initialize FlowHandler
val flowHandler = FlowHandler()3 · Prepare context and transaction type
val context: Context = this // replace with your actual context val transactionType: TransactionTypes = TransactionTypes.Sale // or TransactionTypes.Void
4 · Build the request object
Create a request for the specific action — for example, BeforeAmountEntryRequest:
val request = BeforeAmountEntryRequest( currency_code = "818", decimal_shift = "2", amount = "1000" )
5 · Subscribe to the action
Use the FlowHandler instance to call the action and handle response + exceptions:
flowHandler.beforeAmountEntry( context, transactionType, // select the type of the transaction request // request for the specific action ).subscribe({ // response with beforeAmountEntry data }) { throwable -> // on exception if (throwable is NotRegisteredFlowException) { // target application did not register an action for this step } else { // other exception } }
Available transaction actions
Transaction types
The TransactionTypes enum defines the available transaction kinds:
enum class TransactionTypes {
Sale,
Void,
Refund,
PreAuth,
Completion
}tran_status values
Every response carries a tran_status describing the redemption outcome:
tran_status {
FULLY_COVERED, // discount covers the entire transaction amount
NEW_AMOUNT, // discount partially covers the amount; remainder returned
SAME_AMOUNT, // no redemption occurred
ERROR, // error on Zeal or redemption side
CANCEL // skipped or chose not to redeem points
}Base response model
All response classes extend BaseResponse:
package com.zeal.zeal_communicator_sdk.communicationResponses
open class BaseResponse(
var third_party_request_type: String,
var tran_status: String,
var message: String
)See the API Reference for every request and response model — BeforeAmountEntryRequest, AfterCardDetectedRequest, DigitalReceiptRequest, RefundTransactionRequest, MarkAsReverseRequest, and their response counterparts.
Full example
End-to-end implementation of beforeAmountEntry, including exception handling:
import com.zeal.zeal_communicator_sdk.* fun performBeforeAmountEntryAction(context: Context) { val flowHandler = FlowHandler() val transactionType = TransactionTypes.Sale val request = BeforeAmountEntryRequest( currency_code = "818", decimal_shift = "2", amount = "1000" ) flowHandler.beforeAmountEntry( context, transactionType, request ).subscribe({ // Handle the successful response Log.d("TransactionAction", "Response: $it") }, { throwable -> if (throwable is NotRegisteredFlowException) { Log.e("TransactionAction", "UnRegisteredFlowException: ${throwable.message}") } else { Log.e("TransactionAction", "Exception: ${throwable.message}") } }) }
Direct App-to-App
Direct App-to-App is the dependency-free way to integrate: your app talks to the Zeal POS app
using nothing but Android broadcasts — Intent + BroadcastReceiver. It does the same job as the
official zeal_communicator_sdk, but with the protocol written out in plain Kotlin so you can see
exactly what an integration sends and receives, and copy the pieces into your own app.
An integration boils down to four moves: discover Zeal → get registered → send a request → wait
for the result. No network calls, no AIDL, no bound Service — only Intent broadcasts, a bit of
state in SharedPreferences, and a coroutine that waits for the reply.
What it demonstrates
The example has one screen with five buttons. Each maps to one step of a real integration:
card_id = SHA-256(card), card_pseudo_token = SHA-256(card + amount). No network — just logs the two tokens.customer_identified, transaction_processed, has_voucher).card_id as loyalty_token and attaches the sale-benefit response.payment_reference. Also sends card_id as loyalty_token.The on-screen log shows what was sent and the result that came back.
The whole integration is about seven small files. Their roles:
ZealClient.How it flows
Two phases: a one-time registration handshake, then a per-transaction request/response for every flow.
A · Discovery & registration (once)
──────────────────────────────────────
Your app → queryBroadcastReceivers(ACTION_REGISTER_FROM_COMMUNICATOR)
Android ← matching Zeal receiver(s)
Your app → ACTION_REGISTER_FROM_COMMUNICATOR + terminal IDs, response_pkg/cls
Zeal ← REGISTER_APP — register_request JSON (one per event type)
← SELF_REGISTRATION_RESULT (ack — optional)
B · Per transaction
──────────────────────────────────────
Your app → explicit broadcast to Zeal receiver (parameters = HashMap)
· suspend, await result (20 s timeout)
Zeal ← ACTION_THIRD_PARTY_RESULT — parameters HashMap
· resume coroutine, read tran_statusStep A — Discover & register (setTerminalInfo). Your app stores the terminal IDs, asks Android
“who listens for the Zeal discovery action?” via
queryBroadcastReceivers(ACTION_REGISTER_FROM_COMMUNICATOR), then sends an explicit broadcast to
each match carrying the terminal IDs plus response_pkg / response_cls (where Zeal should send the
registration ack). Zeal answers by broadcasting one REGISTER_APP per event type it supports —
each carries a register_request JSON blob you parse into a ZealRegistration and store keyed by
event type (AFTER_CARD_DETECTED, E_RECEIPT, …).
Step B — Run a flow. Look up the stored registration for the event type. Build the request as a
HashMap<String, String>: your business fields plus the request type (third_party_request_type)
plus the callback-wiring keys. Send an implicit com.zeal_api.ACTION_* broadcast (best-effort), then
the authoritative explicit broadcast to the exact receiver Zeal registered. Suspend and wait — Zeal
does its work and broadcasts the result via ACTION_THIRD_PARTY_RESULT, with a
Serializable HashMap<String, String> under the parameters extra. Read tran_status to decide
what to do next (NEW_AMOUNT, FULLY_COVERED, SAME_AMOUNT, ERROR, CANCEL).
Installation
Step 1 · Manifest
Declare how Zeal can find you (<queries>) and the receivers Zeal will broadcast to:
<queries>
<intent>
<action android:name="com.zeal.zealapplication.ACTION_REGISTER_FROM_COMMUNICATOR" />
</intent>
</queries>
<application ...>
<receiver android:name=".receivers.ZealRegisterReceiver" android:exported="true">
<intent-filter>
<action android:name="com.zeal.api.communicator.action.REGISTER_APP" />
</intent-filter>
</receiver>
<receiver android:name=".receivers.ZealResultReceiver" android:exported="true">
<intent-filter android:priority="4">
<action android:name="com.zeal.api.communicator.action.ACTION_THIRD_PARTY_RESULT" />
</intent-filter>
</receiver>
<receiver android:name=".receivers.ZealOpenActivityReceiver" android:exported="true">
<intent-filter android:priority="4">
<action android:name="com.zeal.api.communicator.action.ACTION_THIRD_PARTY_REQUEST_OPEN_ACTIVITY" />
</intent-filter>
</receiver>
<receiver android:name=".receivers.ZealRegisterResultReceiver" android:exported="true">
<intent-filter>
<action android:name="com.zeal.api.communicator.action.SELF_REGISTRATION_RESULT" />
</intent-filter>
</receiver>
</application>Receivers must be exported="true" — Zeal is a different app sending you explicit broadcasts.
During development you may add QUERY_ALL_PACKAGES, but for a Play Store build drop the broad
permission and rely on the scoped <queries> element above.
Step 2 · Dependencies
Only two beyond the usual AndroidX libraries — one to parse the registration JSON, one to bridge the broadcast receiver back to a coroutine:
dependencies {
implementation("com.google.code.gson:gson:2.10.1")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.4")
// + appcompat / material / core-ktx as usual
}
android {
buildFeatures { viewBinding = true } // optional; the demo UI uses it
}Step 3 · Constants
Put every action string and key in one file so they can’t drift. Zeal matches on these exact strings — copy them verbatim:
object ZealConstants { const val ACTION_REGISTER_FROM_COMMUNICATOR = "com.zeal.zealapplication.ACTION_REGISTER_FROM_COMMUNICATOR" const val ACTION_REGISTER_APP = "com.zeal.api.communicator.action.REGISTER_APP" const val ACTION_THIRD_PARTY_RESULT = "com.zeal.api.communicator.action.ACTION_THIRD_PARTY_RESULT" // Event types — NOTE: the AfterCardDetected event travels on the wire as "AFTER_TOTAL_AMOUNT"; // keep that string value verbatim (Zeal matches on it) even though the constant is AFTER_CARD_DETECTED. const val AFTER_CARD_DETECTED = "AFTER_TOTAL_AMOUNT" const val E_RECEIPT = "E_RECEIPT" // Intent extras const val EXTRA_PARAMETERS = "parameters" const val EXTRA_TERMINAL_ID = "terminalId" const val EXTRA_REGISTER_REQUEST = "register_request" const val EXTRA_RESPONSE_PKG = "response_pkg" const val EXTRA_RESPONSE_CLS = "response_cls" // Callback-wiring keys placed inside the parameters map const val PARAM_REQUEST_TYPE = "third_party_request_type" const val PARAM_THIRD_PARTY_CALLER = "third_party_caller" // ... see ZealConstants.kt in the sample for the full list }
Discover & register
The one-time handshake that has to run before any flow will work. Persist the terminal IDs, ask Android for Zeal’s receivers, and tell each one where to send its registration ack.
fun setTerminalInfo( ctx: Context, serial: String, terminalId: String, merchantId: String, ) { // 1. Remember the terminal so later requests can include it. prefs(ctx).edit() .putString(EXTRA_TERMINAL_SERIAL, serial) .putString(EXTRA_TERMINAL_ID, terminalId) .putString(EXTRA_MERCHANT_ID, merchantId) .apply() // 2. Find Zeal by the discovery action it listens for. val discovery = Intent(ACTION_REGISTER_FROM_COMMUNICATOR) val receivers = ctx.packageManager .queryBroadcastReceivers(discovery, PackageManager.GET_RECEIVERS) // 3. Tell each Zeal receiver our IDs + where to send the registration ack. receivers.forEach { ri -> val info = ri.activityInfo ?: return@forEach ctx.sendBroadcast(Intent(ACTION_REGISTER_FROM_COMMUNICATOR).apply { setClassName(info.packageName, info.name) // explicit = only Zeal putExtra(EXTRA_TERMINAL_SERIAL, serial) putExtra(EXTRA_TERMINAL_ID, terminalId) putExtra(EXTRA_MERCHANT_ID, merchantId) putExtra(EXTRA_RESPONSE_PKG, ctx.packageName) putExtra(EXTRA_RESPONSE_CLS, ZealRegisterResultReceiver::class.java.name) }) } }
When Zeal replies, store what it tells you keyed by event type:
class ZealRegisterReceiver : BroadcastReceiver() { override fun onReceive(context: Context, intent: Intent) { val json = intent.extras?.getString(EXTRA_REGISTER_REQUEST) ?: return val reg = Gson().fromJson(json, ZealRegistration::class.java) ?: return // "Zeal handles <eventType> via <package>/<receiver> using <action>" — keyed by event type. if (reg.registrationType == REGISTRATION_TYPE_REGISTER) { ZealClient.storeRegistration(context, reg.thirdPartyEventType, json) } else { ZealClient.removeRegistration(context, reg.thirdPartyEventType) } } }
After this, your app knows which Zeal package/receiver/action handles each event type.
Send & await a result
The trick: a BroadcastReceiver delivers the answer, but you want a clean suspend call. Bridge the
two with a CompletableDeferred stored in a map keyed by event type.
private val pending = ConcurrentHashMap<String, CompletableDeferred<Map<String, String>>>()
private suspend fun sendFlow(
ctx: Context,
eventType: String,
txnType: String,
req: Map<String, String>,
): Map<String, String> {
// Must be registered first (from the setTerminalInfo handshake).
val reg = getRegistration(ctx, eventType)
?: throw NotRegisteredException("$eventType is not registered yet")
// Your business fields + request type + callback wiring (who to reply to).
val params = HashMap(req).apply {
put(PARAM_REQUEST_TYPE, eventType)
put(PARAM_THIRD_PARTY_CALLER, ctx.packageName)
put(PARAM_RESPONSE_RECEIVER, ZealResultReceiver::class.java.name)
put(PARAM_RESULT_CALLBACK, ACTION_THIRD_PARTY_RESULT)
// + open-activity wiring (see ZealClient.kt in the sample)
}
val deferred = CompletableDeferred<Map<String, String>>()
pending[eventType] = deferred
// Explicit broadcast to the exact receiver Zeal registered.
ctx.sendBroadcast(Intent(reg.thirdPartyAction).apply {
setClassName(reg.thirdPartyPackage, reg.thirdPartyReceiver)
putExtra(EXTRA_PARAMETERS, HashMap(params)) // Serializable HashMap<String, String>
// + terminal IDs
})
// Suspend until ZealResultReceiver delivers — or give up after 20 s.
return withTimeout(20_000L) { deferred.await() }
}The result receiver just unpacks the map and resumes the waiter:
class ZealResultReceiver : BroadcastReceiver() { override fun onReceive(context: Context, intent: Intent) { @Suppress("DEPRECATION", "UNCHECKED_CAST") val raw = intent.getSerializableExtra(EXTRA_PARAMETERS) as? Map<*, *> val params = raw?.entries?.associate { (k, v) -> k.toString() to (v?.toString() ?: "") } ?: emptyMap() ZealClient.deliverResult(params) // -> pending[type]?.complete(params) } }
And a caller looks like this:
lifecycleScope.launch {
val req = mapOf(
"cardBin" to card.take(6),
"loyalty_token" to card,
"currency_code" to "818",
"decimal_shift" to "0",
"amount" to "100.00",
"Masked_Pan" to card,
"expired_Date" to "1234",
)
val result = ZealClient.afterCardDetected(this@MainActivity, ZealConstants.TXN_SALE, req)
// result["tran_status"], result["amount"], ...
}Data contract
Send keys exactly as written, including the non-snake-case ones (cardBin, Masked_Pan,
expired_Date). Values are always strings.
Every response carries these base fields: third_party_request_type, tran_status, message.
tran_status is one of:
tran_status {
FULLY_COVERED, // discount covers the entire transaction amount
NEW_AMOUNT, // discount partially covers the amount; remainder returned
SAME_AMOUNT, // no redemption occurred
ERROR, // error on Zeal or redemption side
CANCEL // skipped or chose not to redeem points
}AfterCardDetected — AFTER_CARD_DETECTED
cardBin, loyalty_token, currency_code, decimal_shift, amount, Masked_Pan, expired_Datetransaction_id, decimal_shift, amount, currency_codeeReceipt — E_RECEIPT
currency_code, decimal_shift, amount, entry_mode, card_brand, card_type, customer_receipt_data, merchant_receipt_data, loyalty_token, third_party_approval_code, third_party_tran_date, third_party_tran_time, phone_numberpayment_referenceBuild & run
Requirements: Android Studio (with bundled JDK 17), minSdk 24, compileSdk/targetSdk 34,
and a device or terminal that also has the Zeal app installed — the example only does something
useful when Zeal is there to answer.
./gradlew :app:assembleDebug # build the debug APK ./gradlew :app:installDebug # install on a connected device/terminal
The APK is written to app/build/outputs/apk/debug/app-debug.apk.
Typical flow when testing: enter the terminal serial / ID / merchant ID and tap Set terminal
info, wait for ZealRegisterReceiver to log the registrations it stored, then tap
After card detected / eReceipt and read the result in the on-screen log.
Download
Grab the full source as a ZIP and open it in Android Studio:
Download zeal-raw-example.zipOr read the source alongside this guide — the seven files listed in What it demonstrates are the whole integration.