Event schema (for engineers)
Ce contenu n’est pas encore disponible dans votre langue.
In short
Section titled “In short”Every accepted transaction becomes one TxnIngested message on a Kafka (or Redpanda) topic, and every account event one AccountEvent message. SpeculaPro’s own components read them: the persister writes them to the database, and the stream evaluator runs your rules. This page is for engineers who operate SpeculaPro, inspect the queue while troubleshooting, or build an approved consumer of their own.
You don’t need this page to send data.
Topic and key
Section titled “Topic and key”| Topic | <prefix>txn.ingested, which is sp.txn.ingested with the default prefix (SP_KAFKA_TOPIC_PREFIX) |
| Dead-letter topic | <prefix>txn.ingested.dlq: messages the persister couldn’t read are moved here instead of blocking the topic |
| Key | <tenant_id>:<account_external_id>, or <tenant_id>:c:<customer_external_id> when the transaction has no account |
| Format | JSON, UTF-8 |
| Delivery | Written with acknowledgement from all in-sync replicas before ingest answers accepted |
Because the key is the account, all transactions for one account land on the same partition, in the order they were accepted. Consumers can therefore keep per-account state without locks.
Fields (version 1)
Section titled “Fields (version 1)”| Field | Type | Always present | Meaning |
|---|---|---|---|
v |
integer | Yes | Schema version: 1 |
id |
UUID (v7) | Yes | SpeculaPro’s id for the transaction. Stable across retries |
tenant_id |
UUID | Yes | The workspace |
external_id |
string | Yes | The sender’s reference |
occurred_at |
date-time, UTC | Yes | When it happened at the institution |
received_at |
date-time, UTC | Yes | When SpeculaPro accepted it |
customer_external_id |
string | Yes | |
customer_type |
string | No | individual · business, if sent |
account_external_id |
string | Yes (may be empty) | |
account_type |
string | No | If sent |
direction |
string | Yes | in · out · internal |
type |
string | Yes | As sent, e.g. cash_in |
channel |
string | Yes | As sent, e.g. agent |
status |
string | Yes | Defaults to completed |
amount_minor |
integer | Yes | Original amount in minor units |
currency |
string | Yes | Original currency |
amount_base_minor |
integer | Yes | Amount in the workspace base currency, minor units |
base_currency |
string | Yes | |
fx_rate |
string | Yes | Rate used (base per 1 unit of currency); "1" when no conversion |
country, counterparty_country |
string | No | 2-letter codes |
mcc |
string | No | |
ip |
string | No | |
geo_lat, geo_lon |
number | No | |
reference, description |
string | No | As sent |
device_id |
string | No | Keyed hash (hex) of the device_fingerprint you sent, unique to the workspace. The fingerprint itself never reaches the queue |
session_id |
string | No | As sent |
counterparty |
object | No | See below. Present only if a name or identifier was sent |
attributes |
object | No | The sender’s custom fields, as sent |
decision |
object | No | Present only when the transaction came through the real-time decision API: the decision’s id, time, outcome (allow, review, block or step_up), score, whether it was degraded, the live and shadow rules that fired, and the feature values they read |
payload_hash |
bytes (base64) | No | On decided transactions only: a fingerprint of the request, so a later resend of the same transaction through the ingest API is recognised |
counterparty
Section titled “counterparty”| Field | Type | Meaning |
|---|---|---|
fingerprint |
bytes (base64) | Keyed fingerprint of the normalised name, identifier and provider. Identifies the counterparty within the workspace |
name |
string | As sent, trimmed |
identifier_mask |
string | Display form, e.g. +233•••••••67 |
identifier_hash |
bytes (base64) | Keyed fingerprint of the identifier, comparable with the workspace’s account identifiers |
identifier_enc |
bytes (base64) | The identifier, encrypted with the workspace’s key |
provider |
string | Lower-cased |
country, type |
string | As sent |
Example
Section titled “Example”{ "v": 1, "id": "0199c8f2-41a7-7d02-b3e5-20c7d8e9fa13", "tenant_id": "0199a1c0-5e2d-7b44-9a10-6f3c2e1d0b9a", "external_id": "MOMO-2026-10-09-000184977", "occurred_at": "2026-10-09T08:31:40Z", "received_at": "2026-10-09T08:31:41.207Z", "customer_external_id": "CUST-77120", "account_external_id": "WAL-0244123456", "direction": "out", "type": "transfer", "channel": "momo", "status": "completed", "amount_minor": 120000, "currency": "GHS", "amount_base_minor": 120000, "base_currency": "GHS", "fx_rate": "1", "counterparty_country": "GH", "reference": "School fees", "device_id": "5eda377d7dcc41f0a9e2c7b6d58a1f3e9c0b2d4a6e8f1a3c5b7d9e0f2a4c6b8d", "counterparty": { "fingerprint": "q1v8…", "name": "Kwame Boateng", "identifier_mask": "+233•••••••67", "identifier_hash": "Xo3N…", "identifier_enc": "AQGx…", "provider": "telecel_cash", "country": "GH", "type": "individual" }}Account events
Section titled “Account events”Every accepted account event becomes one AccountEvent
message.
| Topic | <prefix>account.events, which is sp.account.events with the default prefix |
| Dead-letter topic | <prefix>account.events.dlq, written by the persister |
| Key | The same as for transactions: <tenant_id>:<account_external_id>, or <tenant_id>:c:<customer_external_id> when the event has no account |
| Read by | The persister, which stores it, and the stream evaluator, which counts it for rules |
| Field | Type | Always present | Meaning |
|---|---|---|---|
v |
integer | Yes | Schema version: 1 |
id |
UUID | Yes | SpeculaPro’s id for the event, derived from the workspace and external_id, so a resend carries the same id |
tenant_id |
UUID | Yes | The workspace |
external_id |
string | Yes | The sender’s reference |
customer_external_id |
string | Yes | |
account_external_id |
string | No | If sent |
type |
string | Yes | For example sim_swap |
occurred_at, received_at |
date-time, UTC | Yes | When it happened, and when SpeculaPro accepted it |
channel, session_id, ip, geo_lat, geo_lon, success |
No | As sent | |
device_id |
string | No | Keyed hash of the device fingerprint, as for transactions |
attributes |
object | No | As sent |
Because the key matches the transactions’ key, an account’s events and transactions share a partition and keep their order.
No readable identifiers on the queue
Section titled “No readable identifiers on the queue”Counterparty account and wallet numbers are encrypted before the message is written. Anyone with access to the topic sees the masked form and fingerprints, never the number itself. Decryption needs the workspace’s key, which only the SpeculaPro application holds.
Rules for consumers
Section titled “Rules for consumers”- Check
v. Version 1 is described here. A future incompatible change will use a new version number. - Ignore fields you don’t know. New optional fields can be added to version 1 at any time; a consumer that rejects unknown fields will break.
- Be idempotent on
id. A transaction can be delivered more than once (after a retry or a consumer restart). It always carries the sameid. - Don’t produce to this topic. Only SpeculaPro’s ingest writes it. Anything else written there bypasses validation, currency conversion and encryption.