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

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

beforeAmountEntryaction
Triggered after the amount-entry step and before subsequent steps. Lets the third-party app run custom logic or collect data before proceeding.
afterAmountEnteraction
Triggered before card detection. The third-party app can handle the transaction with alternative methods other than credit/debit cards.
afterCardDetectedaction
Triggered after card entry and before card verification. Provides total amount, currency, and the first 6 digits of the PAN — useful for discounts or custom validation.
eReceiptaction
Triggered after the transaction is approved and before the receipt is printed.
afterTransactionaction
Triggered after the receipt is processed and before returning to the idle screen. Use for surveys, phone-number capture, or loyalty enrolment.
reverseaction
Triggered when a completed sale must be reversed. Mark the original transaction as reversed and undo any associated points, rewards, or vouchers.
refundaction
Triggered when a refund is performed. Mark the transaction as refunded and reverse any benefits granted by the original sale; can be linked to a previous sale or processed standalone.

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:

1 · Set terminal infosetTerminalInfo
Announces this terminal to Zeal and asks Zeal to register its handlers. Always do this first.
2 · Hash cardhashCard
Mimics the card hashing a real terminal/Zeal does from a live card read — the example has no reader, so it derives the tokens from the typed-in card number: card_id = SHA-256(card), card_pseudo_token = SHA-256(card + amount). No network — just logs the two tokens.
3 · Member checkfetchSaleBenefits
Mimics the host-to-host call that fetches the sale-benefit body: POST the two tokens 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.

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:

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

RequestHashMap
cardBin, loyalty_token, currency_code, decimal_shift, amount, Masked_Pan, expired_Date
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
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.