Skip to main content
Webhooks tell you when something happens on your integration β€” a deposit arrives, a conversion settles, a verification is approved β€” without polling for it. Every delivery uses the same envelope. The interesting part is 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.
funds_received and processing are separate events on purpose β€” funds_received is your cue that the money is in, which is usually what you tell a customer about. Don’t treat either as final; wait for success, failed or returned.
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.held first, then deposit.confirmed when the hold is released. Don’t treat held as final, and don’t expect it on every deposit.
  • A cross-currency deposit never reaches a terminal deposit.* event. It stays at received, and the conversion.* events carry the outcome. Waiting for deposit.confirmed on 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:
Reconcile money against actual_*, not against rate/destination_amount. The quoted values are what you were promised; the actual_* values are what settled. They are null until the economics are known.

Onramp

A completed NGN β†’ USDC onramp. Same body as GET /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. Note failure_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 no actual_* 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.
A held cross-currency deposit arrives as 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_number is 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.
Crypto deposits carry no sender keys β€” the on-chain 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.
A given resource emits a given event at most once β€” so two upstream changes that map to the same event (a cancelled transfer and then a reversed one, both 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.