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 array | What 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 ["*"] |
A typo in the API is accepted and never fires
Konsole checks what you tick against a list of valid names. POST /webhooks does not: it
validates events as a non-empty array and nothing more. payment.succeded,
payment.SUCCEEDED and payment.* are all stored happily, match nothing, and your endpoint
stays silent forever. Copy the names from this page.
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.
`*` does not cover the Sync lifecycle deliveries
The Sync lifecycle deliveries match subscriptions by exact name
only: an endpoint registered with ["*"], or with an empty list, receives none of them. List each
type you want. Compliance and provider events do reach * and an empty list.
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.
| Verb | Meaning |
|---|---|
created | The resource now exists |
updated | A field changed |
deleted | The resource was removed, or anonymised |
processing | Handed to a provider, outcome unknown |
succeeded | Terminal, it worked |
failed | Terminal, it did not |
cancelled | Terminal, stopped on purpose |
expired | Terminal, 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.
| Event | Fires when | What to do |
|---|---|---|
payment.created | The payment exists and is waiting for the payer | Nothing, record the id |
payment.processing | The operator accepted the debit request | Show a pending state |
payment.succeeded | The funds are captured | Fulfil the order here |
payment.failed | The operator or the card issuer declined | Release the reservation, tell the customer |
payment.cancelled | You called DELETE /payments/{id}, or the payer pressed Cancel on the hosted page | Release the reservation |
payment.expired | The payment window closed before confirmation | Release the reservation |
payment.attempt_failed | One attempt was refused at once and the payment is pending again | Let 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.
There is no `payment.refunded`
A refund never produces a payment event. It produces refund.* events on the refund object. From
API version 2026-10-11, the payment's own status reads refunded or partially_refunded the
next time you read it, but no payment event is emitted for it.
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:
"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.
| Event | Fires when |
|---|---|
payment.split.processing | One instalment was sent to the operator |
payment.split.succeeded | That instalment cleared, and was credited to your balance |
payment.split.failed | That instalment was declined |
data is the full payment, plus a split object naming which instalment this was.
"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
| Event | Fires when | What to do |
|---|---|---|
refund.created | The refund was accepted for processing | Record it, do not credit yet |
refund.processing | The provider is executing it. Live only: a sandbox refund is created processing and never sends it | Nothing |
refund.succeeded | The money left your balance | Credit the customer here |
refund.failed | The provider rejected it | Read failure_reason, decide manually |
refund.cancelled | Wajub 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: failed | Nothing left your balance. Refund again if the customer is still owed |
refund.updated | A 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.
| Event | Fires when | What to do |
|---|---|---|
transfer.created | The transfer was accepted and debited your available balance | Record the id |
transfer.processing | Handed to the payout provider | Nothing |
transfer.succeeded | The beneficiary was paid | Mark the payout settled |
transfer.failed | The provider could not pay | Read failure_reason, the amount returns to your balance |
transfer.cancelled | The 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: failed | The amount returns to your balance |
A transfer under review sends transfer.created, then waits
review is a real transfer status: Wajub may hold a payout for review before sending it. It sends
transfer.created, with status: review, and nothing more while it waits. Approved, it sends
transfer.processing once, then transfer.succeeded or transfer.failed. Refused, it sends
transfer.failed with admin_rejected. Either can come hours later. Build the waiting state on
the status, not on the silence.
Customers
| Event | Fires when |
|---|---|
customer.created | A customer record was created, including implicitly by a payment |
customer.updated | Any field on the customer changed |
customer.deleted | DELETE /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
| Event | Fires when |
|---|---|
beneficiary.created | A payout destination was saved: with POST /beneficiaries, inline in a POST /transfers, or as a payout account in the Dashboard |
beneficiary.updated | Its details changed |
beneficiary.deleted | It was removed |
beneficiary.receivable_recorded | You declared that a paid-out seller owes money back, with POST /beneficiaries/{id}/receivables |
beneficiary.receivable_recovered | A 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.
Payment links
| Event | Fires when |
|---|---|
link.created | A payment link was created |
link.updated | Its amount, expiry or state changed |
link.deleted | It 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
| Event | Fires when |
|---|---|
invoice.created | An invoice was created, as a draft: once, with its lines and totals |
invoice.updated | A 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_succeeded | A payment made on the invoice's page succeeded, each instalment included |
invoice.paid | The invoice is settled in full, online or with mark-paid |
invoice.deleted | It 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.
| Event | Fires when | What to do |
|---|---|---|
dispute.created | A dispute was opened against a payment | Start gathering evidence |
dispute.updated | A field changed, or a message was added | Read data.messages |
dispute.closed | The dispute ended. A loss sends it right after dispute.lost, a win never sends it: read data.status for the outcome | Nothing more, act on dispute.lost |
dispute.won | The decision went your way | Keep the funds |
dispute.lost | The decision went against you | The amount is debited |
dispute.deadline_approaching | The due_date is two days away and the dispute is still open or under_review. Sent once | Submit your evidence now |
dispute.deleted | The dispute record was removed | Nothing |
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:
| Event | Fires when |
|---|---|
account.created | You created the connection with POST /accounts. The merchant has not accepted it yet: that is account.connected, below |
account.updated | The 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.deauthorized | You ended the connection with DELETE /accounts/{id} |
account.payment_activated | You set payment_status to active with PUT /accounts/{id} |
account.payment_suspended | You set it to suspended |
account.change_requested | PUT /accounts/{id} proposed new pricing or capabilities, which the merchant has to approve |
account.commission_receivable_recorded | A commission could not be taken at the source and is now owed by the merchant. Live only |
account.commission_receivable_collected | That 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:
| Event | data.account | data.id |
|---|---|---|
account.created, account.updated, account.deauthorized, account.payment_activated, account.payment_suspended | acc_… | The same acc_…: data is the account, as GET /accounts/{id} returns it |
account.change_requested | acc_… | Absent. The proposal is identified by change_id |
account.commission_receivable_recorded, account.commission_receivable_collected | acc_… | 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:
| Event | Fires when |
|---|---|
account.onboarding_started | The merchant opened your onboarding link |
account.onboarding_email_verified | They verified their email |
account.onboarding_account_selected | They picked the Wajub account to connect |
account.onboarding_kyc_submitted | They submitted their verification file |
account.connected | They accepted the connection. It is live from now on |
account.change_requested | You proposed new terms from the Dashboard |
account.change_accepted | The merchant approved them. They apply from now on |
account.change_declined | The merchant refused them. Nothing changes |
account.payment_activated, account.payment_suspended, account.deauthorized | The 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 emptyeventsarray do not match. List each type you want. - The body is flat, not the usual envelope:
event,account(the connection'sacc_…id),sandbox,timestamp, then the fields of that event. There is nodata: read the connection inaccount, the key the API's ownaccount.*events use underdata.sync_uidcarries the sameacc_…id and stays for the platforms that already read it. Rely only on the fields documented here. - The headers are different:
X-Sync-Eventnames the event,X-Idempotency-Keyidentifies the delivery, andX-Sync-Signature-V1withX-Sync-Timestampcarry the signature. - Verify the signature as for any other delivery.
X-Sync-Signature-V1isv1=followed by the hex HMAC-SHA256 of{timestamp}.{raw_body}, keyed with the endpoint's signing secret, andX-Sync-Timestampis that timestamp in Unix seconds. Pass both toconstructEventin 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 eachX-Idempotency-Keyyou have processed, and ignore a delivery whose key you have already seen. X-Sync-Signatureis kept for existing integrations. It is the HMAC-SHA256 of the raw body alone, in hexadecimal with nov1=prefix. It does not sign the time, so it cannot tell a replay from a delivery. It will be retired later: verifyX-Sync-Signature-V1instead.- 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:
Balance and fees
| Event | Fires when | Payload |
|---|---|---|
balance.updated | Your balance moved | The movement, not the new totals |
fee.charged | Wajub debited a fee from you | The fee |
fee.received | A Sync platform collected its fee on a connected merchant | The fee, plus account |
A fee debit is not a `balance.updated`
Fee debits are deliberately excluded from balance.updated and surfaced as fee.charged
instead. If you sum balance.updated movements to keep a local ledger, you will be over by
exactly the fees. Subscribe to both.
balance.updated sends the movement itself, which is what you want for a ledger.
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.
| Event | Fires when |
|---|---|
compliance.submitted | Your verification file was submitted for review |
compliance.verified | The account is verified, live money becomes possible |
compliance.rejected | The file was refused |
compliance.changes_requested | A reviewer needs something else from you |
compliance.inquiry.created | A specific question was raised on your file |
compliance.inquiry.resolved | That 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.
| Event | Fires when |
|---|---|
provider.activated | A provider was enabled on your account |
provider.deactivated | A provider was disabled |
provider.channel_activated | One channel of a provider became available |
provider.channel_deactivated | One channel was withdrawn |
Worth subscribing to if you cache your account's channels rather than calling GET /providers on
every checkout.
Webhook endpoints
| Event | Fires when |
|---|---|
webhook_endpoint.created | An endpoint was registered |
webhook_endpoint.updated | Its URL, subscriptions or state changed |
webhook_endpoint.deleted | It 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.
`request.idempotency_key` is not your key
Events carry an idem_… value generated for the event itself, and request.id is null. Neither
field reflects the Idempotency-Key header you sent when you created the payment. To connect an
event to your own order, use data.reference, which is the reference you supplied, or
data.metadata.
Events people look for and do not find
| You expected | What actually exists |
|---|---|
payment.refunded | refund.succeeded |
payment.partially_refunded | refund.succeeded, compare data.amount to the payment |
transfer.review | transfer.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.completed | payment.succeeded |
account.deleted | account.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.
| Type | Why |
|---|---|
link.expired | Not 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:
livemodeisfalsein sandbox andtruein live.- Event ids are prefixed
evt_test_in sandbox andevt_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.
"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.