Aller au contenu

Transactions

Ce contenu n’est pas encore disponible dans votre langue.

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/transactions
Authorization: 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.

The body is {"items": [ … ]} with up to 500 transactions.

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.

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

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}

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"
}
{
"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.

Terminal window
curl https://speculapro.example.com/api/v1/ingest/transactions \
-H "Authorization: Bearer $SPECULAPRO_API_KEY" \
-H "Content-Type: application/json" \
-d @batch.json

where 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.

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.

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
  • 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_fingerprint is 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, ip and attributes are stored as sent. Don’t put phone numbers, card numbers or ID numbers in them.
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