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")

Shared setup

Every action below uses the same preamble: import the SDK, create a FlowHandler, and pick a context plus transaction type.

import com.zeal.zeal_communicator_sdk.*

val flowHandler = FlowHandler()
val context: Context = this // replace with your actual context
val transactionType: TransactionTypes = TransactionTypes.Sale // or Void / Refund

Available transaction actions

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. Use digitalReceipt on FlowHandler with DigitalReceiptRequest.
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
)

Request and response models for each action are documented in the sale path and Refund and Reversal sections below — AfterCardDetectedRequest, DigitalReceiptRequest, RefundTransactionRequest, MarkAsReverseRequest, and their response counterparts.

AfterCardDetected

Triggered after card entry and before card verification. Run this step first in the sale path, then continue with Digital Receipt.

Send an action

Build an AfterCardDetectedRequest and subscribe on FlowHandler:

val request = AfterCardDetectedRequest(
  loyalty_token = "4111111111111111",
  currency_code = "818",
  decimal_shift = "2",
  amount = "100.00",
  phone_number = "",
  masked_pan = "1111",
  cardBin = "411111"
)

flowHandler.afterCardDetected(
  context,
  transactionType,
  request
).subscribe({ response ->
  // AfterCardDetectedResponse — check tran_status, amount, transaction_id
}) { throwable ->
  if (throwable is NotRegisteredFlowException) {
      // target application did not register an action for this step
  } else {
      // other exception
  }
}

Request and response models

package com.zeal.zeal_communicator_sdk.communicationRequests

class AfterCardDetectedRequest(
  var loyalty_token: String,
  var currency_code: String,
  var decimal_shift: String, // optional
  var amount: String, // in double format
  var phone_number: String = "",
  var masked_pan: String = "", // last 4 digits of card number
  var cardBin: String = "" // first 6 or 8 digits of card number
)
package com.zeal.zeal_communicator_sdk.communicationResponses

class AfterCardDetectedResponse(
  tran_status: String, // FULLY_COVERED, NEW_AMOUNT, SAME_AMOUNT, ERROR, CANCEL
  var transaction_id: String? = null, // redemption id needed to complete redemption at the end
  third_party_request_type: String,
  var decimal_shift: String,
  var amount: String,
  var currency_code: String
) : BaseResponse(third_party_request_type, tran_status)

Digital Receipt

Triggered after the transaction is approved and before the receipt is printed. Call this after AfterCardDetected in the sale path. The wire action is eReceipt; the SDK method and models use digitalReceipt / DigitalReceipt*.

Send an action

val request = DigitalReceiptRequest(
  currency_code = "818",
  decimal_shift = "2",
  amount = "100.00",
  entry_mode = "CHIP",
  card_brand = "VISA",
  card_type = "CREDIT",
  customer_receipt_data = "...",
  merchant_receipt_data = "...",
  loyalty_token = "4111111111111111",
  third_party_approval_code = "123456",
  third_party_tran_date = "20260422",
  third_party_tran_time = "143055",
  phone_number = ""
)

flowHandler.digitalReceipt(
  context,
  transactionType,
  request
).subscribe({ response ->
  // DigitalReceiptResponse — payment_reference for later reverse calls
}) { throwable ->
  if (throwable is NotRegisteredFlowException) {
      // target application did not register an action for this step
  } else {
      // other exception
  }
}

Request and response models

package com.zeal.zeal_communicator_sdk.communicationRequests

class DigitalReceiptRequest(
  var currency_code: String,
  var decimal_shift: String,
  var amount: String,
  var entry_mode: String,
  var card_brand: String,
  var card_type: String,
  var customer_receipt_data: String,
  var merchant_receipt_data: String,
  var loyalty_token: String,
  var third_party_approval_code: String,
  var third_party_tran_date: String,
  var third_party_tran_time: String,
  var phone_number: String = ""
)
package com.zeal.zeal_communicator_sdk.communicationResponses

class DigitalReceiptResponse(
  tran_status: String,
  third_party_request_type: String,
  var payment_reference: String? = null
) : BaseResponse(third_party_request_type, tran_status)

Refund and Reversal

Refund and reverse are separate from the sale path. Use them when undoing benefits from a completed sale — not as the next step after Digital Receipt.

Refund

Triggered when a refund is performed. Mark the transaction as refunded and reverse benefits from the original sale; link with an optional transaction_id or process standalone.

Send an action

val request = RefundTransactionRequest(
  transaction_id = "sale-txn-id", // optional — link to the original sale
  currency_code = "818",
  decimal_shift = "2",
  amount = "100.00",
  card_brand = "VISA",
  card_type = "CREDIT",
  loyalty_token = "4111111111111111",
  masked_pan = "1111",
  card_expiration_date = "1230",
  auth_code = "123456"
)

flowHandler.refund(
  context,
  TransactionTypes.Refund,
  request
).subscribe({ response ->
  // RefundTransactionResponse
}) { throwable ->
  if (throwable is NotRegisteredFlowException) {
      // target application did not register an action for this step
  } else {
      // other exception
  }
}

Request and response models

package com.zeal.zeal_communicator_sdk.communicationRequests

class RefundTransactionRequest(
  val transaction_id: String? = null, // original sale transaction id (optional)
  val currency_code: String,
  val decimal_shift: String,
  val amount: String,
  val card_brand: String,
  val card_type: String,
  val loyalty_token: String, // card number or generated token
  val masked_pan: String? = null,
  val card_expiration_date: String? = null,
  val auth_code: String? = null
)
package com.zeal.zeal_communicator_sdk.communicationResponses

class RefundTransactionResponse(
  tran_status: String,
  third_party_request_type: String,
  message: String = ""
) : BaseResponse(third_party_request_type, tran_status, message)

Reversal

Triggered when a completed sale must be reversed. Pass the payment_reference from Digital Receipt to undo associated points, rewards, or vouchers.

Send an action

val request = MarkAsReverseRequest(
  payment_reference = "payment-ref-from-digital-receipt",
  reversal_payment_reference = "" // optional
)

flowHandler.reverse(
  context,
  TransactionTypes.Void,
  request
).subscribe({ response ->
  // MarkAsReverseResponse
}) { throwable ->
  if (throwable is NotRegisteredFlowException) {
      // target application did not register an action for this step
  } else {
      // other exception
  }
}

Request and response models

package com.zeal.zeal_communicator_sdk.communicationRequests

class MarkAsReverseRequest(
  var payment_reference: String, // original sale payment reference/id
  var reversal_payment_reference: String = "" // optional
)
package com.zeal.zeal_communicator_sdk.communicationResponses

class MarkAsReverseResponse(
  tran_status: String,
  third_party_request_type: String,
  message: String = ""
) : BaseResponse(third_party_request_type, tran_status, 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.