Zeal Direct App-to-App
Hook into the Zeal payment flow on Android terminals without the Communicator SDK — plain Intent broadcasts and BroadcastReceivers, with a runnable example app to download.
Prefer a typed Kotlin API? The Communicator SDK wraps this same broadcast
protocol in FlowHandler calls, so you don’t write the receivers yourself.
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. No network — just logs the result.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.Don’t ship the example’s card handling. It hashes the card number with plain, unkeyed SHA-256, which can be
reversed, and it sends the whole card number as Masked_Pan. In production, loyalty_token must carry a Card
Fingerprint that is stable and irreversible — see
Card fingerprint — and Masked_Pan carries only the last four digits, as in
the snippet below. The same rules apply with or without the SDK.
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, with the Card fingerprint in loyalty_token:
lifecycleScope.launch {
val req = mapOf(
"cardBin" to card.take(6),
"loyalty_token" to cardFingerprint, // never the PAN
"currency_code" to "818",
"decimal_shift" to "0",
"amount" to "100.00",
"Masked_Pan" to card.takeLast(4),
"expired_Date" to "1234",
// Optional. Omit the key entirely when you have no PAR.
"par" to "V0010013000000000000000000001",
)
val result = ZealClient.afterCardDetected(this@MainActivity, ZealConstants.TXN_SALE, req)
// result["tran_status"], result["amount"], ...
}Card fingerprint
loyalty_token carries the Card Fingerprint, also called the Unique Card Identifier. It is the value
that tells Zeal which card was used: your payment app sends it with every card event (AFTER_CARD_DETECTED and
E_RECEIPT), and Zeal recognises a returning customer by it.
Your payment app produces the card fingerprint at card read, inside its own secure card processing, in one of two ways:
- Keyed hash — an HMAC-SHA-256 of the full PAN, with a key you hold.
- Vault lookup — a tokenization vault that returns the same identifier every time it sees the same card, so the fingerprint isn’t derived from the PAN itself.
The card number (PAN) is never sent to Zeal. The BIN (cardBin) and last four digits (Masked_Pan) you also send
are not used for identity.
The card fingerprint must meet three requirements:
If your payment app already produces a stable per-card hash for its own purposes, such as a PAN hash exposed by your terminal SDK, you can send that value as the card fingerprint, provided it meets all three requirements.
Zeal doesn’t prescribe how you generate it. It prescribes stability and irreversibility.
Can’t generate a card fingerprint that meets these requirements? Contact Zeal before you build the integration.
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_Datepar — EMV Payment Account Reference, forwarded to the Zeal backendtransaction_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_numberpar — EMV Payment Account Reference, forwarded to the Zeal backendpayment_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.