ZealDeveloper Hub Beta
EN ES
App-to-App Integration · No SDK · Android

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:

1 · Set terminal infosetTerminalInfo
Announces this terminal to Zeal and asks Zeal to register its handlers. Always do this first.
2 · Hash cardhashCard
Demo only. Stands in for the Card Fingerprint your payment app generates at card read. The example has no card reader or key, so it hashes the typed-in card number with plain SHA-256 to get card_id. No network — just logs the result.
3 · Member checkfetchSaleBenefits
Mimics the host-to-host call that fetches the sale-benefit body: POST the hashed card data to the Zeal loyalty web service and get back a sale-benefit response (customer_identified, transaction_processed, has_voucher).
4 · After card detectedAFTER_CARD_DETECTED
Send card + amount, get back an adjusted amount / status. Sends card_id as loyalty_token and attaches the sale-benefit response.
5 · eReceiptE_RECEIPT
Send receipt data, get back a 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:

ZealConstants.ktcontract
Every action string and data key in one place — the wire contract.
ZealRegistration.ktmodel
Data model (parsed from JSON with Gson) describing one handler Zeal registered.
ZealClient.ktcore
Discovery, storing registrations, sending requests, awaiting results.
ZealRegisterReceiver.ktreceiver
Receives Zeal’s “I handle event X” broadcasts and stores them.
ZealResultReceiver.ktreceiver
Receives the result of a request and hands it back to ZealClient.
ZealOpenActivityReceiver.ktreceiver
Handles Zeal asking your app to open a screen.
ZealRegisterResultReceiver.ktreceiver
Receives Zeal’s acknowledgement that registration succeeded.

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_status

Step 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:

1 · Stablerequirement
The same physical card always produces the same fingerprint across purchases, terminals and merchants.
2 · Irreversiblerequirement
Nobody without your key or vault can recover the PAN from it. The key or vault stays with you and is never shared with Zeal.
3 · Fixed for the integrationrequirement
Keep the same key or vault for the life of the integration. Changing it changes every fingerprint and orphans every enrolled card.

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

RequestHashMap
cardBin, loyalty_token, currency_code, decimal_shift, amount, Masked_Pan, expired_Date
Requestoptional
par — EMV Payment Account Reference, forwarded to the Zeal backend
Response addsHashMap
transaction_id, decimal_shift, amount, currency_code

eReceipt — E_RECEIPT

RequestHashMap
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_number
Requestoptional
par — EMV Payment Account Reference, forwarded to the Zeal backend
Response addsHashMap
payment_reference

Build & 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.zip

Or read the source alongside this guide — the seven files listed in What it demonstrates are the whole integration.