Skip to main content
Webhooks provide real-time notifications about payment events, allowing your application to respond immediately to payment completions, failures, and status changes. This guide covers implementing secure webhook endpoints.

Why Use Webhooks

  • Real-time updates: Immediate notification when payouts and orders complete
  • Reliable delivery: Built-in retry mechanisms for failed deliveries
  • Reduced polling: No need to constantly check payout status
  • Better UX: Instant confirmation and account updates

Webhook Events

Zuba sends webhooks for the following events:

Payout Events

  • payout.processing - Payout is being processed by the payment provider
  • payout.paid - Payout completed successfully
  • payout.failed - Payout failed before settlement
  • payout.reverted - A payout that had already settled (payout.paid) was later rejected by the provider’s downstream bank; an operator manually refunded the merchant and the funds were credited back. Unlike payout.failed (a pre-settlement failure), this payout did originally succeed: do not automatically re-issue it. (Not yet delivered, pending a backend enum migration. You can register a handler now, but it will only start firing once that migration ships.)
  • payout.cancelled - Payout was cancelled downstream while processing. Cancellations you initiate via POST /v1/payouts/{id}/cancel are confirmed synchronously by the cancel response and do not emit a webhook

Payout Lifecycle

Where each event fires in a payout’s life. created and queued are internal pre-processing states and fire no webhook. The first delivery you receive is normally payout.processing, but a payout that fails validation or funding before processing (for example, insufficient balance) emits payout.failed as its first and only event:
The decline reason is not included in the payout.failed webhook payload. When you receive one, fetch GET /v1/payouts/{id} and read the failureReason field.

Order Events

Orders (POST /v1/orders) are executed manually by Zuba’s trade desk; webhooks are how you learn the outcome. See Trade Desk Orders for the full lifecycle.
  • order.created - The order was created. Fires together with order.funds_received (balance-funded) or order.awaiting_funds (settlement-funded)
  • order.awaiting_funds - Funding instructions were issued; the payload’s settlement block carries the account or address, amount, and reference to fund the order
  • order.funds_received - The source amount is held and the order is queued for desk execution
  • order.completed - The desk delivered to your beneficiary; uetr is present for wire deliveries
  • order.failed - The order failed (failureReason set) and the held funds were returned to your balance
  • order.expired - The funding window closed before the order was funded; a transfer arriving later credits the balance instead
There is no order.cancelled event (cancellation is answered synchronously by POST /v1/orders/{id}/cancel) and no event for the executing status (desk review is internal).

Account Events

  • account.created - An account record was created via POST /v1/accounts

Application Events

Your onboarding application moves through review out of band, so these events report where it stands.
  • application.under_review - The application was submitted and is being reviewed
  • application.more_info_requested - Review needs more from you; the payload adds moreInfoReason and the editableSections to complete
  • application.approved - The application was approved
  • application.rejected - The application was rejected

Virtual Account Events

Most currencies issue virtual accounts asynchronously: POST /v1/virtual-accounts returns status: "pending" and the account completes out of band, which can take far longer than a request cycle. These events are the completion signal, so use them rather than a polling loop. A delivery can be retried but is never replayed on demand, so if you have been waiting longer than you expect, repeat the POST: it is idempotent per owner and currency, and answers with the pending account, the active one, or the refusal that stands. GET /v1/virtual-accounts lists active accounts only, so a pending account and a refused one are both simply absent from it. A refusal answers on the POST itself when the provider can settle it inside the request: 409 VIRTUAL_ACCOUNT_VERIFICATION_REQUIRED means the provider requires verification material for your business or its owner beyond the application’s fields. Complete the outstanding onboarding verification and repeat the POST — the request is refused with the same code until the verification passes. A repeat within ten minutes of the last refusal is answered from that refusal without consulting the provider, so allow that interval before retrying. 409 VIRTUAL_ACCOUNT_DEACTIVATED is different: the account was deactivated, and only support can re-issue it.
  • virtual_account.active - The account was approved and now carries its issued details; fetch them with GET /v1/virtual-accounts
  • virtual_account.failed - The account was refused after issuance, or can never produce usable payment details. A repeat POST /v1/virtual-accounts for the same currency answers with the refusal that stands (409 VIRTUAL_ACCOUNT_VERIFICATION_REQUIRED for a provider verification refusal, 409 VIRTUAL_ACCOUNT_DEACTIVATED for a deactivation only support can lift)
Which events a currency can fire follows from where its approval step sits:
  • XOF/XAF/GHS accounts are approved after creation, so a refusal lands on an account you already hold as pending — these are the currencies virtual_account.failed fires for.
  • EUR/GBP accounts are approved before creation: a refusal surfaces as an error on POST /v1/virtual-accounts itself. Expect virtual_account.active only; these two currencies emit no virtual_account.failed, so an account that stays pending far longer than usual needs support rather than a retry.
  • USD accounts are verified after creation, and part of the verification can also settle inside the request: a POST the provider refuses outright answers 409 VIRTUAL_ACCOUNT_VERIFICATION_REQUIRED with nothing created — complete the outstanding verification and repeat the request. Once the POST answers 201 with status: "pending", the remaining verification can run for days: virtual_account.active carries the issued wire details, and virtual_account.failed means the verification refused the account — a repeat request then answers 409 VIRTUAL_ACCOUNT_VERIFICATION_REQUIRED while the refusal stands; where the refusal names verification material you can supply, completing it and repeating the request is the recovery, otherwise contact support.
When accountId is not null the account belongs to that sub-account, and both follow-up calls are owner-scoped: send Zuba-Account-Id: <accountId> on the GET or the repeat POST, or the request addresses your master account’s accounts instead. The payload identifies the account but never carries bank details — always read those from the authenticated GET /v1/virtual-accounts. NGN accounts emit no event. In production nobody issues them through this endpoint — POST /v1/virtual-accounts answers 422 VIRTUAL_ACCOUNT_SELF_SERVE_UNSUPPORTED for NGN. NG-incorporated workspaces have one provisioned during onboarding review and read it from GET /v1/virtual-accounts once the application is approved; anyone else needing NGN collection should contact support. In the sandbox, POST /v1/virtual-accounts returns the account number directly for your master account.

Other Events

  • webhook.test - Test event sent via the test endpoint

Event Payload

Every webhook delivery is a JSON POST request with this envelope:

Event-specific data fields

payout.* events order.* events account.created application.* events virtual_account.* events Each request includes these headers:

Setting Up Webhook Endpoints

Webhook endpoint URLs must use HTTPS: registering an http:// URL is rejected with a 400.

Basic Webhook Handler

Create an endpoint to receive webhook notifications:

Processing Different Event Types

Handle specific webhook events:

Webhook Security

Verifying Signatures

Every webhook delivery includes a cryptographic signature so you can verify the request genuinely originated from Zuba and has not been tampered with. You should always verify signatures before processing webhook events. The signature is computed as:
Where:
  • signing_secret is the plaintext secret (the whsec_-prefixed value returned when you created the endpoint)
  • timestamp is the value from the X-Zuba-Timestamp header
  • raw_body is the raw HTTP request body (not parsed JSON)
  • The result is hex-encoded (64 characters)
Your signing secret is only shown once when you create a webhook endpoint or rotate the secret. Store it securely. If you lose it, use the rotate secret endpoint to generate a new one.

Verification Steps

  1. Extract the X-Zuba-Signature and X-Zuba-Timestamp headers
  2. Concatenate the timestamp, a literal ., and the raw request body
  3. Compute the HMAC-SHA256 of that string using your signing secret
  4. Compare your computed signature against the header value using a constant-time comparison
  5. Check that the timestamp is recent (within your tolerance window) to prevent replay attacks

Code Examples

Replay Protection

The timestamp is included in the signed content specifically to enable replay protection. An attacker who intercepts a valid webhook cannot replay it at a later time if you enforce a timestamp tolerance. We recommend rejecting any webhook where the X-Zuba-Timestamp is more than 5 minutes from your server’s current time. All the examples above implement this check.
Make sure your server’s clock is synchronized with NTP. A clock that drifts significantly could cause valid webhooks to be rejected.

IP Allowlisting

Zuba does not currently publish a static egress IP range for webhook deliveries. Signature verification (above) is the supported way to authenticate incoming webhooks: it proves both origin and integrity, which an IP check alone does not.

Handling Webhook Failures

Idempotency

Ensure webhooks can be safely retried:

Retry Logic

Implement exponential backoff for webhook processing:

Delivery and Retries

Zuba retries failed deliveries up to 5 times (6 attempts in total) with exponential backoff. A delivery is successful when your endpoint responds with a 2xx status code within 30 seconds. Redirects are not followed: a 3xx response counts as a failed attempt. Delays include random jitter and can be up to twice these values. Treat them as lower bounds. After all attempts are exhausted, the delivery is marked as failed. You can view delivery history via the API:
To inspect a single delivery, including the exact payload that was sent, fetch GET /v1/webhooks/{endpoint_id}/deliveries/{delivery_id}.
Your endpoint must respond within 30 seconds. If you need to do long-running processing, accept the webhook with a 200 response immediately and process the event asynchronously.

Testing Webhooks

Sending a Test Event

Trigger a webhook.test delivery to a registered endpoint:
The delivery carries "test": true in the envelope and is signed like any other event, so you can exercise your signature verification and processing end to end.

Local Testing with ngrok

Expose your local development server with ngrok:

Complete Example

Next Steps

  • Learn about error handling strategies for webhook failures
  • Explore the API reference for complete event schemas
  • Set up monitoring and alerting for production webhook endpoints
  • Implement webhook event replay for failed processing scenarios