400 with the code webhook_secret_required.
Verifying a signature
Each request carries two headers:
Compute the signature over the raw request body, before any JSON parsing, and compare it in constant time. Reject requests whose timestamp is more than five minutes old.
Delivery and retries
Answer with200 once you have stored the event. Any other response, or a timeout, is retried up to five times with exponentially growing delays. The same event can arrive more than once, so make your handler idempotent: use data.transaction.reference as the key.
Virtual account events
All amounts are decimal strings in the account currency, except
destinationAmount, which is in USD. sender.accountNumber is masked to its last four digits.
virtual_account.payment.received
virtual_account.payment.manual_review
Same shape, with transaction.status set to Manual Review and a review object:
For reasons other than
amount_variance, expectedAmount and variance are null.
After the review you receive either virtual_account.payment.received (approved) or virtual_account.payment.rejected (declined) for the same transaction.reference.
virtual_account.payment.rejected
Same shape as received, with transaction.status set to Rejected.
Account status events
Creating an account returns one of these statuses right away. Some change later, and you get a webhook when they do, so you never need to poll.
An account that is rejected while you are creating it returns
422 straight away with the reason in errors[0].reason, and no account is created:
virtual_account.created
virtual_account.failed
Same shape with status: "failed", bankDetails: null and a reason:
The same
statusReason is on the account in GET /virtual-accounts/{uuid}.
virtual_account.expired
Same shape with status: "expired", no bankDetails and no reason.
Checkout events
Checkout payments sendsuccess, failed, pending, partially_paid and the chargeback events with the existing payload, unchanged.
