Events
Producers post events; Customer 360 resolves each one into the identity graph and appends it to a single global stream. This page covers the ingest endpoint, the envelope every event shares, the identity block, the money convention, idempotency, and the type registry the service validates against.
Ingest a batch
POST /v1/events takes a batch of 1 to 500 envelopes as { "events": [ ... ] }. The request returns 200 when the request shape is valid; each event's fate is reported inline, so one bad or racing event never fails the batch. Each event is resolved and inserted in its own savepoint.
Every result carries a status of accepted, duplicate, or invalid. An invalid result also carries an error string explaining why (an unknown type, a missing required field, or a money field that does not parse). The response tallies accepted, duplicates, and invalid counts.
Requires the ingest scope. The event's source is stamped from the authenticated key's callerId, never from the body.
Request
curl -X POST https://c360-api.lioncapventures.com/v1/events \
-H "X-API-Key: $C360_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"events": [
{
"eventId": "mall-order-paid-ord_abc123",
"type": "order.paid",
"schemaVersion": 1,
"occurredAt": "2026-08-10T09:15:00.000Z",
"identity": { "platform": "smilemall", "userId": "42" },
"payload": {
"orderId": "ord_abc123",
"subtotal": "40.00",
"total": "44.00",
"currency": "USD"
}
}
]
}'
Response (200)
{
"success": true,
"data": {
"results": [
{ "eventId": "mall-order-paid-ord_abc123", "status": "accepted" }
],
"accepted": 1,
"duplicates": 0,
"invalid": 0
}
}
A batch with a bad event
{
"success": true,
"data": {
"results": [
{ "eventId": "ok-1", "status": "accepted" },
{ "eventId": "bad-2", "status": "invalid",
"error": "missing required payload field 'currency'" }
],
"accepted": 1,
"duplicates": 0,
"invalid": 1
}
}
The envelope
Every event is a JSON object with the same top-level shape. source is not part of the envelope: the service stamps it from the authenticated key.
- Name
eventId- Type
- string, 1..120
- Description
Producer-stable. The idempotency key together with the caller-stamped
source. Reposting the sameeventIdfrom the same source is a safe no-op.
- Name
type- Type
- string, 1..60
- Description
Must be in the type registry below. An unknown type marks the event
invalid.
- Name
schemaVersion- Type
- integer
- Description
Optional, defaults to
1. Bump it when a payload shape changes.
- Name
occurredAt- Type
- ISO-8601 datetime
- Description
The real-world time, UTC with a trailing
Z. Used for feed ordering and windows.
- Name
identity- Type
- object
- Description
The subject of the event. See the identity block below.
- Name
payload- Type
- object
- Description
Optional, defaults to
{}. Type-specific fields. Extra fields are always allowed and stored verbatim.
Event envelope
{
"eventId": "qupa-loan-disbursed-APP-9931",
"type": "loan.disbursed",
"schemaVersion": 1,
"occurredAt": "2026-08-10T09:15:00.000Z",
"identity": {
"platform": "qupa",
"phone": "+263771234567",
"nationalId": "63-1234567A00"
},
"payload": {
"applicationId": "APP-9931",
"amount": "250.00",
"currency": "USD"
}
}
The identity block
platform is required and must exist in the platforms registry (smilemall, qupa, zbid, notification-hub, product-intel, smilerewards). Everything else is optional; supply as much as you know.
- Name
platform- Type
- string, required
- Description
The channel producing the event.
- Name
userId- Type
- string
- Description
Your own user id on that platform, the strongest per-platform key.
- Name
zbIdSubject- Type
- string
- Description
The ZB ID JWT
subUUID, the cross-channel canonical key. Include it whenever you have it, even alongside auserId: this is what unifies the customer across channels.
- Name
phone- Type
- string
- Description
Normalized to E.164, Zimbabwe-aware (
0771234567becomes+263771234567).
- Name
email- Type
- string
- Description
Normalized to lower-case and trimmed.
- Name
nationalId- Type
- string
- Description
National id (Qupa loans key on this plus phone).
- Name
sessionId- Type
- string
- Description
Anonymous session for guest browsing before login.
- Name
displayName- Type
- string
- Description
Best-known name.
- Name
isGuest- Type
- boolean
- Description
truefor guest checkout with no user row on your side.
Primary node precedence: the customer is resolved or created from the first present of userId, then phone, then email, then sessionId, then zbIdSubject alone. Identity resolution behaviour is covered on the Identity resolution page.
Money convention
Money values are decimal strings ("12.50", "0.00"), never floats. If your system stores cents or integers, divide by 100 before sending (1250 becomes "12.50").
This service computes no money. It validates that money fields parse as Decimal >= 0 and stores them verbatim. It never derives a fee, tax, commission, discount, or total. The read-time trait engine only sums what you stored. A money field that does not parse marks the event invalid.
Money as decimal strings
{
"payload": {
"orderId": "ord_abc123",
"subtotal": "40.00",
"total": "44.00",
"currency": "USD"
}
}
Idempotency
Events dedupe on (source, eventId). Choose an eventId that is stable for the real-world fact, so a retry or a re-emit after a crash is a safe no-op that comes back duplicate. Never mint a random eventId per attempt; that defeats dedupe.
Because ingest is idempotent, reconciliation is always safe: a producer can re-emit missing events with the same stable eventIds and each re-emit is a duplicate. See Event feed & parity for the reconciliation flow.
Stable eventId recipes
mall-order-paid-<orderPublicId>
mall-payment-completed-<paymentPublicId>
qupa-loan-disbursed-<applicationId>
mall-session-<sessionId>-started
Event type registry
Ingest validates the type and its required payload fields. An unknown type, a missing required field, or a money field that does not parse as Decimal >= 0 marks that event invalid (the rest of the batch still processes). Extra payload fields are always allowed and stored verbatim.
The registry below is the complete set of types the service accepts, taken directly from the service. Producers only emit the types relevant to their platform.
Session and browsing
| Type | Required payload | Money fields |
|---|---|---|
session.started | sessionId | - |
page.viewed | path | - |
Identity lifecycle
| Type | Required payload | Notes |
|---|---|---|
identity.registered | (none) | account created |
identity.updated | changed | the changed fields (for example a zbid attached later) |
identity.claimed | claimedType, claimedValue | merges the customer owning that identity into this event's customer |
identity.claimed is how a registered user absorbs a prior guest: send it with the user's userId in identity and { "claimedType": "phone", "claimedValue": "+263771234567" } in the payload. See Identity resolution.
Orders
| Type | Required payload | Money fields | Notes |
|---|---|---|---|
order.created | orderId, total, currency | total | optional merchantId (merchant insight) plus an optional items line array |
order.status_changed | orderId, status | - | |
order.paid | orderId, subtotal, total, currency | subtotal, total | subtotal is the loyalty earn basis; optional merchantId |
An optional items array on order.created unlocks the mallProduct dimension of Insights. Each entry needs productId; name, quantity, and a decimal-string unit price are recommended (the line money is price * quantity).
Payments
| Type | Required payload | Money fields |
|---|---|---|
payment.completed | paymentId, amount, currency | amount |
payment.failed | paymentId | - |
Bill payments
| Type | Required payload | Money fields | Notes |
|---|---|---|---|
billpay.initiated | transactionId, amount, total, currency, channel | amount, total | channel for example zesa, dstv |
billpay.completed | transactionId, amount, total, currency, channel | amount, total | |
billpay.failed | transactionId | - |
Tickets
| Type | Required payload | Notes |
|---|---|---|
ticket.purchased | orderId, eventId, quantity | here the payload eventId is the show's id, not the envelope eventId |
Loans
| Type | Required payload | Money fields |
|---|---|---|
loan.applied | applicationId, amountRequested, currency | amountRequested |
loan.status_changed | applicationId, status | - |
loan.disbursed | applicationId, amount, currency | amount |
loan.repayment | applicationId, amount, currency, status | amount |
Messaging
| Type | Required payload | Notes |
|---|---|---|
notification.sent | messageId, channel | set identity.platform to the recipient's platform when known |
notification.delivered | messageId, channel, status |
Consent
| Type | Required payload | Notes |
|---|---|---|
consent.updated | purpose, status | upserts the consent state; status is granted or revoked; an older occurredAt than the stored decision is ignored |
That is 21 registered types. Extra payload fields are always allowed and stored verbatim; the service computes no fees and stores amounts exactly as sent.