> ## Documentation Index
> Fetch the complete documentation index at: https://docs.cngn.co/llms.txt
> Use this file to discover all available pages before exploring further.

# Webhooks

> Receive real-time notifications when deposits, redemptions, withdrawals, and bridges settle

Instead of polling [Get Transactions](/api-reference/get-transactions), let cNGN notify your server the moment a transaction changes state. Webhooks are how your systems learn that a customer's deposit landed, a redemption paid out, or a withdrawal failed.

<Note>
  Webhook URLs, event subscriptions, and the signing secret are managed in your **merchant dashboard**, not through the third-party API. Each environment has its own webhook URL: one for test and one for live.
</Note>

## Setting up

<Steps>
  <Step title="Add your webhook URL">
    In the dashboard security settings, set the HTTPS endpoint that should receive events. Configure the test URL first and verify your handler against test transactions before setting the live URL.
  </Step>

  <Step title="Set a signing secret">
    Add a signing secret in the dashboard. cNGN uses it to sign every delivery so your server can verify the event genuinely came from cNGN. Without a secret configured, deliveries arrive unsigned.
  </Step>

  <Step title="Subscribe to events">
    Choose which of the five event types (below) your endpoint should receive. Subscribe only to what you act on; you can change subscriptions at any time.
  </Step>

  <Step title="Verify and respond fast">
    Validate the signature, queue the event for processing, and return a `2xx` immediately. Do the heavy work asynchronously.
  </Step>
</Steps>

## Event types

| Event                  | Fires when                                                                                                 |
| ---------------------- | ---------------------------------------------------------------------------------------------------------- |
| `deposit.received`     | A fiat payment has landed and is awaiting mint approval. Funds are confirmed but cNGN is not yet credited. |
| `deposit.completed`    | A deposit (fiat or on-chain) has been fully processed and cNGN credited to your balance.                   |
| `redemption.completed` | A redemption has settled; Naira has been paid out to the bank account.                                     |
| `withdrawal.completed` | An on-chain withdrawal has been confirmed on the network.                                                  |
| `transaction.failed`   | Any transaction has failed or been rejected. The `reason` field explains why.                              |

<Tip>
  `deposit.received` followed by `deposit.completed` is the normal lifecycle of a fiat deposit. Credit your customer only on `deposit.completed`.
</Tip>

## Delivery payload

Every delivery is an HTTPS `POST` with the same JSON envelope:

```json theme={null}
{
  "event": "<event type>",
  "data": { "...": "transaction snapshot, fields vary by event" },
  "timestamp": "2026-07-22T14:32:12.104Z"
}
```

<ResponseField name="event" type="string">
  One of the five event types above.
</ResponseField>

<ResponseField name="data" type="object">
  The transaction snapshot at the moment of the state change. Common fields:

  <Expandable title="properties">
    <ResponseField name="transactionId" type="string">Unique transaction ID. Use it for idempotency.</ResponseField>
    <ResponseField name="trx_ref" type="string">Transaction reference, the same value shown in [Get Transactions](/api-reference/get-transactions).</ResponseField>
    <ResponseField name="businessId" type="string">Your business ID.</ResponseField>
    <ResponseField name="initiatorId" type="string">User that initiated the transaction.</ResponseField>
    <ResponseField name="status" type="string">`pending`, `completed`, `failed`, or `rejected`.</ResponseField>
    <ResponseField name="trx_type" type="string">Underlying type: `fiat_buy`, `crypto_deposit`, `fiat_redeem`, `enaira_redeem`, or `withdraw`.</ResponseField>
    <ResponseField name="network" type="string">Network the transaction executed on. Present when the event involves an on-chain step.</ResponseField>
    <ResponseField name="base_trx_hash" type="string | null">On-chain hash on the origin network, when applicable.</ResponseField>
    <ResponseField name="extl_trx_hash" type="string | null">On-chain hash on the destination network, when applicable.</ResponseField>
    <ResponseField name="explorer_link" type="string | null">Block explorer URL, when a hash exists.</ResponseField>
    <ResponseField name="amount" type="string">Transaction amount as a decimal string.</ResponseField>
    <ResponseField name="asset_symbol" type="string">`NGN` for fiat-side events, `CNGN` for on-chain events.</ResponseField>
    <ResponseField name="receiver" type="string">Recipient. Format varies by event; see the samples below.</ResponseField>
    <ResponseField name="reason" type="string">Failure reason. Present on `transaction.failed` when a cause is known.</ResponseField>
    <ResponseField name="occurredAt" type="string">ISO 8601 timestamp of the state change.</ResponseField>
  </Expandable>
</ResponseField>

<ResponseField name="timestamp" type="string">
  ISO 8601 time the webhook was dispatched.
</ResponseField>

## Sample payloads per event

<Tabs>
  <Tab title="deposit.received">
    Fires when a fiat payment lands in your virtual account and is awaiting mint approval. No on-chain step has happened yet, so there is no `network` or hash, the `asset_symbol` is `NGN`, and `receiver` is your business ID:

    ```json theme={null}
    {
      "event": "deposit.received",
      "data": {
        "transactionId": "9f8b7c6d-5e4f-4a3b-9c2d-1e0f9a8b7c6d",
        "trx_ref": "b3c1a2d4-8e9f-4a5b-9c0d-1e2f3a4b5c6d",
        "businessId": "4f1d0c2a-7b3e-4d5f-8a9b-0c1d2e3f4a5b",
        "initiatorId": "7e6d5c4b-3a2f-4e1d-9c8b-7a6f5e4d3c2b",
        "status": "pending",
        "trx_type": "fiat_buy",
        "amount": "50000",
        "asset_symbol": "NGN",
        "receiver": "4f1d0c2a-7b3e-4d5f-8a9b-0c1d2e3f4a5b",
        "occurredAt": "2026-07-22T14:32:11.000Z"
      },
      "timestamp": "2026-07-22T14:32:11.104Z"
    }
    ```

    <Warning>
      Funds are confirmed received but cNGN has **not** been credited yet. Do not credit your customer on this event; wait for `deposit.completed`.
    </Warning>
  </Tab>

  <Tab title="deposit.completed">
    Fires when the deposit is approved and cNGN is minted to your balance. The mint settles on the issuing network, so the payload now carries `network`, the mint hash, and an explorer link:

    ```json theme={null}
    {
      "event": "deposit.completed",
      "data": {
        "transactionId": "9f8b7c6d-5e4f-4a3b-9c2d-1e0f9a8b7c6d",
        "trx_ref": "b3c1a2d4-8e9f-4a5b-9c0d-1e2f3a4b5c6d",
        "businessId": "4f1d0c2a-7b3e-4d5f-8a9b-0c1d2e3f4a5b",
        "initiatorId": "7e6d5c4b-3a2f-4e1d-9c8b-7a6f5e4d3c2b",
        "status": "completed",
        "trx_type": "fiat_buy",
        "network": "XBN",
        "base_trx_hash": "e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f6",
        "explorer_link": "https://explorer.bantu.network/tx/e5f6a7b8...e5f6",
        "amount": "50000",
        "asset_symbol": "NGN",
        "receiver": "4f1d0c2a-7b3e-4d5f-8a9b-0c1d2e3f4a5b",
        "occurredAt": "2026-07-22T15:05:42.000Z"
      },
      "timestamp": "2026-07-22T15:05:42.310Z"
    }
    ```

    This is the event to credit your customer on. For fiat deposits, the pair (`deposit.received` then `deposit.completed`) shares the same `transactionId` and `trx_ref`.

    For **on-chain deposits** (cNGN sent to your wallet from an external address), the same event fires with `trx_type: crypto_deposit` and `asset_symbol: CNGN`. There is no preceding `deposit.received`. Deposits arriving via an EVM or Solana network also carry `extl_trx_hash`, the transaction hash on the source network:

    ```json theme={null}
    {
      "event": "deposit.completed",
      "data": {
        "transactionId": "5a6b7c8d-9e0f-4a1b-8c2d-3e4f5a6b7c8d",
        "trx_ref": "d4c3b2a1-0f9e-4d8c-b7a6-5f4e3d2c1b0a",
        "businessId": "4f1d0c2a-7b3e-4d5f-8a9b-0c1d2e3f4a5b",
        "initiatorId": "7e6d5c4b-3a2f-4e1d-9c8b-7a6f5e4d3c2b",
        "status": "completed",
        "trx_type": "crypto_deposit",
        "network": "base",
        "base_trx_hash": "f6e5d4c3b2a1f0e9d8c7b6a5f4e3d2c1b0a9f8e7d6c5b4a3f2e1d0c9b8a7f6e5",
        "extl_trx_hash": "0x1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2",
        "explorer_link": "https://basescan.org/tx/0x1b2c...a1b2",
        "amount": "75000",
        "asset_symbol": "CNGN",
        "receiver": "Wrap CBDC",
        "occurredAt": "2026-07-22T18:03:27.000Z"
      },
      "timestamp": "2026-07-22T18:03:27.245Z"
    }
    ```
  </Tab>

  <Tab title="redemption.completed">
    Fires when a redemption has settled and the Naira payout to the bank account has been confirmed. The `receiver` encodes the destination bank account as `bankCode:accountNumber:accountName`:

    ```json theme={null}
    {
      "event": "redemption.completed",
      "data": {
        "transactionId": "2b3c4d5e-6f7a-4b8c-9d0e-1f2a3b4c5d6e",
        "trx_ref": "a1b2c3d4-5e6f-4a7b-8c9d-0e1f2a3b4c5d",
        "businessId": "4f1d0c2a-7b3e-4d5f-8a9b-0c1d2e3f4a5b",
        "initiatorId": "7e6d5c4b-3a2f-4e1d-9c8b-7a6f5e4d3c2b",
        "status": "completed",
        "trx_type": "fiat_redeem",
        "network": "xbn",
        "amount": "100000",
        "asset_symbol": "NGN",
        "receiver": "058:0123456789:ADA OBI",
        "occurredAt": "2026-07-22T16:12:03.000Z"
      },
      "timestamp": "2026-07-22T16:12:03.221Z"
    }
    ```

    `trx_type` is `fiat_redeem` for bank redemptions and `enaira_redeem` for eNaira redemptions.
  </Tab>

  <Tab title="withdrawal.completed">
    Fires when an on-chain withdrawal is confirmed on the destination network. This is the richest payload: `base_trx_hash` is the burn on the issuing network, `extl_trx_hash` is the transfer on the destination network, and `receiver` is the destination wallet address:

    ```json theme={null}
    {
      "event": "withdrawal.completed",
      "data": {
        "transactionId": "c4d5e6f7-a8b9-4c0d-9e1f-2a3b4c5d6e7f",
        "trx_ref": "f0e1d2c3-b4a5-4968-8776-5a4b3c2d1e0f",
        "businessId": "4f1d0c2a-7b3e-4d5f-8a9b-0c1d2e3f4a5b",
        "initiatorId": "7e6d5c4b-3a2f-4e1d-9c8b-7a6f5e4d3c2b",
        "status": "completed",
        "trx_type": "withdraw",
        "network": "BASE",
        "base_trx_hash": "d0c1b2a3f4e5d6c7b8a9f0e1d2c3b4a5f6e7d8c9b0a1f2e3d4c5b6a7f8e9d0c1",
        "extl_trx_hash": "0x4a5b6c7d8e9f0a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b",
        "explorer_link": "https://basescan.org/tx/0x4a5b...4a5b",
        "amount": "25000",
        "asset_symbol": "CNGN",
        "receiver": "0x8Ba1f109551bD432803012645Ac136ddd64DBA72",
        "occurredAt": "2026-07-22T17:44:19.000Z"
      },
      "timestamp": "2026-07-22T17:44:19.402Z"
    }
    ```
  </Tab>

  <Tab title="transaction.failed">
    Fires when any transaction fails or is rejected. The shape depends on where in the pipeline it stopped.

    A withdrawal that failed **before** the on-chain burn includes a `reason` and no hashes:

    ```json theme={null}
    {
      "event": "transaction.failed",
      "data": {
        "transactionId": "c4d5e6f7-a8b9-4c0d-9e1f-2a3b4c5d6e7f",
        "trx_ref": "f0e1d2c3-b4a5-4968-8776-5a4b3c2d1e0f",
        "businessId": "4f1d0c2a-7b3e-4d5f-8a9b-0c1d2e3f4a5b",
        "initiatorId": "7e6d5c4b-3a2f-4e1d-9c8b-7a6f5e4d3c2b",
        "status": "failed",
        "trx_type": "withdraw",
        "network": "base",
        "amount": "25000",
        "asset_symbol": "CNGN",
        "receiver": "0x8Ba1f109551bD432803012645Ac136ddd64DBA72",
        "reason": "Insufficient balance",
        "occurredAt": "2026-07-22T17:40:02.000Z"
      },
      "timestamp": "2026-07-22T17:40:02.118Z"
    }
    ```

    A withdrawal that failed **after** the burn carries the burn hash (funds are reverted automatically); `reason` may be absent:

    ```json theme={null}
    {
      "event": "transaction.failed",
      "data": {
        "transactionId": "c4d5e6f7-a8b9-4c0d-9e1f-2a3b4c5d6e7f",
        "trx_ref": "f0e1d2c3-b4a5-4968-8776-5a4b3c2d1e0f",
        "businessId": "4f1d0c2a-7b3e-4d5f-8a9b-0c1d2e3f4a5b",
        "initiatorId": "7e6d5c4b-3a2f-4e1d-9c8b-7a6f5e4d3c2b",
        "status": "failed",
        "trx_type": "withdraw",
        "network": "BASE",
        "base_trx_hash": "d0c1b2a3f4e5d6c7b8a9f0e1d2c3b4a5f6e7d8c9b0a1f2e3d4c5b6a7f8e9d0c1",
        "explorer_link": "https://basescan.org/tx/d0c1...d0c1",
        "amount": "25000",
        "asset_symbol": "CNGN",
        "receiver": "0x8Ba1f109551bD432803012645Ac136ddd64DBA72",
        "occurredAt": "2026-07-22T17:52:47.000Z"
      },
      "timestamp": "2026-07-22T17:52:47.093Z"
    }
    ```

    A redemption whose bank payout could not be processed arrives with the redemption's details and a provider failure reason:

    ```json theme={null}
    {
      "event": "transaction.failed",
      "data": {
        "transactionId": "2b3c4d5e-6f7a-4b8c-9d0e-1f2a3b4c5d6e",
        "trx_ref": "a1b2c3d4-5e6f-4a7b-8c9d-0e1f2a3b4c5d",
        "businessId": "4f1d0c2a-7b3e-4d5f-8a9b-0c1d2e3f4a5b",
        "initiatorId": "7e6d5c4b-3a2f-4e1d-9c8b-7a6f5e4d3c2b",
        "status": "failed",
        "trx_type": "fiat_redeem",
        "network": "xbn",
        "amount": "100000",
        "asset_symbol": "NGN",
        "receiver": "058:0123456789:ADA OBI",
        "reason": "Provider failed to process payout",
        "occurredAt": "2026-07-22T16:20:44.000Z"
      },
      "timestamp": "2026-07-22T16:20:44.156Z"
    }
    ```

    The redeemed cNGN is automatically reverted to your balance when a payout fails.

    A deposit declined during review arrives with `status: "rejected"` and the decline reason:

    ```json theme={null}
    {
      "event": "transaction.failed",
      "data": {
        "transactionId": "9f8b7c6d-5e4f-4a3b-9c2d-1e0f9a8b7c6d",
        "trx_ref": "b3c1a2d4-8e9f-4a5b-9c0d-1e2f3a4b5c6d",
        "businessId": "4f1d0c2a-7b3e-4d5f-8a9b-0c1d2e3f4a5b",
        "initiatorId": "7e6d5c4b-3a2f-4e1d-9c8b-7a6f5e4d3c2b",
        "status": "rejected",
        "trx_type": "fiat_buy",
        "amount": "50000",
        "asset_symbol": "NGN",
        "receiver": "4f1d0c2a-7b3e-4d5f-8a9b-0c1d2e3f4a5b",
        "reason": "Deposit could not be matched to a payment",
        "occurredAt": "2026-07-22T15:01:20.000Z"
      },
      "timestamp": "2026-07-22T15:01:20.077Z"
    }
    ```

    <Tip>
      Branch on `data.trx_type` to know what failed, and on `data.status` (`failed` vs `rejected`) to distinguish processing errors from review declines. Always check for `reason` defensively; it is present only when a cause is known.
    </Tip>
  </Tab>
</Tabs>

### Field presence by event

| Field                                                                                                                             | deposit.received | deposit.completed | redemption.completed | withdrawal.completed | transaction.failed |
| --------------------------------------------------------------------------------------------------------------------------------- | ---------------- | ----------------- | -------------------- | -------------------- | ------------------ |
| `transactionId`, `trx_ref`, `businessId`, `initiatorId`, `status`, `trx_type`, `amount`, `asset_symbol`, `receiver`, `occurredAt` | Yes              | Yes               | Yes                  | Yes                  | Yes                |
| `network`                                                                                                                         | No               | Yes               | Yes                  | Yes                  | Sometimes          |
| `base_trx_hash`                                                                                                                   | No               | Yes               | No                   | Yes                  | Sometimes          |
| `extl_trx_hash`                                                                                                                   | No               | On-chain deposits | No                   | Yes                  | No                 |
| `explorer_link`                                                                                                                   | No               | Yes               | No                   | Yes                  | Sometimes          |
| `reason`                                                                                                                          | No               | No                | No                   | No                   | When known         |

## Verifying the signature

When a signing secret is configured, every delivery carries this header:

```text theme={null}
X-cNGN-Signature: sha256=<hex digest>
```

The digest is an **HMAC-SHA256 of the raw JSON request body**, keyed with your signing secret. Recompute it and compare before trusting the event:

<CodeGroup>
  ```typescript TypeScript (Express) theme={null}
  import crypto from "crypto";
  import express from "express";

  const app = express();

  // Capture the raw body; the HMAC is computed over the exact bytes sent
  app.use(express.json({
    verify: (req: any, _res, buf) => { req.rawBody = buf.toString("utf8"); },
  }));

  app.post("/webhooks/cngn", (req: any, res) => {
    const received = req.header("X-cNGN-Signature") ?? "";
    const expected = "sha256=" + crypto
      .createHmac("sha256", process.env.CNGN_SIGNING_SECRET!)
      .update(req.rawBody)
      .digest("hex");

    const valid = received.length === expected.length &&
      crypto.timingSafeEqual(Buffer.from(received), Buffer.from(expected));

    if (!valid) return res.status(401).send("invalid signature");

    // Acknowledge immediately, process asynchronously
    res.status(200).send("ok");
    queue.add(req.body); // your job queue
  });
  ```

  ```python Python (Flask) theme={null}
  import hashlib, hmac, os
  from flask import Flask, request, abort

  app = Flask(__name__)

  @app.post("/webhooks/cngn")
  def cngn_webhook():
      received = request.headers.get("X-cNGN-Signature", "")
      expected = "sha256=" + hmac.new(
          os.environ["CNGN_SIGNING_SECRET"].encode(),
          request.get_data(),  # raw body bytes
          hashlib.sha256,
      ).hexdigest()

      if not hmac.compare_digest(received, expected):
          abort(401)

      event = request.get_json()
      enqueue(event)  # your job queue; acknowledge fast
      return "ok", 200
  ```

  ```php PHP theme={null}
  <?php
  $rawBody = file_get_contents('php://input');
  $received = $_SERVER['HTTP_X_CNGN_SIGNATURE'] ?? '';

  $expected = 'sha256=' . hash_hmac('sha256', $rawBody, getenv('CNGN_SIGNING_SECRET'));

  if (!hash_equals($expected, $received)) {
      http_response_code(401);
      exit('invalid signature');
  }

  $event = json_decode($rawBody, true);
  enqueue($event); // your job queue; acknowledge fast
  http_response_code(200);
  echo 'ok';
  ```
</CodeGroup>

## Delivery behaviour and best practices

Deliveries are sent once, with a 10-second timeout, and are **not automatically retried**. Design your handler accordingly:

<AccordionGroup>
  <Accordion title="Treat webhooks as notifications, not the source of truth" icon="circle-info">
    If your endpoint is down when an event fires, that delivery is missed. Reconcile periodically against [Get Transactions](/api-reference/get-transactions) or [Verify Withdrawal](/api-reference/verify-withdrawal) so a missed webhook never means missed money.
  </Accordion>

  <Accordion title="Respond with 2xx within seconds" icon="stopwatch">
    The dispatcher times out after 10 seconds. Persist the event to a queue and return `200` immediately; never do bank calls or blockchain lookups inline.
  </Accordion>

  <Accordion title="Handle duplicates idempotently" icon="fingerprint">
    Key your processing on `data.transactionId` plus `event`. If you've already processed that pair, acknowledge and skip.
  </Accordion>

  <Accordion title="Always verify the signature" icon="shield-check">
    Anyone who discovers your endpoint URL can POST fake events to it. Reject anything whose `X-cNGN-Signature` doesn't validate, and keep the signing secret out of source control.
  </Accordion>
</AccordionGroup>

## Bridge completion callback

Separately from event webhooks, [Bridge](/api-reference/bridge) accepts an optional per-request `callbackUrl`. When the bridged cNGN is minted on the destination network, cNGN POSTs a one-time notification to that URL:

```json theme={null}
{
  "explorerLink": "https://basescan.org/tx/0x4a5b...e9f0",
  "hash": "0x4a5b...e9f0",
  "transactionId": "c4d5e6f7-a8b9-4c0d-9e1f-2a3b4c5d6e7f",
  "status": "completed",
  "amount": "100000.00"
}
```

This callback is **not signed** and times out after 4.5 seconds, so treat it purely as a hint to check state; confirm via [Get Transactions](/api-reference/get-transactions) before crediting anything.
