Skip to content

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 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. 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.

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. 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.

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. It arrives as refund.failed, with status: failed and cancelled_by_wajub as its failure_reasonNothing 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 or cancelled by Wajub. It arrives as transfer.failed, with status: failed and merchant_rejected or cancelled_by_wajub as its failure_reasonThe 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 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

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
link.expiredIts 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

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.sentThe invoice was sent: its status became sent
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.cancelledIt was cancelled
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.onboarding_declinedThey declined to connect. Nothing is connected and your invitation link still works. reason holds their words when they gave one
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. 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.
  • Only a 2xx is 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:

An account.connected delivery
{
"event": "account.connected",
"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 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.

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 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.

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": "amina@example.com",
"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

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?