Transfers use the payout webhook events. Every transfer sends payout_received when it's created, then one more event when it finishes. To receive them, create a webhook subscribed to the payout events, or to all.
| Event | Sent when | Transfer status |
|---|---|---|
payout_received | The transfer is created and your wallet is debited | pending |
payout_completed | The bank confirms the transfer | completed |
payout_failed | Vendy declines the transfer and your wallet is refunded. This is final. | failed |
payout_cancelled | The transfer is cancelled before it is processed and your wallet is refunded | cancelled |
When each event is sent
flowchart TD
A["POST /transfers/{businessId}/single<br/>or /bulk"] --> B{"Request valid?<br/>Balance enough?<br/>Reference unused?"}
B -- No --> X["400 error response<br/>no webhook, no debit"]
B -- Yes --> C["Wallet debited<br/>status: pending"]
C --> W1(["payout_received"])
C --> D{"Processed by Vendy"}
D -- "Cancelled before processing" --> E["Wallet refunded<br/>status: cancelled"]
E --> W4(["payout_cancelled"])
D -- "Declined by Vendy" --> F["Wallet refunded<br/>status: failed"]
F --> W3(["payout_failed"])
D -- "Sent to bank" --> G{"Bank result"}
G -- Success --> H["status: completed"]
H --> W2(["payout_completed"])
G -- "Rejected by bank" --> I["status: failed<br/>no webhook, not refunded"]
I -- "Retried through another bank" --> G
I -- "Declined by Vendy" --> F
A bank rejection isn't finalIf a bank rejects a transfer, its status changes to
failed, but no webhook is sent and your wallet isn't refunded yet. Vendy can retry it through another bank, and it may still end ascompleted. Onlypayout_failedmeans the transfer has finally failed and your wallet has been refunded.Treat
payout_completed,payout_failedandpayout_cancelledas the final outcomes. If you poll Get Transfer and seefailed, wait for one of these events before you refund your customer or send the money again.
Payload
Every event has the same shape: the event name in event.type and the transfer in data. data.refid is the reference you sent, so use it to match the event to your records.
{
"event.type": "payout_completed",
"data": {
"id": 1802,
"amount": 5000,
"businessId": 6,
"walletId": 39,
"bankId": 644,
"refid": "INV-2026-0042",
"currency": "NGN",
"channel": "bank",
"status": "completed",
"meta": {
"bank": {
"bankname": "GUARANTY TRUST BANK",
"maskedaccountnumber": "*****456789",
"beneficiaryname": "ADA OKAFOR"
},
"total": 5010.75,
"fee": 10.75,
"transfer": { "narration": "October invoice settlement" }
},
"createdAt": "2026-10-20T09:14:02.511Z",
"updatedAt": "2026-10-20T09:15:40.118Z"
}
}See Payouts events for more payload samples.
Verifying events
Each request includes an x-signature header. It's a base64-encoded HMAC-SHA256 of the raw request body, signed with your webhook's secret. Compute the same value and compare before trusting the event:
const crypto = require('crypto');
function isValid(rawBody, signature, secret) {
const expected = crypto.createHmac('sha256', secret).update(rawBody).digest('base64');
return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(signature));
}import base64, hashlib, hmac
def is_valid(raw_body: bytes, signature: str, secret: str) -> bool:
expected = base64.b64encode(hmac.new(secret.encode(), raw_body, hashlib.sha256).digest()).decode()
return hmac.compare_digest(expected, signature)Respond with 200 or 204 to acknowledge the event. The same transfer can produce more than one event over its life, so handle them by refid and status, not by arrival order.
