Skip to main content
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": { ... },
  "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. 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 fields directly in the body — no event or signature wrapper
Configure a webhook secret in Settings to switch to signed webhooks.

Supported Events

EventWhen it fires
transaction.deposit.status.updatedA deposit changes status
transaction.withdrawal.status.updatedA withdrawal changes status
transaction.onramp.status.updatedAn onramp (fiat→crypto) changes status
transaction.offramp.status.updatedAn offramp (crypto→fiat) 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
refund.lightning.invoice_neededLightning offramp needs a bolt11 invoice to process the refund
settlement.approvedA settlement request was approved
settlement.processedA settlement was completed and funds disbursed
settlement.rejectedA settlement request was rejected
settlement.pausedA settlement was paused pending review
settlement.batch.approvedA settlement batch was approved
settlement.batch.processedA settlement batch completed (fully or partially)
settlement.batch.rejectedA settlement batch was rejected
settlement.batch.cancelledA settlement batch was cancelled
system.eventOperational notices and maintenance alerts
transaction.status.updated(Deprecated) Use the specific events above
Settlement events are opt-in. Subscribe to them in Settings → Webhooks.

Verifying Signatures

Always verify the X-Kotani-Signature header before processing any 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

Casing conventions: Deposit fields use snake_case (reference_id, wallet_id, customer_key). Withdrawal, onramp, and offramp fields use camelCase (referenceId, walletId, customerKey). Handle both in your webhook handler.

transaction.deposit.status.updated

Fired whenever a deposit changes status — including intermediate states like INITIATED and IN_PROGRESS as well as terminal states (SUCCESSFUL, FAILED, CANCELLED).

Payload fields

status
string
required
Current deposit status. See Transaction Statuses.
reference_id
string
required
Your reference ID (or system-generated if not provided).
reference_number
number
required
Auto-generated sequential reference number.
id
string
required
Kotani internal record ID.
amount
number
required
Amount the customer was asked to pay.
wallet_id
string
required
ID of the integrator fiat wallet credited.
transaction_amount
number
required
Amount actually credited to your wallet after fees.
transaction_cost
number
required
Processing fee charged.
customer_key
string
required
The customer identifier supplied at deposit creation.
callback_url
string
Callback URL set on the transaction.
created_at
string
ISO 8601 creation timestamp.
telco_id
string
Mobile money receipt code from the network (e.g. OEI2AK4D9X). Present on successful mobile money deposits.
confirmation_id
string
Provider-level confirmation reference. May differ from telco_id for some providers.
bank_name
string
Bank name for bank-based deposits (e.g. Capitec, FNB). Only present for bank deposits.
bank_code
string
Bank code for bank-based deposits. Only present for bank deposits.
payment_brand
string
Card payment brand for card deposits (e.g. VISA, MASTERCARD). Only present for card deposits.
error_message
string
Human-readable failure reason. Always present (may be empty string) for non-successful statuses.
error_description
string
Detailed provider error description.
error_code
string
Provider error code.
transactionError
string
Raw internal error from the processing pipeline.

Example — successful mobile money deposit

{
  "event": "transaction.deposit.status.updated",
  "data": {
    "status": "SUCCESSFUL",
    "reference_id": "order-abc-001",
    "reference_number": 1001,
    "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"
  },
  "signature": "sha256=a1b2c3d4e5f6..."
}

Example — failed deposit

{
  "event": "transaction.deposit.status.updated",
  "data": {
    "status": "FAILED",
    "reference_id": "order-abc-002",
    "reference_number": 1002,
    "id": "64a1b2c3d4e5f6a7b8c9d0e3",
    "amount": 500,
    "wallet_id": "64a1b2c3d4e5f6a7b8c9d0e2",
    "created_at": "2025-01-01T00:00:00.000Z",
    "transaction_amount": 0,
    "transaction_cost": 0,
    "customer_key": "cust_abc123",
    "error_message": "Insufficient funds",
    "error_description": "The customer does not have enough funds",
    "error_code": "INSUFFICIENT_FUNDS",
    "transactionError": ""
  },
  "signature": "sha256=a1b2c3d4e5f6..."
}

Example — bank deposit (additional fields)

{
  "event": "transaction.deposit.status.updated",
  "data": {
    "status": "SUCCESSFUL",
    "reference_id": "order-bank-001",
    "amount": 2000,
    "transaction_amount": 1960,
    "transaction_cost": 40,
    "customer_key": "cust_za_001",
    "bank_name": "Capitec",
    "bank_code": "470010",
    "payment_brand": "VISA"
  },
  "signature": "sha256=a1b2c3d4e5f6..."
}

transaction.withdrawal.status.updated

Fired whenever a withdrawal changes status.

Payload fields

status
string
required
Current withdrawal status.
referenceId
string
required
Your reference ID.
referenceNumber
number
required
Auto-generated sequential reference number.
id
string
required
Kotani internal record ID.
amount
number
required
Amount requested for withdrawal.
walletId
string
required
ID of the integrator fiat wallet debited.
transactionAmount
number
required
Amount debited from your wallet including fees.
transactionCost
number
required
Processing fee charged.
customerKey
string
required
The customer identifier supplied at withdrawal creation.
callbackUrl
string
Callback URL set on the transaction.
created_at
string
ISO 8601 creation timestamp.
telcoId
string
Mobile money receipt code from the network. Present on successful mobile money payouts.
confirmationId
string
Provider-level confirmation reference.
integratorFeeAmount
number
Additional integrator fee charged on the transaction, if configured.
errorMessage
string
Human-readable failure reason. Always present (may be empty string) for non-successful statuses.
transactionError
string
Raw error from the processing pipeline.

Example — successful withdrawal

{
  "event": "transaction.withdrawal.status.updated",
  "data": {
    "status": "SUCCESSFUL",
    "referenceId": "payout-xyz-001",
    "referenceNumber": 2001,
    "id": "64a1b2c3d4e5f6a7b8c9d0e3",
    "amount": 500,
    "walletId": "64a1b2c3d4e5f6a7b8c9d0e2",
    "callbackUrl": "https://your-server.com/webhook",
    "created_at": "2025-01-01T00:00:00.000Z",
    "transactionAmount": 520,
    "transactionCost": 20,
    "customerKey": "cust_abc123",
    "telcoId": "OEI2AK4D9Y"
  },
  "signature": "sha256=a1b2c3d4e5f6..."
}

Example — failed withdrawal

{
  "event": "transaction.withdrawal.status.updated",
  "data": {
    "status": "FAILED",
    "referenceId": "payout-xyz-002",
    "referenceNumber": 2002,
    "id": "64a1b2c3d4e5f6a7b8c9d0e4",
    "amount": 300,
    "walletId": "64a1b2c3d4e5f6a7b8c9d0e2",
    "transactionAmount": 300,
    "transactionCost": 0,
    "customerKey": "cust_abc123",
    "errorMessage": "Recipient number not found",
    "transactionError": "INVALID_PHONE_NUMBER"
  },
  "signature": "sha256=a1b2c3d4e5f6..."
}

transaction.onramp.status.updated

Fired when a fiat→crypto onramp transaction changes status. Onramp has two independent status fields — fiat collection (depositStatus) and on-chain delivery (onchainStatus).

Payload fields

referenceId
string
required
Your reference ID for the onramp transaction.
status
string
required
Combined overall status of the onramp.
depositStatus
string
required
Status of the fiat payment collection leg.
onchainStatus
string
required
Status of the on-chain crypto delivery leg.
chain
string
required
Blockchain the crypto was sent on (e.g. POLYGON, STELLAR).
token
string
required
Token delivered (e.g. USDT, USDC).
cryptoAmount
number
required
Expected crypto amount to deliver.
cryptoAmountReceived
number
Actual crypto amount delivered on-chain (may differ from cryptoAmount due to gas).
fiatAmount
number
required
Base fiat amount collected (before fee).
fiatFee
number
required
Platform fee on the fiat side.
fiatAmountToSend
number
required
Total fiat the customer paid (fiatAmount + fiatFee).
receiverAddress
string
On-chain address the crypto was delivered to.
transactionHash
string
Blockchain transaction hash once on-chain delivery completes.
rate
object
Rate used for the conversion (from, to, cryptoAmount).
error
object
Error details if the onramp failed. Contains message, code, and details.

Example — successful onramp

{
  "event": "transaction.onramp.status.updated",
  "data": {
    "referenceId": "onramp-001",
    "status": "SUCCESSFUL",
    "depositStatus": "SUCCESSFUL",
    "onchainStatus": "SUCCESSFUL",
    "chain": "POLYGON",
    "token": "USDT",
    "cryptoAmount": 38.5,
    "cryptoAmountReceived": 38.5,
    "fiatAmount": 5000,
    "fiatFee": 100,
    "fiatAmountToSend": 5100,
    "receiverAddress": "0xrecipient123...",
    "transactionHash": "0xabc123def456...",
    "rate": { "from": "KES", "to": "USDT", "cryptoAmount": 38.5 }
  },
  "signature": "sha256=a1b2c3d4e5f6..."
}

Example — fiat collected, crypto transfer failed

{
  "event": "transaction.onramp.status.updated",
  "data": {
    "referenceId": "onramp-002",
    "status": "FAILED",
    "depositStatus": "SUCCESSFUL",
    "onchainStatus": "FAILED",
    "chain": "POLYGON",
    "token": "USDT",
    "cryptoAmount": 38.5,
    "fiatAmount": 5000,
    "fiatFee": 100,
    "fiatAmountToSend": 5100,
    "transactionHash": null,
    "error": {
      "message": "Crypto transfer failed after retries",
      "code": "CRYPTO_TRANSFER_FAILED",
      "details": {}
    }
  },
  "signature": "sha256=a1b2c3d4e5f6..."
}

transaction.offramp.status.updated

Fired when a crypto→fiat offramp changes status.

Payload fields

referenceId
string
required
Your reference ID for the offramp transaction.
status
string
required
Overall offramp status (SUCCESSFUL, FAILED, PENDING, etc.).
onchainStatus
string
required
Status of the on-chain crypto receipt leg.
fiatAmount
number
required
Full fiat amount before fees.
fiatTransactionAmount
number
required
Amount actually disbursed to the recipient after fees.
cryptoAmount
number
required
Crypto amount received from the sender.
fiatCurrency
string
required
Fiat currency code (e.g. KES, GHS).
customerKey
string
required
Customer identifier.
senderAddress
string
required
On-chain address that sent the crypto.
escrowAddress
string
required
Kotani escrow address the crypto was sent to.
fiatWalletId
string
ID of the integrator fiat wallet used, if applicable.
transactionHash
string
On-chain transaction hash of the crypto receipt.
transactionHashAmount
number
Exact on-chain amount confirmed (may differ from cryptoAmount due to network fees).
rate
object
Rate used for the conversion (from, to, fiatAmount).
usingIntegratedWallet
boolean
Whether the platform’s own integrated crypto wallet was used.
created_at
string
ISO 8601 creation timestamp.
updated_at
string
ISO 8601 last-updated timestamp.
onchainError
object
On-chain error details if the crypto receipt failed.
transactionError
object|string
Fiat disbursement error details.

Example — successful offramp

{
  "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"
  },
  "signature": "sha256=a1b2c3d4e5f6..."
}

Example — fiat disbursement failed

{
  "event": "transaction.offramp.status.updated",
  "data": {
    "referenceId": "offramp-002",
    "status": "FAILED",
    "onchainStatus": "SUCCESSFUL",
    "fiatAmount": 5000,
    "fiatTransactionAmount": 0,
    "cryptoAmount": 38.5,
    "fiatCurrency": "KES",
    "customerKey": "cust_abc123",
    "senderAddress": "0xabc123...",
    "escrowAddress": "0xescrow123...",
    "transactionHash": "0xdef456...",
    "onchainError": {},
    "transactionError": "Recipient mobile number is not registered"
  },
  "signature": "sha256=a1b2c3d4e5f6..."
}

refund.completed

Fired when a crypto refund has been successfully sent back to the sender.

Payload fields

referenceId
string
required
Reference ID of the original offramp transaction.
status
string
required
Always REVERSED.
refundStatus
string
required
Always SUCCESSFUL.
refundTransactionHash
string
required
On-chain transaction hash of the refund.
refundAmount
number
required
Amount refunded (in token native units — sats for Lightning, token units for EVM/Solana).
chain
string
required
Chain the refund was sent on.
token
string
required
Token refunded.
currency
string
required
Fiat currency of the original transaction.
timestamp
string
required
ISO 8601 timestamp of the refund.

Example

{
  "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 — contact support with the referenceId.

Payload fields

referenceId
string
required
Reference ID of the original offramp transaction.
refundStatus
string
required
Always FAILED.
refundAmount
number
required
Amount that was attempted for refund.
chain
string
required
Chain the refund was attempted on.
token
string
required
Token that was being refunded.
currency
string
required
Fiat currency of the original transaction.
error
string
required
Error message from the last refund attempt.
totalRetries
number
required
Number of refund attempts made before giving up.
timestamp
string
required
ISO 8601 timestamp of the final failure.

Example

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

refund.lightning.invoice_needed

Fired when a Lightning offramp’s fiat disbursement fails and Kotani needs a bolt11 invoice to return the funds. You must submit a valid invoice before the refund can proceed. See Offramp Refunds for the full Lightning refund lifecycle.

Payload fields

referenceId
string
required
Reference ID of the original offramp transaction.
status
string
required
Status of the offramp (typically FAILED).
onchainStatus
string
required
On-chain crypto receipt status (typically SUCCESSFUL — crypto was received).
refundStatus
string
required
Always INVOICE_NEEDED when this event fires.
refundAmount
number
required
Amount to be refunded in millisatoshis.
refundAmountSats
number
required
Amount to be refunded in satoshis.
chain
string
required
Always LIGHTNING.
currency
string
required
Fiat currency of the original transaction.
requiresAction
boolean
required
Always true — you must submit an invoice.
action
object
required
Instructions for submitting the invoice. Contains type, description, submitUrl, method, body, and invoiceRequirements.

Example

{
  "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.io/api/v3/offramp/submit-refund-invoice/offramp-lightning-001",
      "method": "POST",
      "body": { "invoice": "lnbc..." }
    }
  },
  "signature": "sha256=a1b2c3d4e5f6..."
}
Submit the invoice to the action.submitUrl:
curl -X POST https://api.kotanipay.io/api/v3/offramp/submit-refund-invoice/offramp-lightning-001 \
  -H "Authorization: Bearer <your-api-key>" \
  -H "Content-Type: application/json" \
  -d '{"invoice": "lnbc1500n1p0..."}'

Settlement events

Settlement events are opt-in — subscribe to them in Settings → Webhooks. All single-settlement events (settlement.approved, settlement.processed, settlement.rejected, settlement.paused) share the same payload shape.

Single settlement payload fields

settlementId
string
required
Kotani internal settlement ID.
referenceId
string
required
Settlement reference ID.
status
string
required
New settlement status (APPROVED, PROCESSED, REJECTED, PAUSED).
amount
number
required
Gross settlement amount.
fee
number
required
Fee charged on this settlement.
feePercentage
number
required
Fee as a percentage of the gross amount.
netAmount
number
required
Amount disbursed after fee (amount - fee).
currency
string
required
Settlement currency (e.g. KES, GHS).
tentativeUsdAmount
number
Approximate USD value of the gross amount at time of settlement.
tentativeUsdFee
number
Approximate USD value of the fee.
tentativeUsdNetAmount
number
Approximate USD value of the net disbursement.
balanceSource
string
Which wallet balance was settled (e.g. DEPOSIT, WITHDRAWAL).
batchId
string
Batch ID if this settlement is part of a batch.
beneficiaryDetails
object
Destination bank/wallet details for the disbursement.
timestamp
string
required
ISO 8601 event timestamp.

Example — settlement.processed

{
  "event": "settlement.processed",
  "data": {
    "settlementId": "64a1b2c3d4e5f6a7b8c9d0f1",
    "referenceId": "SET-2025-001",
    "status": "PROCESSED",
    "amount": 50000,
    "fee": 750,
    "feePercentage": 1.5,
    "netAmount": 49250,
    "currency": "KES",
    "tentativeUsdAmount": 387.50,
    "tentativeUsdFee": 5.81,
    "tentativeUsdNetAmount": 381.69,
    "balanceSource": "DEPOSIT",
    "beneficiaryDetails": {
      "bankName": "Equity Bank",
      "accountNumber": "0123456789"
    },
    "timestamp": "2025-01-01T12:00:00.000Z"
  },
  "signature": "sha256=a1b2c3d4e5f6..."
}

Batch settlement payload fields

Batch events (settlement.batch.approved, settlement.batch.processed, settlement.batch.rejected, settlement.batch.cancelled) carry the same fields plus a settlements array.
batchId
string
required
Kotani internal batch ID.
batchReference
string
required
Human-readable batch reference.
status
string
required
New batch status.
totalTentativeUsdAmount
number
Total approximate USD value of all settlements in the batch.
settlements
array
required
Array of settlement summaries. Each entry contains _id, subReference, status, currency, netAmount, tentativeUsdNetAmount, referenceId, and channels.

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
  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.