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

json
{
  "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:

FieldDescription
amountBase transaction amount — what the seller receives on release
platform_feeKashia platform fee charged to the buyer
merchant_feeMarketplace commission credited to the merchant (0 for single-vendor)
total_feesSum of platform_fee + merchant_fee
total_amountTotal paid by the buyer (amount + total_fees)
seller_amountAmount 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.

Header
X-Kashia-Signature: sha256=5d5b9e7c8b...

Verification examples

JavaScript
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

EventTriggered When
payment_link.createdA new payment link is created
escrow.createdPayment confirmed — the escrow is created (already active; escrow.active follows immediately with the same payload)
escrow.activePayment received, funds locked
escrow.awaiting_confirmationDelivery recorded (goods shipped / job completed) — includes a delivery object with a kind field
escrow.completedBuyer confirmed, funds released
escrow.refundedFull refund issued
escrow.cancelledEscrow cancelled
escrow.disputedDispute opened
dispute.openedA new dispute is opened
dispute.escalatedDispute escalated to Kashia admin
dispute.resolvedDispute resolved
withdrawal.initiatedManual withdrawal accepted and processing with the payment provider
withdrawal.successfulFunds sent to the user's bank account
withdrawal.failedWithdrawal failed — funds returned to available balance
payout.initiatedAutomatic settlement payout to the seller is processing (marketplace escrows)
payout.successfulSeller's settlement payout landed in their bank account
payout.failedSettlement payout failed — the seller's funds remain in their Kashia wallet; retryable
bank_account.addedA verified bank account was added
webhook.testSent 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.

json
{
  "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

json
{
  "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.

json
{
  "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

json
{
  "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.

json
{
  "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.

json
{
  "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_id to deduplicate (you may receive the same event more than once)
  • Verify signatures before processing
  • Store webhook payloads for debugging