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

# Webhooks & Events

> Receive real-time notifications about transaction status updates and other events.

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:

| Header                | Value                                                      |
| --------------------- | ---------------------------------------------------------- |
| `X-Kotani-Signature`  | `sha256=<hmac>` — use this to verify authenticity          |
| `X-Kotani-Event`      | The event name (e.g. `transaction.deposit.status.updated`) |
| `X-Kotani-Integrator` | Your integrator ID                                         |
| `Content-Type`        | `application/json`                                         |

The body is always wrapped in this envelope:

```json theme={null}
{
  "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 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

Configure a webhook secret in **Settings** to switch to signed webhooks.

***

## Supported Events

| Event                                   | When it fires                                                  |
| --------------------------------------- | -------------------------------------------------------------- |
| `transaction.deposit.status.updated`    | A deposit changes status                                       |
| `transaction.withdrawal.status.updated` | A withdrawal changes status                                    |
| `transaction.onramp.status.updated`     | An onramp (fiat→crypto) changes status                         |
| `transaction.offramp.status.updated`    | An offramp (crypto→fiat) changes status                        |
| `kyc.status.changed`                    | A customer's KYC verification outcome changes                  |
| `refund.completed`                      | Crypto refund successfully sent back to the sender             |
| `refund.failed`                         | Refund exhausted all retry attempts                            |
| `refund.lightning.invoice_needed`       | Lightning offramp needs a bolt11 invoice to process the refund |
| `settlement.approved`                   | A settlement request was approved                              |
| `settlement.processed`                  | A settlement was completed and funds disbursed                 |
| `settlement.rejected`                   | A settlement request was rejected                              |
| `settlement.paused`                     | A settlement was paused pending review                         |
| `settlement.batch.approved`             | A settlement batch was approved                                |
| `settlement.batch.processed`            | A settlement batch completed (fully or partially)              |
| `settlement.batch.rejected`             | A settlement batch was rejected                                |
| `settlement.batch.cancelled`            | A settlement batch was cancelled                               |
| `system.event`                          | Operational notices and maintenance alerts                     |
| `transaction.status.updated`            | *(Deprecated)* Use the specific events above                   |

<Note>
  Settlement events are opt-in. Subscribe to them in **Settings → Webhooks**.
</Note>

***

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

```typescript theme={null}
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;
  }
}
```

```typescript theme={null}
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

<Note>
  **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.
</Note>

***

### `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

<ParamField body="status" type="string" required>
  Current deposit status. See [Transaction Statuses](/v3/essentials/transaction-statuses).
</ParamField>

<ParamField body="reference_id" type="string" required>
  Your reference ID (or system-generated if not provided).
</ParamField>

<ParamField body="reference_number" type="number" required>
  Auto-generated sequential reference number.
</ParamField>

<ParamField body="id" type="string" required>
  Kotani internal record ID.
</ParamField>

<ParamField body="amount" type="number" required>
  Amount the customer was asked to pay.
</ParamField>

<ParamField body="wallet_id" type="string" required>
  ID of the integrator fiat wallet credited.
</ParamField>

<ParamField body="transaction_amount" type="number" required>
  The fee-adjusted amount for this deposit. On deposits the fee moves in the
  opposite direction to withdrawals, and whether it moves at all depends on your
  [Fees & Billing](/v3/essentials/billing-types) — read that page before
  reconciling against this field.
</ParamField>

<ParamField body="transaction_cost" type="number" required>
  Processing fee for this transaction. See
  [Fees & Billing](/v3/essentials/billing-types) for who pays it and whether it is
  already reflected in `transaction_amount`.
</ParamField>

<ParamField body="customer_key" type="string" required>
  The customer identifier supplied at deposit creation.
</ParamField>

<ParamField body="callback_url" type="string">
  Callback URL set on the transaction.
</ParamField>

<ParamField body="created_at" type="string">
  ISO 8601 creation timestamp.
</ParamField>

<ParamField body="telco_id" type="string">
  Mobile money receipt code from the network (e.g. Mpesa confirmation code like `OEI2AK4D9X`). Present on successful mobile money deposits.
</ParamField>

<ParamField body="confirmation_id" type="string">
  Provider-level confirmation reference. May differ from `telco_id` for some providers.
</ParamField>

<ParamField body="bank_name" type="string">
  Bank name for bank-based deposit methods (e.g. `Capitec`, `FNB`). Only present for bank deposits.
</ParamField>

<ParamField body="bank_code" type="string">
  Bank code for bank-based deposit methods. Only present for bank deposits.
</ParamField>

<ParamField body="payment_brand" type="string">
  Card payment brand for card deposits (e.g. `VISA`, `MASTERCARD`). Only present for card deposits.
</ParamField>

<ParamField body="error_message" type="string">
  Human-readable failure reason. Always present (may be empty string) for non-successful statuses.
</ParamField>

<ParamField body="error_description" type="string">
  Detailed provider error description. Always present (may be empty string) for non-successful statuses.
</ParamField>

<ParamField body="error_code" type="string">
  Provider error code. Always present (may be empty string) for non-successful statuses.
</ParamField>

<ParamField body="transactionError" type="string">
  Raw internal error from the processing pipeline. Always present (may be empty string) for non-successful statuses.
</ParamField>

<Warning>
  The examples below assume the fee is paid by your customer, so it's included in
  `transaction_amount`. If you pay the fee instead, the same request produces
  different numbers — see [Fees & Billing](/v3/essentials/billing-types). Don't
  hard-code the relationship between these fields.
</Warning>

#### Example — successful mobile money deposit

```json theme={null}
{
  "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": 1025,
    "transaction_cost": 25,
    "customer_key": "cust_abc123",
    "telco_id": "OEI2AK4D9X"
  },
  "signature": "sha256=a1b2c3d4e5f6..."
}
```

#### Example — failed deposit

```json theme={null}
{
  "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)

```json theme={null}
{
  "event": "transaction.deposit.status.updated",
  "data": {
    "status": "SUCCESSFUL",
    "reference_id": "order-bank-001",
    "amount": 2000,
    "transaction_amount": 2040,
    "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

<ParamField body="status" type="string" required>
  Current withdrawal status.
</ParamField>

<ParamField body="referenceId" type="string" required>
  Your reference ID.
</ParamField>

<ParamField body="referenceNumber" type="number" required>
  Auto-generated sequential reference number.
</ParamField>

<ParamField body="id" type="string" required>
  Kotani internal record ID.
</ParamField>

<ParamField body="amount" type="number" required>
  The amount you requested in the withdrawal call.
</ParamField>

<ParamField body="walletId" type="string" required>
  ID of the integrator fiat wallet debited.
</ParamField>

<ParamField body="transactionAmount" type="number" required>
  **What the recipient is paid.** This is the amount sent to their mobile money
  wallet or bank account — not what leaves your wallet. Whether it equals
  `amount` or `amount` minus the fee depends on your
  [Fees & Billing](/v3/essentials/billing-types).
</ParamField>

<ParamField body="transactionCost" type="number" required>
  Processing fee for this transaction. Whether it is deducted from
  `transactionAmount`, added to your wallet debit, or invoiced separately depends
  on your [Fees & Billing](/v3/essentials/billing-types).
</ParamField>

<ParamField body="customerKey" type="string" required>
  The customer identifier supplied at withdrawal creation.
</ParamField>

<ParamField body="callbackUrl" type="string">
  Callback URL set on the transaction.
</ParamField>

<ParamField body="created_at" type="string">
  ISO 8601 creation timestamp.
</ParamField>

<ParamField body="telcoId" type="string">
  Mobile money receipt code from the network. Present on successful mobile money payouts.
</ParamField>

<ParamField body="confirmationId" type="string">
  Provider-level confirmation reference.
</ParamField>

<ParamField body="integratorFeeAmount" type="number">
  Additional integrator fee charged on the transaction, if configured.
</ParamField>

<ParamField body="errorMessage" type="string">
  Human-readable failure reason. Always present (may be empty string) for non-successful statuses.
</ParamField>

<ParamField body="transactionError" type="string">
  Raw error from the processing pipeline. Always present (may be empty string) for non-successful statuses.
</ParamField>

<Warning>
  As with deposits, these examples assume the fee is paid by your customer, so the
  recipient is paid `amount` minus the fee. If you pay the fee from your wallet, the
  recipient gets the full `amount` and the fee is added to your wallet debit instead
  — see [Fees & Billing](/v3/essentials/billing-types).
</Warning>

#### Example — successful withdrawal

```json theme={null}
{
  "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": 480,
    "transactionCost": 20,
    "customerKey": "cust_abc123",
    "telcoId": "OEI2AK4D9Y"
  },
  "signature": "sha256=a1b2c3d4e5f6..."
}
```

#### Example — failed withdrawal

```json theme={null}
{
  "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

<ParamField body="referenceId" type="string" required>
  Your reference ID for the onramp transaction.
</ParamField>

<ParamField body="status" type="string" required>
  Combined overall status of the onramp.
</ParamField>

<ParamField body="depositStatus" type="string" required>
  Status of the fiat payment collection leg.
</ParamField>

<ParamField body="onchainStatus" type="string" required>
  Status of the on-chain crypto delivery leg.
</ParamField>

<ParamField body="chain" type="string" required>
  Blockchain the crypto was sent on (e.g. `POLYGON`, `STELLAR`).
</ParamField>

<ParamField body="token" type="string" required>
  Token delivered (e.g. `USDT`, `USDC`).
</ParamField>

<ParamField body="cryptoAmount" type="number" required>
  Expected crypto amount to deliver.
</ParamField>

<ParamField body="cryptoAmountReceived" type="number">
  Actual crypto amount delivered on-chain (may differ from `cryptoAmount` due to gas).
</ParamField>

<ParamField body="fiatAmount" type="number" required>
  Base fiat amount collected (before fee).
</ParamField>

<ParamField body="fiatFee" type="number" required>
  Platform fee on the fiat side.
</ParamField>

<ParamField body="fiatAmountToSend" type="number" required>
  Total fiat the customer paid (`fiatAmount + fiatFee`).
</ParamField>

<ParamField body="receiverAddress" type="string">
  On-chain address the crypto was delivered to.
</ParamField>

<ParamField body="transactionHash" type="string">
  Blockchain transaction hash once on-chain delivery completes.
</ParamField>

<ParamField body="rate" type="object">
  Rate used for the conversion (`from`, `to`, `cryptoAmount`).
</ParamField>

<ParamField body="error" type="object">
  Error details if the onramp failed. Contains `message`, `code`, and `details`.
</ParamField>

#### Example — successful onramp

```json theme={null}
{
  "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

```json theme={null}
{
  "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

<ParamField body="referenceId" type="string" required>
  Your reference ID for the offramp transaction.
</ParamField>

<ParamField body="status" type="string" required>
  Overall offramp status (`SUCCESSFUL`, `FAILED`, `PENDING`, etc.).
</ParamField>

<ParamField body="onchainStatus" type="string" required>
  Status of the on-chain crypto receipt leg.
</ParamField>

<ParamField body="fiatAmount" type="number" required>
  Full fiat amount before fees.
</ParamField>

<ParamField body="fiatTransactionAmount" type="number" required>
  Amount actually disbursed to the recipient after fees.
</ParamField>

<ParamField body="cryptoAmount" type="number" required>
  Crypto amount received from the sender.
</ParamField>

<ParamField body="fiatCurrency" type="string" required>
  Fiat currency code (e.g. `KES`, `GHS`).
</ParamField>

<ParamField body="customerKey" type="string" required>
  Customer identifier.
</ParamField>

<ParamField body="senderAddress" type="string" required>
  On-chain address that sent the crypto.
</ParamField>

<ParamField body="escrowAddress" type="string" required>
  Kotani escrow address the crypto was sent to.
</ParamField>

<ParamField body="fiatWalletId" type="string">
  ID of the integrator fiat wallet used, if applicable.
</ParamField>

<ParamField body="transactionHash" type="string">
  On-chain transaction hash of the crypto receipt.
</ParamField>

<ParamField body="transactionHashAmount" type="number">
  Exact on-chain amount confirmed (may differ from `cryptoAmount` due to network fees).
</ParamField>

<ParamField body="rate" type="object">
  Rate used for the conversion (`from`, `to`, `fiatAmount`).
</ParamField>

<ParamField body="usingIntegratedWallet" type="boolean">
  Whether the platform's own integrated crypto wallet was used.
</ParamField>

<ParamField body="created_at" type="string">
  ISO 8601 creation timestamp.
</ParamField>

<ParamField body="updated_at" type="string">
  ISO 8601 last-updated timestamp.
</ParamField>

<ParamField body="onchainError" type="object">
  On-chain error details if the crypto receipt failed. Present (may be `{}`) for non-successful transactions.
</ParamField>

<ParamField body="transactionError" type="object|string">
  Fiat disbursement error details. Present (may be `{}`) for non-successful transactions.
</ParamField>

#### Example — successful offramp

```json theme={null}
{
  "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

```json theme={null}
{
  "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

<ParamField body="referenceId" type="string" required>
  Reference ID of the original offramp transaction.
</ParamField>

<ParamField body="status" type="string" required>
  Always `REVERSED`.
</ParamField>

<ParamField body="refundStatus" type="string" required>
  Always `SUCCESSFUL`.
</ParamField>

<ParamField body="refundTransactionHash" type="string" required>
  On-chain transaction hash of the refund.
</ParamField>

<ParamField body="refundAmount" type="number" required>
  Amount refunded (in the token's native units — sats for Lightning, token units for EVM/Solana).
</ParamField>

<ParamField body="chain" type="string" required>
  Chain the refund was sent on.
</ParamField>

<ParamField body="token" type="string" required>
  Token refunded.
</ParamField>

<ParamField body="currency" type="string" required>
  Fiat currency of the original transaction.
</ParamField>

<ParamField body="timestamp" type="string" required>
  ISO 8601 timestamp of the refund.
</ParamField>

#### Example

```json theme={null}
{
  "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

<ParamField body="referenceId" type="string" required>
  Reference ID of the original offramp transaction.
</ParamField>

<ParamField body="refundStatus" type="string" required>
  Always `FAILED`.
</ParamField>

<ParamField body="refundAmount" type="number" required>
  Amount that was attempted for refund.
</ParamField>

<ParamField body="chain" type="string" required>
  Chain the refund was attempted on.
</ParamField>

<ParamField body="token" type="string" required>
  Token that was being refunded.
</ParamField>

<ParamField body="currency" type="string" required>
  Fiat currency of the original transaction.
</ParamField>

<ParamField body="error" type="string" required>
  Error message from the last refund attempt.
</ParamField>

<ParamField body="totalRetries" type="number" required>
  Number of refund attempts made before giving up.
</ParamField>

<ParamField body="timestamp" type="string" required>
  ISO 8601 timestamp of the final failure.
</ParamField>

#### Example

```json theme={null}
{
  "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 fiat disbursement fails and Kotani needs a bolt11 invoice to return the funds. You must submit a valid invoice before the refund can proceed.

#### Payload fields

<ParamField body="referenceId" type="string" required>
  Reference ID of the original offramp transaction.
</ParamField>

<ParamField body="status" type="string" required>
  Status of the offramp (typically `FAILED`).
</ParamField>

<ParamField body="onchainStatus" type="string" required>
  On-chain crypto receipt status (typically `SUCCESSFUL` — crypto was received).
</ParamField>

<ParamField body="refundStatus" type="string" required>
  Always `INVOICE_NEEDED` when this event fires.
</ParamField>

<ParamField body="refundAmount" type="number" required>
  Amount to be refunded in millisatoshis.
</ParamField>

<ParamField body="refundAmountSats" type="number" required>
  Amount to be refunded in satoshis.
</ParamField>

<ParamField body="chain" type="string" required>
  Always `LIGHTNING`.
</ParamField>

<ParamField body="currency" type="string" required>
  Fiat currency of the original transaction.
</ParamField>

<ParamField body="requiresAction" type="boolean" required>
  Always `true` — you must submit an invoice.
</ParamField>

<ParamField body="action" type="object" required>
  Instructions for submitting the invoice. Contains `type`, `description`, `submitUrl`, `method`, `body`, and `invoiceRequirements`.
</ParamField>

#### Example

```json theme={null}
{
  "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",
      "body": { "invoice": "lnbc..." }
    }
  },
  "signature": "sha256=a1b2c3d4e5f6..."
}
```

Submit the invoice to the `action.submitUrl`:

```bash theme={null}
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](/v3/essentials/offramp-refunds) for the full refund lifecycle.

***

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

<ParamField body="settlementId" type="string" required>
  Kotani internal settlement ID.
</ParamField>

<ParamField body="referenceId" type="string" required>
  Settlement reference ID.
</ParamField>

<ParamField body="status" type="string" required>
  New settlement status (`APPROVED`, `PROCESSED`, `REJECTED`, `PAUSED`).
</ParamField>

<ParamField body="amount" type="number" required>
  Gross settlement amount.
</ParamField>

<ParamField body="fee" type="number" required>
  Fee charged on this settlement.
</ParamField>

<ParamField body="feePercentage" type="number" required>
  Fee as a percentage of the gross amount.
</ParamField>

<ParamField body="netAmount" type="number" required>
  Amount disbursed after fee (`amount - fee`).
</ParamField>

<ParamField body="currency" type="string" required>
  Settlement currency (e.g. `KES`, `GHS`).
</ParamField>

<ParamField body="tentativeUsdAmount" type="number">
  Approximate USD value of the gross amount at time of settlement.
</ParamField>

<ParamField body="tentativeUsdFee" type="number">
  Approximate USD value of the fee.
</ParamField>

<ParamField body="tentativeUsdNetAmount" type="number">
  Approximate USD value of the net disbursement.
</ParamField>

<ParamField body="balanceSource" type="string">
  Which wallet balance was settled (e.g. `DEPOSIT`, `WITHDRAWAL`).
</ParamField>

<ParamField body="batchId" type="string">
  Batch ID if this settlement is part of a batch.
</ParamField>

<ParamField body="subReference" type="string">
  Sub-reference within a batch, if applicable.
</ParamField>

<ParamField body="beneficiaryDetails" type="object">
  Destination bank/wallet details for the disbursement.
</ParamField>

<ParamField body="timestamp" type="string" required>
  ISO 8601 event timestamp.
</ParamField>

#### Example — `settlement.processed`

```json theme={null}
{
  "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.

<ParamField body="batchId" type="string" required>
  Kotani internal batch ID.
</ParamField>

<ParamField body="batchReference" type="string" required>
  Human-readable batch reference.
</ParamField>

<ParamField body="status" type="string" required>
  New batch status.
</ParamField>

<ParamField body="totalTentativeUsdAmount" type="number">
  Total approximate USD value of all settlements in the batch.
</ParamField>

<ParamField body="settlements" type="array" required>
  Array of settlement summaries in the batch. Each entry contains `_id`, `subReference`, `status`, `currency`, `netAmount`, `tentativeUsdNetAmount`, `referenceId`, and `channels`.
</ParamField>

***

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

***

## Status Corrections & Delivery Order

Some status changes aren't final the moment they look final. Under certain circumstances (a system issue during the transaction, a delayed confirmation from the underlying payment rail, etc.), a transaction can settle into what looks like a terminal status — `CANCELLED`, `EXPIRED` — and then later be **corrected** to `SUCCESSFUL` once the payment actually clears. This is real, expected behavior on some payment rails, not a bug on either side.

Typical transition paths you may see for the same `reference_id`:

* `PENDING` → `SUCCESSFUL`, `FAILED`, or `CANCELLED`
* `CANCELLED` or `EXPIRED` → `SUCCESSFUL` (a later correction)

Kotani Pay **cannot guarantee webhooks arrive in the same order the underlying status changes happened.** Retries, exponential backoff, and upstream delays can all cause a later event to reach your endpoint before an earlier one for the same transaction — so it's possible to receive a `SUCCESSFUL` webhook after having already received (and processed) a `CANCELLED` one for the same `reference_id`.

<Warning>
  Don't treat the first terminal-looking status you receive as permanently final. Design your webhook handler to accept a later status update for a `reference_id` you've already processed, and prefer whichever status you received most recently rather than discarding it because an earlier webhook already looked terminal. If you need to confirm the authoritative current state at any point, poll the corresponding status endpoint (e.g. [GET /deposit/mobile-money/status/:reference\_id](/v3/api-reference/deposits/mobile-money-status)) rather than relying on webhook arrival order alone.
</Warning>
