Event catalogue
Every event Wajub emits, what triggers it, and what arrives with it.
Every event Wajub sends, grouped by what it is about, with what to do when it lands. All of them
arrive in the webhook envelope with the full resource in data, except
the Sync lifecycle deliveries, which have their own format.
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 ["*"] |
Prefix wildcards do not exist. From API version 2026-10-12, registering payment.*, or any name
that is not an event type, answers 422; on earlier versions it is stored as a literal string that
no event is ever equal to. If you want everything under one resource, list its types, copied from
this page.
`*` 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. 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. On a channel that takes
a code the payer generates, it is also sent when
that code is missing (otp_required) or refused (invalid_otp, and otp_attempts_exceeded the
third time); other refusals keep the provider's code. The operator refusing that code
later sends payment.attempt_failed too, not payment.failed.
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. It arrives as refund.failed, with status: failed and cancelled_by_wajub as its failure_reason | 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 or cancelled by Wajub. It arrives as transfer.failed, with status: failed and merchant_rejected or cancelled_by_wajub as its failure_reason | 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 is replaced,
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 |
link.expired | Its expiry_date passed: sent once, within a day, unless the link was archived by then |
A payment made through a link emits payment.*, not link.*. The link events are about the link
object itself. A link's own endpoints, set in its Webhooks tab, also get its payments, tickets,
gifts, orders and refunds: see Webhooks of one link.
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.sent | The invoice was sent: its status became sent |
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.cancelled | It was cancelled |
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.onboarding_declined | They declined to connect. Nothing is connected and your invitation link still works. reason holds their words when they gave one |
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. 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. - Only a
2xxis a delivery, as for every other event: a redirect is not followed and counts as a failure. Each attempt goes to the endpoint as it is when it is sent, signed with its current secret, and an endpoint disabled or deleted in the meantime is sent nothing more. - Redelivery lasts only a few minutes: keep the endpoint up and answer within 10 seconds. 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 totals GET /balance returns are not in this payload. Call the endpoint when you need them.
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 as its REST endpoint returns it. Three things are worth knowing.
A field with no value can be absent or null. Read every field with a default, and accept
null.
An empty metadata arrives as {}, never as [].
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.
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 |
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.