Webhooks

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.

EventSent whenTransfer status
payout_receivedThe transfer is created and your wallet is debitedpending
payout_completedThe bank confirms the transfercompleted
payout_failedVendy declines the transfer and your wallet is refunded. This is final.failed
payout_cancelledThe transfer is cancelled before it is processed and your wallet is refundedcancelled

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 final

If 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 as completed. Only payout_failed means the transfer has finally failed and your wallet has been refunded.

Treat payout_completed, payout_failed and payout_cancelled as the final outcomes. If you poll Get Transfer and see failed, 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.