event_object: it carries the same
body as the matching REST resource, plus a few fields that only make sense on an event. So if you
already handle GET /v1/conversions/{id}, you already know how to read a conversion.success
payload.
Subscribe by category
You donβt subscribe to individual events. You subscribe to categories β families of related events β when you create a webhook endpoint, and receive every event in the family.New endpoints are created inactive. Store the
signing_secret from the create response β it is
shown once β then activate the endpoint with PATCH /v1/notifications/webhooks/{id}
{ "active": true }. Nothing is delivered until it is active.The envelope
Every event, in every category, arrives in this shape:
Signature headers and how to verify them are covered in
Verify webhook signatures.
When each event fires
Onramps, offramps and conversions
These three run the same lifecycle, so they emit the same six events.A returned offramp refunds your wallet automatically. If the payout had already been funded
from your balance when the provider sent the money back, we credit the full source amount back to
the same wallet. The refund shows up in
GET /v1/transactions
as a deposit-type transaction with source_type of offramp_reversal, linked to the original
offramp as its parent. No deposit.* event is emitted for it β offramp.returned is the signal,
and the offrampβs source_amount is the amount returned.Transfers
A cancelled or reversed transfer arrives as
transfer.failed β failure_reason tells you which
(cancelled, reversed, or another code).
Deposits
Two paths are worth planning for:
- A held deposit sends two events.
deposit.heldfirst, thendeposit.confirmedwhen the hold is released. Donβt treatheldas final, and donβt expect it on every deposit. - A cross-currency deposit never reaches a terminal
deposit.*event. It stays atreceived, and theconversion.*events carry the outcome. Waiting fordeposit.confirmedon that path waits forever.
Verifications
Only final outcomes are sent. Intermediate progress β a customer opening the link, submitting
documents, or being asked to resubmit β is visible on
GET /v1/customers/{customerId}/verifications and does not emit a webhook.
Payloads
event_object is the REST resource body plus event-only fields. Two groups of extras:
Onramp
A completed NGN β USDC onramp. Same body asGET /v1/onramps/{id}, plus the four event fields.
transaction_hash is present or absent, never null. It is the on-chain hash of the payout
that delivered your crypto, so it appears on onramp.success and effectively nowhere else: an
offramp pays a bank account and has no on-chain leg, and a conversion between two of your own
balances moves nothing on-chain. Some onramp rails also settle before a hash exists.Treat it as a convenience, not a guarantee, and store it when you receive it: this webhook is
the only place it appears. GET /v1/onramps/{id} does not return it, and a hash that reaches us
after the terminal event does not trigger a second webhook β an onramp delivers onramp.success
exactly once. If you need a hash you did not capture, contact support.Offramp
A failed USDC β NGN offramp. Notefailure_reason, the null actual_* values (nothing settled),
and that terminal_at is set because failed is terminal.
Conversion
A wallet-to-wallet conversion that has just been created.actual_* and terminal_at are null
because nothing has settled yet.
Transfer
A completed crypto payout. Transfers have no FX leg, so there are noactual_* fields β only
terminal_at.
Deposit
A confirmed same-currency crypto deposit.event_object is the transaction record, so it carries
transaction_hash, the wallet it credited, and the on-chain addresses in metadata.
event_object_status reports the deposit event (received / held / confirmed / failed),
while event_object.status reports the underlying transaction status (processing / received /
success / failed). They describe the same thing at different resolutions β branch on
event_type.Cross-currency deposit
A deposit into a cross-currency credential adds three blocks βwallet, customer and credential β so you can see which credential was funded, including the
payer-facing bank details, without another call. destination_amount is the quoted value in the
walletβs currency.
deposit.held with metadata.hold_reason and a null
destination_amount β no rate is quoted while it is under review. On release you receive
deposit.received priced at the then-current rate.
Who sent a fiat deposit
When money lands in a fiat credential,metadata names the payer as far as the bank reported them.
The same keys appear on GET /v1/transactions/{id}, so a webhook and a later read agree.
Three rules to code against:
- A key you do not see was not reported. Keys are omitted, never blank, so check for presence
rather than comparing against
"". Banks report these inconsistently: you may get the name without the bank, or nothing at all. sender_account_numberis a last-4, always. Use it to help a human recognise a payment, not as an account identifier.- On deposits into a Paystack NGN virtual account, the bank and account describe the virtual account the money landed in, while the name is the payerβs. That rail does not expose the payerβs own bank details to us, and we would rather tell you that than guess.
from_address in metadata is the equivalent.
Verification
kyc.approved carries the verified profile. Identity-document numbers are masked to their last
four digits β we never send the full number.
kyc.rejected replaces profile with the reason:
tier_upgraded_from appears only when the approval actually promoted the customer. Fields with no
value are omitted rather than sent as null.
Delivery
1
Return 2xx quickly
Acknowledge with any
2xx as soon as you have stored the event. Do the work afterwards β a slow
handler looks like a failure and gets retried.2
Expect at-least-once, not exactly-once
A delivery can arrive more than once.
event_id is stable across every retry of the same event,
so record the ids you have processed and ignore repeats.3
Don't rely on order
Events are not guaranteed to arrive in lifecycle order. Use
event_object_status and
event_object_changes rather than assuming the previous event already landed, and ignore an
event that would move a resource backwards from a terminal state.transfer.failed) reach you once, not
twice.
Debugging a delivery
Replay only delivers to an active endpoint β an inactive one returns
409 invalid_status.
Activate it first, then resend.