Skip to content

Event catalogue

Every event Wajub emits, what triggers it, and what arrives with it.

Konsole offers 72 event types to subscribe to. 68 of them actually reach an endpoint, and the four that do not are named at the bottom of this page. Almost all of them arrive in the same envelope, described in Webhooks; only data changes from one to the next, and data is always the full resource, identical to what the REST endpoint for that resource returns. The exception is the Sync connection lifecycle, which has its own format.

This page is the complete list, grouped by what the event is about, with the thing worth doing when it lands.

How an endpoint decides to fire

Subscription matching is a plain string comparison against the events array you registered. Three cases, and no fourth.

Your events arrayWhat you receive
["payment.succeeded", "refund.failed"]Exactly those two types
["*"]Every payment, refund, transfer, customer, dispute, invoice, link, account, balance, fee, endpoint, compliance and provider event, except the Sync lifecycle deliveries
[]The same as ["*"]

Prefix wildcards do not exist. payment.* is a literal string that no event is ever equal to. If you want everything under one resource, list its types.

How the names are built

resource.verb, verb in the past tense, both lowercase. The verbs are stable across resources, which makes the list shorter to remember than it looks.

VerbMeaning
createdThe resource now exists
updatedA field changed
deletedThe resource was removed, or anonymised
processingHanded to a provider, outcome unknown
succeededTerminal, it worked
failedTerminal, it did not
cancelledTerminal, stopped on purpose
expiredTerminal, the window closed

Three spellings cost people an afternoon: it is succeeded, never completed or complete; it is cancelled with two l; and Sync uses deauthorized with a z while everything else in the platform is British spelling.

Payments

The six states a payment moves through, and the attempt that fails without ending it. Only payment.succeeded means money.

EventFires whenWhat to do
payment.createdThe payment exists and is waiting for the payerNothing, record the id
payment.processingThe operator accepted the debit requestShow a pending state
payment.succeededThe funds are capturedFulfil the order here
payment.failedThe operator or the card issuer declinedRelease the reservation, tell the customer
payment.cancelledYou called DELETE /payments/{id}, or the payer pressed Cancel on the hosted pageRelease the reservation
payment.expiredThe payment window closed before confirmationRelease the reservation
payment.attempt_failedOne attempt was refused at once and the payment is pending againLet the payer try again. Nothing is over

payment.failed carries failure_reason in data. The field is absent, not null, when there is no reason to give.

An attempt refused at once

payment.attempt_failed is not an end. A provider refused one attempt on the spot, or no provider could take it, and the payment went back to pending: the payer can try again on the same payment. payment.processing may already have reached you for that attempt, and this is what follows it. A refusal that comes later, from the operator, ends the payment with payment.failed.

data is the payment, plus an attempt object:

What payment.attempt_failed adds to the payment
"attempt": {
  "failure_code": "insufficient_funds",
  "failure_message": "Insufficient funds.",
  "channel": "cm.mtn"
}

failure_code is the provider's decline code when it gave one, no_eligible_provider when no provider could take the attempt, and payment_declined otherwise. In sandbox, a number ending in 000001 sends it.

Sandbox sends what live sends

A payment that reaches expired sends payment.expired, and one that reaches cancelled sends payment.cancelled, in sandbox as in live. A sandbox timeout used to send payment.failed and a sandbox cancellation nothing at all, so a handler tested in sandbox missed every live expiry.

Payment splits

Emitted when a single payment is debited in several instalments, using split_count or split_amounts on POST /payments/{id}/splits.

EventFires when
payment.split.processingOne instalment was sent to the operator
payment.split.succeededThat instalment cleared, and was credited to your balance
payment.split.failedThat instalment was declined

data is the full payment, plus a split object naming which instalment this was.

The split object added to the payment payload
"split": {
  "id": "splt_9mWvL2xR7tB5nY4hC6dF",
  "split_number": 2,
  "amount": 500000,
  "currency": "XAF",
  "split_currency": "XAF",
  "status": "succeeded",
  "reference": "order-4172-2",
  "provider_reference": "MP260115.1032.B41209",
  "initiated_at": "2026-01-15T10:32:04+00:00",
  "succeeded_at": "2026-01-15T10:32:41+00:00"
}

The parent payment reaches payment.succeeded only once every instalment has succeeded, or once you close it with POST /payments/{id}/close. Counting payment.split.succeeded yourself to decide the order is complete is a way to ship an order that is half paid.

Live sends them for the same calls, on Mobile Money channels. The parent payment also sends payment.processing each time a tranche goes to the payer, and is partial between two tranches. Neither live nor sandbox splits a payment on its own: a single charge above the per-transaction cap of Limits is refused with a 422, and POST /payments/{id}/splits pays it in tranches. Split payments has what each call returns.

Refunds

EventFires whenWhat to do
refund.createdThe refund was accepted for processingRecord it, do not credit yet
refund.processingThe provider is executing it. Live only: a sandbox refund is created processing and never sends itNothing
refund.succeededThe money left your balanceCredit the customer here
refund.failedThe provider rejected itRead failure_reason, decide manually
refund.cancelledWajub stopped the refund before sending it, with cancelled_by_wajub as its failure_reason. Sent once API version 2026-10-12 is current: until then the same refund arrives as refund.failed, with status: failedNothing left your balance. Refund again if the customer is still owed
refund.updatedA live refund changed lane: who sends it (execution_mode) or why (manual_reason)Tell your team when it reads merchant: the refund is yours to send

data.transaction is the id of the payment being refunded, as a string, not a nested object.

Transfers

Payouts out of your balance, whether to a mobile money wallet, a bank account or a card.

EventFires whenWhat to do
transfer.createdThe transfer was accepted and debited your available balanceRecord the id
transfer.processingHanded to the payout providerNothing
transfer.succeededThe beneficiary was paidMark the payout settled
transfer.failedThe provider could not payRead failure_reason, the amount returns to your balance
transfer.cancelledThe payout was stopped before anything was sent: rejected in the Dashboard (merchant_rejected) or cancelled by Wajub (cancelled_by_wajub). Sent once API version 2026-10-12 is current: until then it arrives as transfer.failed, with status: failedThe amount returns to your balance

Customers

EventFires when
customer.createdA customer record was created, including implicitly by a payment
customer.updatedAny field on the customer changed
customer.deletedDELETE /customers/{id} ran, and the record was anonymised

The payload of customer.deleted is the customer after anonymisation: the name reads Deleted User <pseudonym>, and the email, phone and date of birth are gone. If you need the original values for your own records, you must have kept them.

Beneficiaries

EventFires when
beneficiary.createdA payout destination was saved: with POST /beneficiaries, inline in a POST /transfers, or as a payout account in the Dashboard
beneficiary.updatedIts details changed
beneficiary.deletedIt was removed
beneficiary.receivable_recordedYou declared that a paid-out seller owes money back, with POST /beneficiaries/{id}/receivables
beneficiary.receivable_recoveredA batch payout line took part or all of that debt back

data for the two receivable events is the debt itself: id, beneficiary, reference, refund, currency, amount, recovered, outstanding, status, settled_at and created_at, amounts in major units. See the receivables endpoint.

EventFires when
link.createdA payment link was created
link.updatedIts amount, expiry or state changed
link.deletedIt was deleted

A payment made through a link emits payment.*, not link.*. The link events are about the link object itself.

link.expired appears in the Konsole picker but is not delivered to account endpoints. Subscribing to it on an account endpoint produces nothing.

Invoices

EventFires when
invoice.createdAn invoice was created, as a draft: once, with its lines and totals
invoice.updatedA line, a status or a due date changed: once per PUT /invoices/{id}, with the lines and totals, and not at all for a PUT that changes nothing
invoice.payment_succeededA payment made on the invoice's page succeeded, each instalment included
invoice.paidThe invoice is settled in full, online or with mark-paid
invoice.deletedIt was deleted

The payment and the invoice name each other

When a customer pays an invoice, payment.succeeded fires for the payment, then invoice.updated and invoice.payment_succeeded for the invoice, and invoice.paid once it is settled. Fulfil on payment.succeeded: its data.invoice is the id of the invoice it paid, and the invoice events carry the invoice itself.

Disputes

Chargebacks and complaints. These are the events with the shortest deadlines attached to them.

EventFires whenWhat to do
dispute.createdA dispute was opened against a paymentStart gathering evidence
dispute.updatedA field changed, or a message was addedRead data.messages
dispute.closedThe dispute ended. A loss sends it right after dispute.lost, a win never sends it: read data.status for the outcomeNothing more, act on dispute.lost
dispute.wonThe decision went your wayKeep the funds
dispute.lostThe decision went against youThe amount is debited
dispute.deadline_approachingThe due_date is two days away and the dispute is still open or under_review. Sent onceSubmit your evidence now
dispute.deletedThe dispute record was removedNothing

Accounts, for Sync platforms

These describe a connected merchant on a Sync platform, not your own account. The ones sent by the API arrive in the usual envelope:

EventFires when
account.createdYou created the connection with POST /accounts. The merchant has not accepted it yet: that is account.connected, below
account.updatedThe account changed, and data is the account as the change left it. Sent when PUT /accounts/{id} changes the connection (its email, metadata, callback, payment_status, payout terms, or the capabilities and pricing of a connection not yet accepted), when POST /accounts/{id}/token puts a cancelled invitation back to pending, and when Wajub sends a Lite merchant's payout, which moves last_payout_at. A call that changes nothing sends nothing. Neither does a proposal alone, which is account.change_requested, nor DELETE, which is account.deauthorized. A change of payment_status also sends account.payment_activated or account.payment_suspended
account.deauthorizedYou ended the connection with DELETE /accounts/{id}
account.payment_activatedYou set payment_status to active with PUT /accounts/{id}
account.payment_suspendedYou set it to suspended
account.change_requestedPUT /accounts/{id} proposed new pricing or capabilities, which the merchant has to approve
account.commission_receivable_recordedA commission could not be taken at the source and is now owed by the merchant. Live only
account.commission_receivable_collectedThat commission was taken from the merchant's Wajub balance

Every one of them names its connection in data.account: the acc_… id, the value you send as X-Sync. Read the connection there, not in data.id, which is not always the connection:

Eventdata.accountdata.id
account.created, account.updated, account.deauthorized, account.payment_activated, account.payment_suspendedacc_…The same acc_…: data is the account, as GET /accounts/{id} returns it
account.change_requestedacc_…Absent. The proposal is identified by change_id
account.commission_receivable_recorded, account.commission_receivable_collectedacc_…crv_…, the commission owed

On these events account is the id itself, a string. On the copy of a payment, refund, transfer or dispute event a platform receives, and on fee.received, account is an object and the id is in account.id: see Sync platforms receive a copy.

There is no account.deleted. account.created announces the invitation you created; the merchant's acceptance is account.connected, below. When the merchant revokes the connection themselves, the account.deauthorized you receive is the Sync-format one.

The rest of the connection's life, and the same changes when they are made from the Dashboard rather than the API, are sent in the Sync format described in the next section:

EventFires when
account.onboarding_startedThe merchant opened your onboarding link
account.onboarding_email_verifiedThey verified their email
account.onboarding_account_selectedThey picked the Wajub account to connect
account.onboarding_kyc_submittedThey submitted their verification file
account.connectedThey accepted the connection. It is live from now on
account.change_requestedYou proposed new terms from the Dashboard
account.change_acceptedThe merchant approved them. They apply from now on
account.change_declinedThe merchant refused them. Nothing changes
account.payment_activated, account.payment_suspended, account.deauthorizedThe same changes, made from the Dashboard or triggered by the merchant's verification

Sync lifecycle deliveries

The events of the second table do not travel like the others. They are sent to the endpoints of the platform that owns the connection, in the environment of that connection.

  • Only an exact subscription receives them. * and an empty events array do not match. List each type you want.
  • The body is flat, not the usual envelope: event, account (the connection's acc_… id), sandbox, timestamp, then the fields of that event. There is no data: read the connection in account, the key the API's own account.* events use under data. sync_uid carries the same acc_… id and stays for the platforms that already read it. Rely only on the fields documented here.
  • The headers are different: X-Sync-Event names the event, X-Idempotency-Key identifies the delivery, and X-Sync-Signature-V1 with X-Sync-Timestamp carry the signature.
  • Verify the signature as for any other delivery. X-Sync-Signature-V1 is v1= followed by the hex HMAC-SHA256 of {timestamp}.{raw_body}, keyed with the endpoint's signing secret, and X-Sync-Timestamp is that timestamp in Unix seconds. Pass both to constructEvent in the SDKs, or verify them by hand as in Signature verification, and refuse a delivery whose timestamp is more than 300 seconds from your clock. Then record each X-Idempotency-Key you have processed, and ignore a delivery whose key you have already seen.
  • X-Sync-Signature is kept for existing integrations. It is the HMAC-SHA256 of the raw body alone, in hexadecimal with no v1= prefix. It does not sign the time, so it cannot tell a replay from a delivery. It will be retired later: verify X-Sync-Signature-V1 instead.
  • Retries are short: three attempts, 30 seconds then 2 minutes apart, 10 seconds each. These deliveries do not appear in the Konsole event log.

This is what account.connected looks like:

An account.connected delivery
{
"event": "account.connected",
"sync_uid": "acc_7Yh2MpL4tRb3nP8sZcXv",
"account": "acc_7Yh2MpL4tRb3nP8sZcXv",
"sandbox": false,
"timestamp": "2026-09-16T10:32:41+00:00",
"team_name": "Boutique Akwa",
"capabilities": [
"read",
"payments"
],
"status": "active",
"payment_status": "active"
}

Balance and fees

EventFires whenPayload
balance.updatedYour balance movedThe movement, not the new totals
fee.chargedWajub debited a fee from youThe fee
fee.receivedA Sync platform collected its fee on a connected merchantThe fee, plus account

balance.updated sends the movement itself, which is what you want for a ledger.

A balance.updated payload
{
"currency": "XAF",
"amount": 24250,
"direction": "credit",
"balance_type": "available",
"balance_before": 1840000,
"balance_after": 1864250,
"reason": "payment_settlement",
"sandbox": false
}

The eight figures GET /balance returns are not in this payload. Call the endpoint when you need the totals.

Compliance

Emitted while your account is being verified, and whenever your file changes state afterwards.

EventFires when
compliance.submittedYour verification file was submitted for review
compliance.verifiedThe account is verified, live money becomes possible
compliance.rejectedThe file was refused
compliance.changes_requestedA reviewer needs something else from you
compliance.inquiry.createdA specific question was raised on your file
compliance.inquiry.resolvedThat question was closed

For a platform onboarding merchants, compliance.verified is the signal that a connected account may start taking live payments. Do not infer it from anything else.

Providers

Emitted when the set of rails available to your account changes.

EventFires when
provider.activatedA provider was enabled on your account
provider.deactivatedA provider was disabled
provider.channel_activatedOne channel of a provider became available
provider.channel_deactivatedOne channel was withdrawn

Worth subscribing to if you cache your account's channels rather than calling GET /providers on every checkout.

Webhook endpoints

EventFires when
webhook_endpoint.createdAn endpoint was registered
webhook_endpoint.updatedIts URL, subscriptions or state changed
webhook_endpoint.deletedIt was removed

Useful on a platform account where several people can change the configuration. Note that the endpoint receiving this event may be the one that was just changed.

What data actually contains

data is the resource serialised by the same code that serves the REST endpoint, with three consequences worth knowing.

A field with no value is absent or null, depending on the resource. A payment or a customer drops it: a payment with no description has no description key at all. A transfer, an invoice, a link or an account sends it as null. Read every field with a default, and accept null.

An empty object stays an object. A metadata with nothing in it arrives as {}, as the REST endpoint returns it, never as [], so a handler that reads it into a map or a struct does not break on the empty one.

The payload is always in the latest API version. Version pinning applies to your API calls, not to webhook deliveries: the envelope's api_version is the platform's current version at the moment the event was recorded, and the body is shaped for that version. Pinning your account to 2026-08-01 does not change what your endpoint receives.

A complete payment.succeeded delivery
{
"id": "evt_aio5DpN577tNU2vOxdmuZGhT",
"event": "payment.succeeded",
"livemode": true,
"created": "2026-01-15T10:32:41+00:00",
"api_version": "2026-10-11",
"pending_webhooks": 1,
"request": {
"id": null,
"idempotency_key": "idem_kZ3qP8mWvL2xR7tB5nY4hC6dF9jS1aG0"
},
"data": {
"id": "trx_CSUGajfv9xh0XQ5wu2lx",
"reference": "order-4172",
"amount": 25000,
"amount_paid": 25000,
"currency": "XAF",
"status": "succeeded",
"channel": "cm.mtn",
"payment_method": {
"channel": "cm.mtn",
"account": "+237670000000"
},
"customer": {
"id": "cus_4tRb3nP8sZcXvK2mQ9wL",
"name": "Amina Nkem",
"email": "[email protected]",
"phone": "+237670000000"
},
"sandbox": false,
"credited_at": "2026-01-15T10:32:41+00:00",
"created_at": "2026-01-15T10:30:00+00:00",
"updated_at": "2026-01-15T10:32:41+00:00"
}
}

Events people look for and do not find

You expectedWhat actually exists
payment.refundedrefund.succeeded
payment.partially_refundedrefund.succeeded, compare data.amount to the payment
transfer.reviewtransfer.created, with status: review. Nothing else until the decision
payout.*transfer.*, payouts are transfers in the API
subscription.*invoice.*, recurring billing emits invoice events
checkout.session.completedpayment.succeeded
account.deletedaccount.deauthorized

Events Wajub records for its own use are never delivered, even to * subscribers.

Types you can subscribe to that never arrive

Konsole lets you tick this one. Nothing is ever delivered.

TypeWhy
link.expiredNot delivered to account endpoints

payment.checkout_page_opened and payment.redirected_to_callback were offered too, and never delivered either: they are checkout timeline entries, recorded in the Konsole event log only. They are no longer offered. An endpoint that already lists one keeps it, without effect, and can still be saved with it. Both stay visible in Events, so you can still audit a checkout after the fact.

Sandbox and live are separate streams

An endpoint belongs to one environment. A sandbox endpoint only ever receives sandbox events, and a live endpoint only live ones. Two things change in the payload:

  • livemode is false in sandbox and true in live.
  • Event ids are prefixed evt_test_ in sandbox and evt_ in live.

A handler that hardcodes an id length or slices a prefix off will break when it meets the other environment.

Sync platforms receive a copy

When a payment, refund or transfer belongs to a merchant connected through Sync, or a dispute is opened on such a payment, the event is delivered twice: once to the merchant's own endpoints, and once to the platform's, as a separate event with its own id. The platform's copy carries an extra account object so you know which connected merchant it came from. Links, invoices and beneficiaries the connection created are not copied: GET /events with X-Sync lists their events.

What the platform copy adds to data
"account": {
  "id": "acc_7Yh2MpL4tRb3nP8sZcXv",
  "reference": "boutique-akwa"
}

Deduplicate on the event id, not on the payment id: the two copies describe the same payment and carry two different event ids, deliberately.

What did you think of this content?