Transactions
In short
Section titled “In short”A transaction is any movement of money: a MoMo transfer, an agent cash-in, a card payment, a bank transfer, a loan repayment, a reversal. Send every transaction, including small, failed and reversed ones. Patterns such as structuring and card testing show up precisely in the small and failed ones.
POST /api/v1/ingest/transactionsAuthorization: Bearer spk_…Transactions are immutable: once SpeculaPro has one, it never changes. A later event, such as a reversal, is a new transaction. See Corrections and reversals.
Fields
Section titled “Fields”The body is {"items": [ … ]} with up to 500 transactions.
Required
Section titled “Required”| Field | Type | What it is | Example |
|---|---|---|---|
external_id |
string, 1–128, no leading or trailing spaces | Your unique reference. This is the idempotency key: see Idempotency and retries | "MOMO-2026-10-09-000184223" |
occurred_at |
date-time (RFC 3339) | When it happened at your institution, with a time-zone offset. Any offset works and is converted to UTC, so 08:14:03Z and 09:14:03+01:00 are the same time. Not in the future (5 minutes of clock difference allowed), not before 2000 |
"2026-10-09T08:14:03Z" |
customer_external_id |
string, 1–128 | The customer it belongs to | "CUST-77120" |
direction |
in · out · internal |
Money into the customer’s account, out of it, or between their own accounts | "in" |
type |
see below | What kind of transaction | "cash_in" |
channel |
see below | How it was made | "agent" |
amount_minor |
integer, 0 to 10¹⁵ | The amount in minor units: pesewas for GHS, cents for USD, whole francs for XOF. Never negative: use direction |
495000 (GHS 4,950.00) |
currency |
3-letter code | Currency of amount_minor |
"GHS" |
type is one of: transfer, payment, deposit, withdrawal, cash_in, cash_out, airtime, bill_payment, loan_disbursement, loan_repayment, refund, reversal, chargeback, fee, other.
channel is one of: momo, bank_transfer, instant_payment, card, pos, atm, agent, branch, cash, online, mobile_app, ussd, qr, crypto, cheque, other.
Optional, and why they matter
Section titled “Optional, and why they matter”| Field | Type | What it is | Used by rules for |
|---|---|---|---|
account_external_id |
string, ≤ 128 | The customer’s account involved | Almost everything. Without it, activity is tracked per customer only |
customer_type |
individual · business |
Used only if SpeculaPro has to create a placeholder customer | |
account_type |
same values as accounts | Used only for a placeholder account | |
status |
pending · completed · failed · reversed · blocked |
Default completed. A transaction the decision API blocked is always stored as blocked |
Failed-then-successful patterns, card testing |
country |
2-letter code | Where the transaction took place | Geography rules |
counterparty_country |
2-letter code | The other side’s country | Cross-border and high-risk jurisdiction rules |
counterparty |
object | Who was on the other side. See Counterparties | Fan-in, fan-out, mule networks, deny-lists |
mcc |
4 digits | Card merchant category code | High-risk merchant rules |
ip |
IPv4 or IPv6 address | Where an online transaction came from | New IP network for the customer (txn.ip_is_new) |
geo_lat, geo_lon |
numbers | Location, if you have it | Distance and speed from the previous location: impossible travel |
device_fingerprint |
string, ≤ 256 | Your identifier for the phone or browser used. Stored only as a keyed hash | New-device and shared-device rules |
session_id |
string, ≤ 128 | Your app or web session | Stored with the transaction, so it can be matched with account events from the same session (the sign-in, the one-time code). Not shown on screen or used by rules yet |
reference |
string, ≤ 140 | The payment reference or narration | Shown to investigators |
description |
string, ≤ 500 | Free text | Shown to investigators |
attributes |
object, ≤ 64 keys | Your own fields | Your own rules |
Jurisdiction attributes
Section titled “Jurisdiction attributes”Some jurisdiction rules read optional fields in a transaction’s attributes. A rule that needs one skips transactions without it.
| Attribute | Used by | Send |
|---|---|---|
balance_after |
uemoa.emi_balance_cap |
The wallet’s balance once this transaction is applied, in whole units of your base currency (francs CFA), as a number |
invoice_amount |
fatf.tbml_invoice_mismatch |
For a cross-border trade payment, the invoice amount, in the transaction’s own currency, as a number |
supplier_country |
fatf.tbml_third_country |
For a trade payment, the supplier’s country, as a two-letter ISO code in upper case (CN) |
"attributes": {"balance_after": 3850000}Examples
Section titled “Examples”An agent cash-in into a wallet
Section titled “An agent cash-in into a wallet”Ama deposits GHS 4,950.00 cash with agent 0417 into her MTN MoMo wallet:
{ "external_id": "MOMO-2026-10-09-000184223", "occurred_at": "2026-10-09T08:14:03Z", "customer_external_id": "CUST-77120", "account_external_id": "WAL-0244123456", "direction": "in", "type": "cash_in", "channel": "agent", "amount_minor": 495000, "currency": "GHS", "country": "GH", "reference": "Cash in via agent 0417", "attributes": {"agent_id": "AGT-0417"}}A wallet-to-wallet transfer to another network
Section titled “A wallet-to-wallet transfer to another network”Ama sends GHS 1,200.00 to a Telecel Cash number:
{ "external_id": "MOMO-2026-10-09-000184977", "occurred_at": "2026-10-09T08:31:40Z", "customer_external_id": "CUST-77120", "account_external_id": "WAL-0244123456", "direction": "out", "type": "transfer", "channel": "momo", "amount_minor": 120000, "currency": "GHS", "counterparty": { "name": "Kwame Boateng", "identifier": "0201234567", "provider": "telecel_cash", "country": "GH", "type": "individual" }, "counterparty_country": "GH", "device_fingerprint": "android-7f3c9a", "reference": "School fees"}A card payment in US dollars
Section titled “A card payment in US dollars”{ "external_id": "CARD-AUTH-88213409", "occurred_at": "2026-10-09T13:02:11Z", "customer_external_id": "CUST-90311", "account_external_id": "CARD-4417", "direction": "out", "type": "payment", "channel": "online", "amount_minor": 12000, "currency": "USD", "mcc": "5734", "ip": "41.66.12.9", "counterparty": {"name": "Example Software Inc", "country": "US", "type": "business"}, "counterparty_country": "US"}USD 120.00 needs a USD rate loaded first. With a rate of 15.25, SpeculaPro records GHS 1,830.00 alongside the original amount. See Currencies and FX.
Sending them
Section titled “Sending them”curl https://speculapro.example.com/api/v1/ingest/transactions \ -H "Authorization: Bearer $SPECULAPRO_API_KEY" \ -H "Content-Type: application/json" \ -d @batch.jsonwhere batch.json is {"items": [ …the three transactions… ]}. A successful response:
{ "accepted": 3, "duplicate": 0, "stale": 0, "rejected": 0, "retry": 0, "results": [ {"index": 0, "external_id": "MOMO-2026-10-09-000184223", "status": "accepted", "id": "0199c8f2-41a7-7d02-b3e5-1f6a9c0e2b47"}, {"index": 1, "external_id": "MOMO-2026-10-09-000184977", "status": "accepted", "id": "0199c8f2-41a7-7d02-b3e5-20c7d8e9fa13"}, {"index": 2, "external_id": "CARD-AUTH-88213409", "status": "accepted", "id": "0199c8f2-41a7-7d02-b3e5-3d4e5f60718a"} ]}What “accepted” means for a transaction
Section titled “What “accepted” means for a transaction”A transaction marked accepted has passed every check, been converted to your base currency and been written to SpeculaPro’s durable queue (Kafka) and confirmed by all of the queue’s in-sync replicas. From then on it isn’t lost if a SpeculaPro server stops.
It then reaches the database shortly afterwards, written in batches by SpeculaPro’s persist role. If the database is briefly unavailable, transactions wait safely on the queue and are written when it returns. Nothing needs resending.
Counterparties
Section titled “Counterparties”The counterparty is whoever is on the other side: the recipient of a transfer, the sender of a deposit, the merchant of a payment.
| Field | Type | What it is |
|---|---|---|
name |
string, ≤ 200 | The name as your system has it |
identifier |
string, ≤ 64 | Their account number, wallet number or card token. Encrypted |
identifier_type |
msisdn · account_number · card_token · iban · other |
What kind of number it is. Optional: defaults to msisdn when provider is a mobile-money provider, otherwise account_number |
provider |
string, ≤ 64 | Their bank or network, e.g. telecel_cash, mtn_momo, a bank code |
country |
2-letter code | |
type |
individual · business · unknown |
A counterparty is recorded only if it has a name or an identifier.
SpeculaPro recognises the same counterparty across transactions by a keyed fingerprint of the normalised name, identifier and provider together. Names are upper-cased and extra spaces removed, so “kwame boateng” and “KWAME BOATENG” match. If the name is spelt differently between transactions (“Kwame Boateng” and “K. Boateng”), they are treated as two counterparties.
The identifier gets the same treatment as account identifiers: encrypted, masked for display, and fingerprinted with the same key as your own accounts’ identifiers. When a counterparty’s identifier matches one of your monitored accounts, SpeculaPro can follow the money between your customers.
Corrections and reversals
Section titled “Corrections and reversals”Transactions can’t be edited. If you send the same external_id again with any difference, including a changed status, it is rejected with external_id was already used for a different transaction.
Record later events as new transactions:
| What happened | What to send |
|---|---|
| A payment was reversed | A new transaction with type: "reversal", the opposite direction, its own external_id, and the original’s external_id in reference or attributes |
| A refund | A new transaction with type: "refund" |
| A card chargeback | A new transaction with type: "chargeback" |
A transaction you sent as pending completed |
Send transactions once their outcome is known. If you must send pending ones, send the final outcome as a new transaction with its own external_id, and link them in attributes |
Where your data goes
Section titled “Where your data goes”- Readable identifiers (counterparty account numbers) are encrypted before the transaction is put on the queue. Nothing on the queue or in the database holds them in readable form.
- The original request body is not kept.
device_fingerprintis turned into a keyed hash, unique to your workspace, before the transaction is queued. The value you sent isn’t kept.- Fields such as
reference,description,ipandattributesare stored as sent. Don’t put phone numbers, card numbers or ID numbers in them.
Common rejections
Section titled “Common rejections”| Message | Cause | Fix |
|---|---|---|
occurred_at is required |
The time was an empty “zero” date such as 0001-01-01T00:00:00Z (leaving the field out altogether fails the whole batch with 422) |
Send the real time |
occurred_at is in the future |
More than 5 minutes ahead of SpeculaPro’s clock | Check your server’s clock and timezone. Send times with an explicit offset (Z or +00:00) |
occurred_at is before 2000 |
Bad date, often a default value such as 0001-01-01 |
Send the real time |
external_id has leading or trailing spaces |
Padding from a fixed-width export | Trim the value, consistently, every time |
currency must be an ISO 4217 code |
Not three capital letters | GHS, USD, XOF… |
ip is not a valid IPv4/IPv6 address |
Malformed address | Send the address alone, without port |
no FX rate for USD→GHS on or before 2026-10-09; … |
No rate loaded | Load rates first. See Currencies and FX |
latest FX rate for USD→GHS is from …, too old for … |
Rates stopped being loaded | Load current rates, then resend |
external_id was already used for a different transaction |
Same external_id, different content |
See Corrections and reversals |
external_id repeated in this batch with different content |
Two different transactions in one batch share an external_id |
Your references aren’t unique: fix the source |
external_id was already used for a transaction sent to the decision API |
The transaction was decided in real time, and this item differs from what was decided | You don’t need to send decided transactions again: they are recorded and monitored already. An identical resend comes back duplicate |