Skip to content

Event types

The gateway emits one webhook per state transition on a transaction. The body is a flat JSON object keyed by transaction_id and status. There is no top-level event_type field today — the status value tells you everything.

{
"transaction_id": "txn_8f3a4c2e9b1d7a6f5c0e8d",
"customer_reference": "cust_abc123",
"status": "approved",
"amount": "12.50",
"currency": "usd",
"fee_amount": "0.50",
"total_amount": "13.00",
"created_at": "2026-05-05T10:15:00Z",
"completed_at": "2026-05-05T10:15:08Z",
"additional_data": {
"provider_reference": "onafriq_ref_abc123"
}
}

Field-by-field:

FieldAlways present?Notes
transaction_idYesMatch against your local order.
customer_referenceYesEchoed from your /payments/collect body.
statusYesThe transition. See enum below.
amountYesDecimal string.
currencyYesLowercase ISO 4217.
fee_amountOn terminal statesThe fee charged.
total_amountOn terminal statesamount + fee_amount.
created_atYesWhen the transaction was first created.
completed_atOn terminal statesSettlement time.
failure_reasonOn failed / declined / cancelledHuman-readable reason.
additional_dataSometimesProvider-specific metadata.

The full lifecycle:

StatusMeaningTerminal?
pendingJust created, awaiting processorNo
processingProcessor accepted, doing the workNo
pending_otp_verificationWaiting for customer OTPNo
pending_mobile_money_verificationWaiting for carrier-side authorizationNo
pending_email_verificationWaiting for email-based confirmationNo
pending_bank_validationBank-side validation in progressNo
pending_bank_proof_uploadCustomer must upload proof of wireNo
pending_bank_submissionAwaiting our operator reviewNo
bank_payment_validatedOperator confirmed wire receivedNo
approvedMoney moved successfullyYes
declinedIssuer / processor declinedYes
failedSystem or processor errorYes
cancelledCustomer or system cancelledYes
expiredTTL exceeded without completionYes
refundedApproved earlier, then refundedYes

You should expect:

  • Multiple webhooks per transaction. A typical card payment emits pendingprocessingapproved. Your handler must be idempotent and tolerate duplicates.
  • No webhook for pending in some flows where the customer is still on your collect page. The first webhook may be processing or directly approved.
  • refunded may arrive much later than the original approved — refunds happen on a different timescale.

/payments/send emits webhooks per beneficiary, not per batch:

{
"transaction_id": "txn_b2c_8f3a4c",
"customer_reference": "payout_2026_05_05_001",
"status": "completed",
"amount": "10.00",
"currency": "usd",
"additional_data": {
"batch_id": "batch_xyz789",
"beneficiary_phone_number": "+243998857000",
"beneficiary_index": 0
},
"created_at": "2026-05-05T10:15:00Z",
"completed_at": "2026-05-05T10:15:05Z"
}

For a batch of 100 beneficiaries you’ll receive up to 200 webhooks (one for pending/processing plus one for each terminal state). Plan accordingly.

sending_type: recurring emits one set of webhooks per occurrence. The first occurrence’s webhooks arrive on recurring_start_date; subsequent occurrences as the schedule fires. Each occurrence has its own transaction_id.

The customer_reference is the same across all occurrences — use transaction_id to disambiguate.

Some transitions are illegal. If you see them, treat as a bug report:

  • approvedpending
  • declinedapproved
  • Any terminal state → any non-terminal state (except refunded, which is a fresh terminal state on a previously-approved transaction)