Webhooks
Kashia sends webhook notifications to your server when events occur. Configure your webhook URL in Dashboard → Integrations. All webhooks are signed with your webhook secret for verification.
Failed deliveries are retried up to 3 times with exponential backoff.
Webhook payload format
{
"event": "escrow.completed",
"data": {
"id": "cb3dc305-2061-4d1b-9062-aec4c33fd849",
"reference": "ESC-a1b2c3d4",
"order_id": "ORD-12345",
"payment_link_id": "5d2b066d-534e-40f7-aa7f-ccd7a44dd949",
"merchant_id": "merchant-uuid",
"buyer_user_id": "buyer-uuid",
"seller_user_id": "seller-uuid",
"title": "iPhone 15 Pro Max",
"status": "completed",
"environment": "live",
"provider": "monnify",
"amount": 100000000,
"currency": "NGN",
"platform_fee": 1500000,
"merchant_fee": 10000000,
"total_fees": 11500000,
"total_amount": 111500000,
"seller_amount": 100000000
},
"timestamp": "2025-01-15T10:30:00Z",
"webhook_id": "webhook-event-uuid"
}On every escrow.* event, data.id is the escrow id — store it (or just your order_id, which is echoed back whenever you set one on the payment link) to drive follow-up calls like action links, deliver, and confirm. payment_link_id ties the escrow back to the link that was paid; provider is absent on simulated (test) escrows.
Every payload — every event type, not just escrow.* — carries environment: "test" for events produced by your test key (simulated payments, test escrows) and "live" for real money. The same value is sent as the X-Kashia-Environment header, and the event name as X-Kashia-Event.
Test and live have separate endpoints and separate signing secrets. Switch the dashboard to Test to set the URL that receives test events — a local tunnel, for instance — and to Live for production. A test payload is signed with the test secret only, so it can never validate against your production secret.Send test webhook on the Integrations page delivers a signed webhook.testevent to the current environment's endpoint and shows the status code your server returned.
Escrow events include fee breakdown fields. As everywhere in the API, amounts are 64-bit integers (long) in kobo:
| Field | Description |
|---|---|
amount | Base transaction amount — what the seller receives on release |
platform_fee | Kashia platform fee charged to the buyer |
merchant_fee | Marketplace commission credited to the merchant (0 for single-vendor) |
total_fees | Sum of platform_fee + merchant_fee |
total_amount | Total paid by the buyer (amount + total_fees) |
seller_amount | Amount released to the seller — equals amount |
Signature verification
Kashia signs every webhook with your webhook secret using HMAC-SHA256. The signature is sent in the X-Kashia-Signature header.
X-Kashia-Signature: sha256=5d5b9e7c8b...Verification examples
const crypto = require('crypto');
const express = require('express');
const app = express();
"code-hl-comment">// IMPORTANT: verify against the RAW request body bytes, before any JSON
"code-hl-comment">// parsing — re-serializing the parsed object can produce different bytes
"code-hl-comment">// and reject valid webhooks.
app.use('/webhooks/kashia', express.raw({ type: 'application/json' }));
function verifyWebhook(rawBody, signature, secret) {
const expectedSignature = 'sha256=' +
crypto.createHmac('sha256', secret)
.update(rawBody)
.digest('hex');
return crypto.timingSafeEqual(
Buffer.from(signature),
Buffer.from(expectedSignature)
);
}
app.post('/webhooks/kashia', (req, res) => {
const signature = req.headers['x-kashia-signature'];
if (!verifyWebhook(req.body, signature, process.env.KASHIA_WEBHOOK_SECRET)) {
return res.status(401).send('Invalid signature');
}
const { event, data } = JSON.parse(req.body);
switch (event) {
case 'escrow.active':
"code-hl-comment">// Payment received — update your order status
break;
case 'escrow.completed':
"code-hl-comment">// Delivery confirmed — funds released
break;
case 'escrow.disputed':
"code-hl-comment">// Dispute opened — notify relevant parties
break;
}
res.status(200).send('OK');
});Event types
| Event | Triggered When |
|---|---|
payment_link.created | A new payment link is created |
escrow.created | Payment confirmed — the escrow is created (already active; escrow.active follows immediately with the same payload) |
escrow.active | Payment received, funds locked |
escrow.awaiting_confirmation | Delivery recorded (goods shipped / job completed) — includes a delivery object with a kind field |
escrow.completed | Buyer confirmed, funds released |
escrow.refunded | Full refund issued |
escrow.cancelled | Escrow cancelled |
escrow.disputed | Dispute opened |
dispute.opened | A new dispute is opened |
dispute.escalated | Dispute escalated to Kashia admin |
dispute.resolved | Dispute resolved |
withdrawal.initiated | Manual withdrawal accepted and processing with the payment provider |
withdrawal.successful | Funds sent to the user's bank account |
withdrawal.failed | Withdrawal failed — funds returned to available balance |
payout.initiated | Automatic settlement payout to the seller is processing (marketplace escrows) |
payout.successful | Seller's settlement payout landed in their bank account |
payout.failed | Settlement payout failed — the seller's funds remain in their Kashia wallet; retryable |
bank_account.added | A verified bank account was added |
webhook.test | Sent when you click Send test webhook on the Integrations page — verifies your endpoint and signature |
This table is the complete list of events Kashia sends. Every event uses the same envelope, signature, and retry policy described on this page — including the withdrawal.* and payout.* money-movement events, which fire the moment a withdrawal is initiated and again when it settles.
Delivery details on awaiting confirmation
When delivery is recorded, the escrow.awaiting_confirmation webhook includes a delivery object carrying kind: for goods, tracking, carrier, dispatch contact, and notes; for services, the provider's completion summary in notes plus evidence image metadata — the goods keys are simply absent.
{
"event": "escrow.awaiting_confirmation",
"data": {
"id": "escrow-uuid",
"reference": "ESC-a1b2c3d4",
"status": "awaiting_confirmation",
"kind": "goods",
"delivery": {
"kind": "goods",
"shipped_at": "2025-01-15T10:30:00Z",
"tracking_number": "TRK-123456",
"carrier": "DHL",
"carrier_website": "https://dhl.com/track/TRK-123456",
"dispatch_name": "John Doe",
"dispatch_phone": "+2348000000000",
"estimated_delivery_date": "2025-01-20",
"notes": "Left with reception"
}
}
}Withdrawal webhook example
{
"event": "withdrawal.successful",
"data": {
"withdrawal_id": "uuid",
"reference": "WTH-a1b2c3d4",
"amount": 9500000,
"net_amount": 9495000,
"status": "successful",
"user_id": "user-uuid"
},
"timestamp": "2025-01-15T10:35:00Z",
"webhook_id": "webhook-event-uuid"
}Settlement payout webhook example
When a marketplace escrow settles, Kashia automatically pays the seller and emits payout.* events. The payout amount equals seller_amount exactly — settlement payouts carry no withdrawal fee.
{
"event": "payout.successful",
"data": {
"withdrawal_id": "uuid",
"reference": "PYT-a1b2c3d4",
"escrow_id": "escrow-uuid",
"amount": 50000000,
"net_amount": 50000000,
"status": "successful",
"user_id": "seller-user-uuid"
},
"timestamp": "2025-01-15T10:35:00Z",
"webhook_id": "webhook-event-uuid"
}On payout.failed and withdrawal.failed, the payload additionally carries status_reason — why the transfer failed.
Payment link webhook example
{
"event": "payment_link.created",
"data": {
"id": "payment-link-uuid",
"reference": "PL-a1b2c3d4",
"order_id": "ORD-12345",
"amount": 150000000,
"total_amount": 152250000,
"currency": "NGN",
"status": "active"
},
"timestamp": "2025-01-15T10:30:00Z",
"webhook_id": "webhook-event-uuid"
}order_id is present when you set one on the link. Amounts are in kobo; total_amount is what the buyer pays including fees.
Dispute webhook examples
All three dispute events carry the dispute_id and the escrow_id they belong to, so you can tie them back to your transaction (or fetch it via GET /external/escrows/:escrowId). The escrow itself also emits escrow.disputed when a dispute opens, and escrow.completed or escrow.refunded when it resolves.
{
"event": "dispute.opened",
"data": {
"dispute_id": "dispute-uuid",
"escrow_id": "escrow-uuid",
"reason": "item_not_received"
},
"timestamp": "2025-01-15T10:30:00Z",
"webhook_id": "webhook-event-uuid"
}
{
"event": "dispute.escalated",
"data": {
"dispute_id": "dispute-uuid",
"escrow_id": "escrow-uuid"
},
"timestamp": "2025-01-15T11:00:00Z",
"webhook_id": "webhook-event-uuid"
}
{
"event": "dispute.resolved",
"data": {
"dispute_id": "dispute-uuid",
"escrow_id": "escrow-uuid",
"resolution": "refund_buyer"
},
"timestamp": "2025-01-15T12:00:00Z",
"webhook_id": "webhook-event-uuid"
}Bank account webhook example
Sent when a verified bank account is added through your integration. The event carries the environment of the API key that added it and is signed with that environment's secret.
{
"event": "bank_account.added",
"data": {
"user_id": "user-uuid",
"bank_account_id": "bank-account-uuid",
"bank_name": "GTBank",
"account_number": "0123456789",
"account_name": "ADAOBI OKAFOR"
},
"timestamp": "2025-01-15T10:30:00Z",
"webhook_id": "webhook-event-uuid"
}Retry policy
- First retry: 1 minute after failure
- Second retry: 5 minutes after first retry
- Third retry: 30 minutes after second retry
- When the third retry also fails, the webhook is marked as failed
Best practices
- Always return 200 OK quickly — process webhook data asynchronously
- Use the
webhook_idto deduplicate (you may receive the same event more than once) - Verify signatures before processing
- Store webhook payloads for debugging