Skip to main content

Documentation Index

Fetch the complete documentation index at: https://developers.kotanipay.com/llms.txt

Use this file to discover all available pages before exploring further.

Kotani Pay pushes notifications to your server when key events happen — transaction status changes, refund outcomes, KYC updates, and system notices.

Two Delivery Modes

How notifications are delivered depends on whether a webhook secret is configured on your account.

Signed webhooks

When a webhook secret is configured, all events are delivered through the signed system. Each POST request includes:
HeaderValue
X-Kotani-Signaturesha256=<hmac> — use this to verify authenticity
X-Kotani-EventThe event name (e.g. transaction.deposit.status.updated)
X-Kotani-IntegratorYour integrator ID
Content-Typeapplication/json
The body is always wrapped in this envelope:
{
  "event": "transaction.deposit.status.updated",
  "data": {
    "status": "SUCCESSFUL",
    "reference_id": "order-abc-001",
    "reference_number": "MP250101001",
    "id": "64a1b2c3d4e5f6a7b8c9d0e1",
    "amount": 1000,
    "wallet_id": "64a1b2c3d4e5f6a7b8c9d0e2",
    "callback_url": "https://your-server.com/webhook",
    "created_at": "2025-01-01T00:00:00.000Z",
    "transaction_amount": 975,
    "transaction_cost": 25,
    "customer_key": "cust_abc123",
    "telco_id": "OEI2AK4D9X",
    "timestamp": "2025-01-01T00:01:30.000Z"
  },
  "signature": "sha256=a1b2c3d4e5f6..."
}
The signature field in the body mirrors X-Kotani-Signature — the header is the source of truth for verification.

Direct callbacks

If no webhook secret is configured, Kotani Pay posts directly to the callbackUrl set on each transaction at the time it was created. These requests:
  • Are sent as POST with a JSON body
  • Do not include X-Kotani-Signature, X-Kotani-Event, or X-Kotani-Integrator headers
  • Contain the transaction data fields directly in the body — no event or signature wrapper
Example deposit callback body:
{
  "status": "SUCCESSFUL",
  "reference_id": "order-abc-001",
  "reference_number": "MP250101001",
  "id": "64a1b2c3d4e5f6a7b8c9d0e1",
  "amount": 1000,
  "wallet_id": "64a1b2c3d4e5f6a7b8c9d0e2",
  "callback_url": "https://your-server.com/webhook",
  "created_at": "2025-01-01T00:00:00.000Z",
  "transaction_amount": 975,
  "transaction_cost": 25,
  "customer_key": "cust_abc123",
  "telco_id": "OEI2AK4D9X"
}
Example withdrawal callback body:
{
  "status": "SUCCESSFUL",
  "referenceId": "payout-xyz-001",
  "referenceNumber": "MW250101001",
  "id": "64a1b2c3d4e5f6a7b8c9d0e3",
  "amount": 500,
  "walletId": "64a1b2c3d4e5f6a7b8c9d0e2",
  "callbackUrl": "https://your-server.com/webhook",
  "created_at": "2025-01-01T00:00:00.000Z",
  "transactionAmount": 480,
  "transactionCost": 20,
  "customerKey": "cust_abc123",
  "telcoId": "OEI2AK4D9X"
}
Configure a webhook secret in Settings to switch to signed webhooks.

Supported Events

The following events are fired by the signed webhook system.
EventWhen it fires
transaction.deposit.status.updatedA deposit transaction changes status
transaction.withdrawal.status.updatedA withdrawal transaction changes status
transaction.onramp.status.updatedAn onramp (fiat→crypto) transaction changes status
transaction.offramp.status.updatedAn offramp (crypto→fiat) transaction changes status
kyc.status.changedA customer’s KYC verification outcome changes
refund.completedCrypto refund successfully sent back to the sender
refund.failedRefund exhausted all retry attempts — manual intervention required
refund.lightning.invoice_neededLightning offramp needs a bolt11 invoice to process the refund
system.eventOperational notices and maintenance alerts
transaction.status.updated(Deprecated) Generic status update — use the specific events above

Verifying Signatures

Always verify the X-Kotani-Signature header before processing the event.
  1. Parse the JSON body.
  2. Remove the signature field from the parsed object.
  3. Compute sha256=HMAC-SHA256(secret, JSON.stringify({event, data})).
  4. Compare with X-Kotani-Signature using a timing-safe comparison.
import crypto from 'crypto';

function verifyWebhook({
  secret,
  payload,
  headerSignature,
}: {
  secret: string;
  payload: { event: string; data: Record<string, any>; signature?: string };
  headerSignature: string;
}): boolean {
  const { signature, ...payloadWithoutSignature } = payload;
  const computed =
    'sha256=' +
    crypto
      .createHmac('sha256', secret)
      .update(JSON.stringify(payloadWithoutSignature))
      .digest('hex');
  try {
    return crypto.timingSafeEqual(
      Buffer.from(computed),
      Buffer.from(headerSignature.trim()),
    );
  } catch {
    return false;
  }
}
import express from 'express';

const app = express();

app.post('/webhook', express.json(), (req, res) => {
  const isValid = verifyWebhook({
    secret: process.env.KOTANI_WEBHOOK_SECRET!,
    payload: req.body,
    headerSignature: req.headers['x-kotani-signature'] as string,
  });

  if (!isValid) return res.status(401).send('Invalid signature');

  const { event, data } = req.body;
  // handle event...

  res.status(200).send('OK');
});

Event Payloads

Each event type carries a different data shape. Deposits use snake_case field names; withdrawals, onramp, and offramp use camelCase.

transaction.deposit.status.updated

{
  "event": "transaction.deposit.status.updated",
  "data": {
    "status": "SUCCESSFUL",
    "reference_id": "order-abc-001",
    "reference_number": "MP250101001",
    "id": "64a1b2c3d4e5f6a7b8c9d0e1",
    "amount": 1000,
    "wallet_id": "64a1b2c3d4e5f6a7b8c9d0e2",
    "callback_url": "https://your-server.com/webhook",
    "created_at": "2025-01-01T00:00:00.000Z",
    "transaction_amount": 975,
    "transaction_cost": 25,
    "customer_key": "cust_abc123",
    "telco_id": "OEI2AK4D9X",
    "timestamp": "2025-01-01T00:01:30.000Z"
  },
  "signature": "sha256=a1b2c3d4e5f6..."
}
For non-successful deposits the payload also includes all four error fields (they may be empty strings if no detail is available):
{
  "error_message": "Insufficient funds",
  "error_description": "The customer does not have enough funds",
  "error_code": "INSUFFICIENT_FUNDS",
  "transactionError": "..."
}
FieldDescription
statusCurrent transaction status
reference_idYour reference ID (or system-generated)
amountAmount requested
transaction_amountAmount actually collected (after fees)
transaction_costFee charged
customer_keyThe customer identifier
telco_idMobile money network (e.g. MPESA, MTN)
confirmation_idProvider transaction reference (e.g. M-Pesa confirmation code)

transaction.withdrawal.status.updated

{
  "event": "transaction.withdrawal.status.updated",
  "data": {
    "status": "SUCCESSFUL",
    "referenceId": "payout-xyz-001",
    "referenceNumber": "MW250101001",
    "id": "64a1b2c3d4e5f6a7b8c9d0e3",
    "amount": 500,
    "walletId": "64a1b2c3d4e5f6a7b8c9d0e2",
    "callbackUrl": "https://your-server.com/webhook",
    "created_at": "2025-01-01T00:00:00.000Z",
    "transactionAmount": 480,
    "transactionCost": 20,
    "customerKey": "cust_abc123",
    "telcoId": "MPESA",
    "confirmationId": "OEI2AK4D9Y",
    "timestamp": "2025-01-01T00:02:00.000Z"
  },
  "signature": "sha256=a1b2c3d4e5f6..."
}
For failed withdrawals, also includes:
{
  "errorMessage": "Recipient number not found",
  "transactionError": "..."
}
Withdrawal fields are camelCase (referenceId, walletId, customerKey) whereas deposit fields are snake_case (reference_id, wallet_id, customer_key). Handle both in your webhook handler.

transaction.onramp.status.updated

Onramp has two separate status fields — one for the fiat collection and one for the on-chain crypto transfer.
{
  "event": "transaction.onramp.status.updated",
  "data": {
    "referenceId": "onramp-001",
    "depositStatus": "SUCCESSFUL",
    "onchainStatus": "SUCCESSFUL",
    "transactionHash": "0xabc123def456...",
    "cryptoAmount": 0.00125,
    "fiatAmount": 5000,
    "rate": {
      "from": "KES",
      "to": "USDT",
      "cryptoAmount": 0.00125
    },
    "timestamp": "2025-01-01T00:05:00.000Z"
  },
  "signature": "sha256=a1b2c3d4e5f6..."
}
For failed onramp:
{
  "error": {
    "message": "Crypto transfer failed after retries",
    "code": "CRYPTO_TRANSFER_FAILED",
    "details": {}
  }
}
FieldDescription
depositStatusStatus of the fiat payment collection
onchainStatusStatus of the crypto delivery to the wallet
transactionHashBlockchain transaction hash (once on-chain transfer completes)
cryptoAmountAmount of crypto delivered
fiatAmountFiat amount collected

transaction.offramp.status.updated

{
  "event": "transaction.offramp.status.updated",
  "data": {
    "referenceId": "offramp-001",
    "status": "SUCCESSFUL",
    "onchainStatus": "SUCCESSFUL",
    "fiatAmount": 5000,
    "fiatTransactionAmount": 4850,
    "cryptoAmount": 38.5,
    "fiatCurrency": "KES",
    "customerKey": "cust_abc123",
    "fiatWalletId": "64a1b2c3d4e5f6a7b8c9d0e2",
    "senderAddress": "0xabc123...",
    "transactionHash": "0xdef456...",
    "transactionHashAmount": 38.5,
    "rate": {
      "from": "USDT",
      "to": "KES",
      "fiatAmount": 5000
    },
    "escrowAddress": "0xescrow123...",
    "usingIntegratedWallet": false,
    "created_at": "2025-01-01T00:00:00.000Z",
    "updated_at": "2025-01-01T00:05:00.000Z",
    "timestamp": "2025-01-01T00:05:00.000Z"
  },
  "signature": "sha256=a1b2c3d4e5f6..."
}
FieldDescription
statusOverall offramp status
onchainStatusOn-chain crypto receipt status
fiatAmountFull fiat amount before fees
fiatTransactionAmountAmount actually disbursed to recipient
cryptoAmountCrypto amount received from sender
senderAddressAddress that sent the crypto
escrowAddressKotani escrow address the crypto was sent to
usingIntegratedWalletWhether the platform’s own wallet was used

refund.completed

Fired when a crypto refund has been successfully sent back to the sender.
{
  "event": "refund.completed",
  "data": {
    "referenceId": "offramp-001",
    "status": "REVERSED",
    "refundStatus": "SUCCESSFUL",
    "refundTransactionHash": "0xrefund123...",
    "refundAmount": 38.5,
    "chain": "POLYGON",
    "token": "USDT",
    "currency": "KES",
    "timestamp": "2025-01-01T00:10:00.000Z"
  },
  "signature": "sha256=a1b2c3d4e5f6..."
}

refund.failed

Fired when a refund has exhausted all retry attempts. Manual intervention is required.
{
  "event": "refund.failed",
  "data": {
    "referenceId": "offramp-001",
    "refundStatus": "FAILED",
    "refundAmount": 38.5,
    "chain": "POLYGON",
    "token": "USDT",
    "currency": "KES",
    "error": "Refund failed after max retries",
    "totalRetries": 5,
    "timestamp": "2025-01-01T00:20:00.000Z"
  },
  "signature": "sha256=a1b2c3d4e5f6..."
}
Contact support with the referenceId when you receive this — the refund cannot proceed automatically.

refund.lightning.invoice_needed

Fired when a Lightning offramp fails and a bolt11 invoice is needed to return the funds. You must submit an invoice before the refund can proceed.
{
  "event": "refund.lightning.invoice_needed",
  "data": {
    "referenceId": "offramp-lightning-001",
    "status": "FAILED",
    "onchainStatus": "SUCCESSFUL",
    "refundStatus": "INVOICE_NEEDED",
    "refundAmount": 1500000,
    "refundAmountSats": 1500,
    "chain": "LIGHTNING",
    "currency": "KES",
    "requiresAction": true,
    "action": {
      "type": "SUBMIT_LIGHTNING_INVOICE",
      "description": "Submit Lightning invoice for 1500 sats to receive refund",
      "submitUrl": "https://api.kotanipay.com/api/v3/offramp/submit-refund-invoice/offramp-lightning-001",
      "method": "POST"
    },
    "timestamp": "2025-01-01T00:10:00.000Z"
  },
  "signature": "sha256=a1b2c3d4e5f6..."
}
Submit the invoice to the action.submitUrl:
curl -X POST https://api.kotanipay.com/api/v3/offramp/submit-refund-invoice/offramp-lightning-001 \
  -H "Authorization: Bearer <your-api-key>" \
  -H "Content-Type: application/json" \
  -d '{"invoice": "lnbc1500n1p0..."}'
See Offramp Refunds for the full refund lifecycle.

Configuring Webhooks

  1. Log in to the dashboard → Settings
  2. Enter a publicly reachable HTTPS endpoint URL
  3. Select the events you want to subscribe to (or leave all enabled)
  4. Copy the generated signing secret and store it securely
  5. Save
You can rotate the signing secret at any time. Update your verification logic with the new secret before applying it in production to avoid a verification gap.

Retries

If your endpoint returns a non-2xx response or times out, Kotani Pay retries delivery with exponential backoff — up to every 2 hours for a maximum of 24 hours. Return 200 OK as quickly as possible and handle processing asynchronously.