POS Integration Flows
This page is the authoritative boundary map for the standalone POS. It separates retail operations from regulated money movement and shows how Emali POS, third-party POS systems, Emali Mobile Money, and external payment providers interact.
System boundary
flowchart LR
subgraph Merchant["Merchant estate"]
EPOS["Emali POS<br/>cashier, Android, backoffice"]
TPOS["Third-party POS / ERP"]
HW["Scanner, printer,<br/>cash drawer, card terminal"]
end
subgraph POS["Standalone emali2-pos"]
API["POS API<br/>sales, stock, receipts"]
DB[("POS PostgreSQL")]
WORKER["Payment and outbox workers"]
ADAPTERS["Provider-neutral adapters"]
end
subgraph Platform["Emali Mobile Money / Core"]
CORE["Store-scoped merchant<br/>collection API"]
WALLET["Customer wallet,<br/>approval and ledger"]
APP["Customer mobile app"]
end
subgraph Providers["Country-specific providers"]
MOBILE["Mobile-money adapter"]
CARD["Card-present service"]
FISCAL["Fiscalization adapter"]
end
EPOS --> API
HW --> EPOS
TPOS -->|"OAuth machine token + exact store binding"| API
API --> DB
DB --> WORKER
WORKER --> ADAPTERS
ADAPTERS -->|"EMALI_APP"| CORE
CORE --> APP
APP --> WALLET
WALLET --> CORE
ADAPTERS --> MOBILE
ADAPTERS --> CARD
ADAPTERS --> FISCAL
POS owns baskets, prices, stock reservations, tender state, receipts, shifts, terminal enrollment, and reconciliation. Emali Core owns customer identity, wallet approval, money movement, and ledger settlement. The systems share neither application code nor databases.
Country integrations are adapters selected by configuration. MobileMoneyPort
and the fiscalization port are generic contracts; Daraja/M-PESA and KRA/eTIMS
are optional Kenya implementations, not POS domain concepts.
Choose an integration pattern
Every merchant channel enters through the standalone POS API. Emali-native cashiers and existing third-party POS systems use different OAuth clients and terminal keys, but they converge on the same store-scoped sales, payment, refund and reconciliation invariants. Neither channel receives direct Core credentials or database access.
flowchart TD
START["Merchant needs a checkout channel"] --> CHANNEL{"Which cashier system stays at the lane?"}
CHANNEL -->|"Emali web or Android"| NATIVE["Emali-native POS client<br/>Authorization Code + PKCE<br/>enrolled terminal key"]
CHANNEL -->|"Existing POS, ERP or ecommerce"| MODE{"Who owns the retail record?"}
MODE -->|"Standalone POS"| RETAIL["Retail-backend integration<br/>catalog, stock, sale and receipt APIs"]
MODE -->|"Existing system"| PAYMENT["Payment-only integration<br/>mirrored server-priced POS sale"]
NATIVE --> POSAPI["Standalone POS API<br/>exact merchant/store scope"]
RETAIL --> MACHINE["Merchant machine token<br/>plus enrolled terminal proof"]
PAYMENT --> MACHINE
MACHINE --> POSAPI
POSAPI --> RAIL{"Store-enabled payment rail"}
RAIL -->|"EMALI_APP"| CORE["Emali Core merchant collection<br/>customer approval + wallet ledger"]
RAIL -->|"MOBILE_MONEY"| MOBILE["Configured country adapter<br/>for example Daraja"]
RAIL -->|"CARD_PRESENT"| CARD["Certified card-present boundary"]
CORE --> RESULT["Normalized authoritative outcome"]
MOBILE --> RESULT
CARD --> RESULT
RESULT --> POSAPI
The payment-only path still mirrors a POS sale because amount, currency, store, idempotency, refund allocation and reconciliation must remain server-owned. It is not a raw “charge this amount” proxy to Core.
Credential and trust matrix
| Connection | OAuth/client type | Where private material lives | POS authorization |
|---|---|---|---|
| Cashier Web → POS | Public browser client, Authorization Code + PKCE | Non-exportable WebCrypto terminal key in the browser; no OAuth client secret | Staff assignment + exact enrolled WEB terminal proof |
| Android → POS | Public native client, Authorization Code + PKCE | Android Keystore terminal key; no OAuth client secret | Staff assignment + exact enrolled Android terminal proof |
| Existing POS/ERP → POS | Confidential MERCHANT_INTEGRATION client; prefer private_key_jwt |
Merchant-controlled HSM, vault or mode-0600 key file |
Matching azp/client_id, pos-api audience, approved realm role, persisted organization/store binding, and terminal proof for mutations |
| POS service → Emali Core | Dedicated emali2-pos-service client; prefer private_key_jwt |
POS runtime secret mount only | Exact issuer, core-internal-api audience, POS_SERVICE client role, matching client claims, and active Core client/organization/store binding |
| POS adapter → external provider | Provider-specific server credential | Vault or external-secret reference selected by the store profile | Exact enabled capability and provider code; never selected from a client-supplied unbound provider |
Client secrets, private keys, terminal keys and provider credentials are never returned by POS APIs. Public web and Android packages contain no secret.
Emali customer-app payment
The cashier starts a merchant collection, not an agent cash-out. POS must never
call /api/v1/agents/withdraw/quote and does not accept an agentIdentifier in
this flow.
sequenceDiagram
autonumber
actor Cashier
participant POS as Emali POS
participant Worker as POS payment worker
participant Core as Emali Core
participant App as Customer mobile app
participant Ledger as Wallet ledger
Cashier->>POS: Select EMALI_APP, amount, customer reference
POS->>POS: Reserve stock and persist PENDING tender
POS->>POS: Encrypt customer reference in durable attempt
POS-->>Cashier: Prompt queued
Worker->>Core: POST store-scoped merchant-collection request<br/>OAuth service token + opaque idempotency key
Core->>Core: Verify issuer, audience, role, client and store binding
Core-->>App: Push/in-app/websocket approval prompt
App->>Core: Approve or deny with registered-device proof
alt Approved and wallet payment commits
Core->>Ledger: Atomic customer-to-merchant payment
Ledger-->>Core: Committed
Core-->>Worker: CONSUMED
Worker->>POS: Mark tender CAPTURED and complete sale
POS-->>Cashier: Receipt ready
else Denied, expired or cancelled
Core-->>Worker: DENIED / EXPIRED / CANCELLED
Worker->>POS: Mark tender FAILED
POS-->>Cashier: Offer another payment method
else Outcome cannot be confirmed
Worker->>POS: Move attempt to MANUAL_REVIEW
POS-->>Cashier: Keep tender pending and block cash fallback
end
Only Core status CONSUMED is a captured payment. APPROVED is not enough.
POS polls with bounded backoff, removes the encrypted routing reference after
dispatch, and keeps only a masked reference on the tender. A captured refund
uses the original authorization UUID and the same organization/store scope.
The POS service token must satisfy all of these checks:
| Check | Required value |
|---|---|
| Issuer | exact configured Emali realm issuer |
| Audience | core-internal-api |
azp and client_id |
both present, equal, and the configured POS service client |
| Role | POS_SERVICE from the approved POS client role source |
| Authorization | active persisted client + organization + store binding |
Raw customer references, till values, secrets, and idempotency values do not belong in resource identifiers or logs.
Third-party POS and ERP clients
A merchant may integrate an existing POS, ecommerce platform, warehouse system, or ERP with the standalone POS API. This is a separate direction from the POS-to-Core service integration.
sequenceDiagram
autonumber
participant Admin as Merchant administrator
participant IAM as Identity provider
participant Client as Third-party POS / ERP
participant POS as Standalone POS API
participant DB as POS database
participant Events as POS outbox / events
Admin->>IAM: Provision confidential client
Admin->>IAM: Add pos-api audience and approved realm role
Admin->>POS: Bind exact client ID to organization and stores
Client->>IAM: client_credentials<br/>(prefer private_key_jwt)
IAM-->>Client: JWT with matching azp + client_id
Client->>POS: API request + JWT + idempotency key
POS->>POS: Verify issuer, pos-api audience and realm role source
POS->>DB: Verify active exact store binding
POS->>DB: Apply store-scoped operation atomically
POS->>Events: Publish safe operational event
POS-->>Client: Stable response or safe error code
The token proves client identity; the POS-owned binding decides which merchant
and stores that identity can access. There is no global-store fallback.
Missing or mismatched azp/client_id, a role from an unrelated client, or an
inactive binding fails closed. Terminal mutations additionally require the
enrolled terminal's request signature.
A runnable, secret-safe catalog proof is available in
examples/third-party-pos/catalog-smoke.sh, with setup and checkout guidance
in examples/third-party-pos/README.md. The preferred path creates a
60-second RS256 or ES256 private_key_jwt assertion from a protected merchant
key, submits it from a temporary mode-0600 file, and removes it before the
catalog call. A transitional secret-file mode remains available for existing
clients. Both modes obtain a client-credentials token and print only a safe
catalog count; neither prints the token, assertion, private key, client secret,
merchant/store names, or catalog contents.
Protected third-party mutations can use
examples/third-party-pos/terminal-proof.py. The reference signer accepts the
exact body, an opaque idempotency key, and an enrolled EC or RSA private key
only through mode-0600, non-symlink files. It emits only the six terminal
proof headers. Its self-test shares the canonical v1 golden vector with the
server, Android terminal, web cashier, and remote UAT client. The caller
remains responsible for sending the exact signed bytes and a short-lived
pos-api OAuth token.
Use stable API versions, an opaque Idempotency-Key for every mutation, and
event/outbox delivery for downstream synchronization. Clients must not receive
Keycloak secrets through POS APIs.
Third-party checkout through Emali
An existing merchant POS can use Emali only as a payment rail, or use the
standalone POS as the authoritative retail backend. In both modes it calls the
standalone POS API—not Emali Core and never a platform database. For checkout
mutations, the merchant also enrolls a HARDWARE_POS terminal key and signs
the exact method, path, body hash, nonce, timestamp and idempotency key.
sequenceDiagram
autonumber
participant Lane as Existing POS / ecommerce
participant IAM as Emali identity
participant POS as Standalone POS API
participant Core as Emali Core
participant App as Customer app
Lane->>IAM: client_credentials using private_key_jwt
IAM-->>Lane: short-lived pos-api token
Lane->>POS: Create sale<br/>OAuth + exact store binding + terminal proof
POS-->>Lane: Sale UUID and server-priced totals
Lane->>POS: Collect WALLET / EMALI_APP<br/>opaque idempotency key + terminal proof
POS-->>Lane: 202 payment pending
POS->>Core: Store-scoped merchant collection<br/>POS service identity
Core-->>App: Customer approval prompt
App->>Core: Approve or deny
Core-->>POS: CONSUMED / DENIED / EXPIRED
Lane->>POS: Poll sale or tender status
POS-->>Lane: Captured receipt or safe terminal failure
Current integration modes are:
| Mode | What the external system owns | What Emali POS owns | Current status |
|---|---|---|---|
| Retail backend | Cashier UI/device only | Catalog, stock, sale, tender, receipt, refund and reconciliation | Implemented |
| Payment-only lane | External basket and receipt presentation | A mirrored server-priced sale and the electronic tender lifecycle | Implemented through versioned sale APIs; requires an enrolled proof-bearing terminal |
| Inventory/ERP sync | ERP master workflow | Store-scoped catalog, stock and inventory documents | Implemented for authenticated API reads/writes |
| Event-driven export | ERP/accounting/BI consumer | Signed outbound delivery with replay and retry | Planned; consumers must poll safe read models until the webhook-delivery phase is implemented |
The payment-only mode still creates a POS sale record so amount, currency, store, tender, idempotency, refund and reconciliation invariants remain server-owned. The external lane must not send PAN, PIN, mobile-wallet secrets, Emali customer credentials, or an agent identifier.
Other payment integrations
Every electronic provider returns a normalized POS result:
| Provider family | Typical initiation | Captured evidence | Important boundary |
|---|---|---|---|
| Emali customer app | Core merchant-collection request | Core CONSUMED |
Never use agent cash-out |
| External mobile money | Country adapter prompt or merchant collection | Provider query/callback confirmed | M-PESA is one optional adapter |
| Card present | Certified terminal/card service handoff | Acquirer-approved transaction | POS never handles PAN, PIN, track or EMV cryptograms |
| Cash | Cashier confirmation | Server-owned cash tender | No external provider |
| Loyalty | POS loyalty authorization | POS ledger/points result | Not platform money |
A timeout is not a decline. When an external write may have succeeded but the answer is unknown, POS keeps the tender unresolved and sends it to manual reconciliation. It must not silently accept cash and risk charging twice.
Error isolation
The customer cash-out/agent quote API is a different product flow owned by the
mobile-money clients and Core. A failure there must be mapped by those clients
to a stable customer-safe state such as agent_unavailable; an upstream error
body or submitted identifier must never be rendered directly.
As of the 26 July 2026 source audit, the standalone POS is isolated from that
route and has a CI guard enforcing the boundary. Core/mobile commit
c0ab1c6d245df43f63343422e491e1511dec5178 prepares the governed
non-enumerating contract in an isolated worktree: public agent cash-out routes
remain retired with 410 Gone, and Android/iOS no longer submit or display the
legacy identifier-bearing request or raw response body. Its focused Core,
Android, iOS contract, and iOS Simulator checks pass. That commit has not been
merged as one reconciled mobile release; Android/iOS artifact rollout remains a
platform release gate. The currently deployed Core API and worker run
sha256:a5e4e13d3c62739ca7610b24512c889c87463deb9a30c4f6690ab63a31e0912f
and the Core jar contains the 410 tombstone. The reported raw 404 therefore
came from an earlier/different request path or an older client, not from the
current public tombstone controller.
Deployment-facing endpoints
| Surface | Canonical TEST hostname | Current pilot alias |
|---|---|---|
| Backoffice | pos.test.emali2.damplabs.com |
backoffice.pos.damplabs.com |
| Cashier | pos-cashier.test.emali2.damplabs.com |
cashier.pos.damplabs.com |
| API | pos-api.test.emali2.damplabs.com |
none; production API remains disabled |
| Docs | docs-pos.test.emali2.damplabs.com |
docs.pos.damplabs.com |
| Receipts | receipts.test.emali2.damplabs.com |
receipts.pos.damplabs.com |
The four pilot aliases currently return HTTP 200 and deliberately use TEST identity, API origins and data. They are not evidence of a production deployment. Production still requires an independent database, object storage, identity clients, secrets, certificates, provider credentials, monitoring and rollback evidence before its API is scaled above zero.