Transaction Lifecycle
How every money-moving attempt becomes a durable record, from request to settlement.
A transaction is the durable record of any money-moving attempt: a payment, transfer, exchange, or funding. Every attempt creates an entry before any work happens, so the record exists even when the attempt fails.
The pipeline
stateDiagram-v2
[*] --> pending: Request received
pending --> processing: Submitted to provider
processing --> completed: Provider confirmed
processing --> failed: Provider denied / rule violation
processing --> unknown: Provider timeout
unknown --> completed: Reconciliation resolved
unknown --> failed: Reconciliation resolved
completed --> [*]
failed --> [*]Every money-moving request follows the same pipeline:
Attempt recorded
Before any validation or provider call, the transaction is persisted as an
entry with status pending and its own idempotency key. If your request is
rejected later: validation, compliance, limits, provider outage: the entry
still exists, marked failed with a machine-readable failure.code and the
stage where it stopped. Nothing happens off the record.
Checks
PIN step-up, feature flags, compliance screening, and limit checks run against
your account. A denial here is a failed transaction, not a silent rejection.
Provider submission
The engine routes to a payment provider, places a hold on funds, and posts the
movement. A provider timeout leaves the transaction unknown: the outcome is
never guessed. Reconciliation resolves unknown to completed or failed.
Ledger posting
The double-entry ledger is the source of truth. Every status you see via the
API reflects a posted journal entry; pending means not yet posted.
Notification
Signed webhooks fire for every status transition. You always know the final
state: you never have to poll for it (though GET is available).
The transaction object
{
"id": "01M2S17G4A0N9MDMXYSCCX8SPS",
"type": "payment",
"status": "completed",
"amount_minor": 101000,
"fee_minor": 1000,
"currency": "NGN",
"customer_id": "01M2S17...",
"reference": "TRF-2026-00042",
"idempotency_key": "pay-001",
"failure": null,
"created_at": "2026-09-18T01:13:59Z",
"updated_at": "2026-09-18T01:14:00Z"
}Failure details
A failed transaction carries where and why it stopped:
"failure": {
"code": "INSUFFICIENT_FUNDS",
"message": "Wallet balance is insufficient for this payment.",
"stage": "settlement"
}stage is the pipeline position: method_selection, pin_stepup,
compliance, limits, quote, order_persistence, provider_call,
settlement: so you can distinguish a PIN denial from a provider outage
without parsing text.
Status history
Every transition is an append-only event. GET /v1/transactions/{id}/events
returns the full trail: who/what moved it, when, and the reason: for audit
and support.
Data guarantees
- Money is integer minor units:
10250with"NGN"is ₦102.50, never floats - Every balance, hold, and movement is a ledger journal; history is append-only
- Corrections are compensating entries, never edits to posted history
- Webhooks are HMAC-signed and deduplicated by
event_key