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

# Webhooks

> HMAC-SHA512 signed event deliveries to your webhookUrl.

When something happens on Demat (an escrow is funded, a wallet is credited, a payout completes), we POST camelCase JSON to **your** `webhookUrl`.

## What you set up

1. Expose any public `POST` path you want. The path is yours. Example: `https://example.com/hooks`. It must be `https://` on a public host (not localhost, not an IP, not `http`).
2. Configure that full URL as your merchant `webhookUrl` in your Demat dashboard.
3. Verify the signature with the **same secret API key** you already use for REST (`sk_test_...` or `sk_live_...`). There is no separate webhook secret. Do not use `pk_` keys.

Customer create does not send a webhook. A simulated NUBAN credit uses the same `wallet.credited` event as a live inbound transfer. It does not lock a hold.

## Verify the signature

We sign the raw body with your secret key and send the hex digest as `x-demat-signature`:

```
HMAC-SHA512(sk_test_or_sk_live, raw_body)
```

Use the raw body bytes. Do not serialize a parsed object again. Compare with a constant time compare.

Every request is `Content-Type: application/json`. `x-demat-signature` is the HMAC. `x-demat-event` is optional and matches `body.type`.

Respond with **2xx** within a few seconds. We time out at 5 seconds and retry a failed delivery **3 times**, 15 seconds apart. Deduplicate on `body.id` (`evt_...`).

```js theme={"theme":{"light":"github-light","dark":"github-dark"}}
const crypto = require('crypto');

function verifyDematWebhook(secret, rawBody, signature) {
  const expected = crypto
    .createHmac('sha512', secret)
    .update(rawBody)
    .digest('hex');
  return (
    expected.length === signature.length &&
    crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(signature))
  );
}

app.post('/hooks', express.raw({ type: 'application/json' }), (req, res) => {
  const rawBody = req.body.toString('utf8');
  const signature = req.header('x-demat-signature');
  if (!signature || !verifyDematWebhook(process.env.DEMAT_SECRET_KEY, rawBody, signature)) {
    return res.status(401).end();
  }

  const event = JSON.parse(rawBody);
  res.status(200).json({ received: true });

  if (alreadySeen(event.id)) return;

  switch (event.type) {
    case 'escrow.funded':
      markOrderHeld(event.data);
      break;
    case 'wallet.credited':
      applyCredit(event.data, event.meta);
      break;
    default:
      break;
  }
});
```

## Body

Every delivery uses the same shape. REST responses are unchanged. This is webhooks only.

`id` is the event id (ignore a replay of the same `evt_...`). `type` is the event name. `createdAt` is when we created it. `data` is the resource snapshot (same fields as the matching GET). `meta` is event-only context and is always present (`{}` when there is nothing extra). Amounts are naira. Fields are camelCase.

`data` on escrow events matches `GET /escrows/{id}`. `data.amount` is the hold total. A release or refund puts this movement in `meta.movementAmount`.

```json theme={"theme":{"light":"github-light","dark":"github-dark"}}
{
  "id": "evt_01hxyz",
  "type": "escrow.created",
  "createdAt": "2026-09-18T12:00:00.000Z",
  "data": {},
  "meta": {}
}
```

## Events we send

These are the events POSTed to your `webhookUrl`. Customer create does **not** send one. Treat this list as the catalog. Do not infer extra events. Payload shapes are shown in the examples below.

| Event | Description |
| - | - |
| `payment.pending` | Checkout session created (or reused; only the first create sends this). |
| `payment.succeeded` | Checkout payment succeeded. We always book the money after this. |
| `wallet.credited` | Available increased: NUBAN inbound, `customerId` checkout, test simulate, or a checkout success that could not fund the hold (cancelled / already held). |
| `escrow.created` | Hold created. Status is still `awaiting_funding` unless `debitPayer` then immediately funded. |
| `escrow.funded` | Hold locked via checkout with `escrowId`, or `debitPayer: true`. |
| `escrow.partially_released` | Some held funds moved to the payee; `remainingAmount` > 0. |
| `escrow.released` | Remaining held hit zero and some funds went to the payee. Status `released`. |
| `escrow.refunded` | Held funds returned to a registered payer's available, or guest refund completed after `refund.processed`. |
| `escrow.cancelled` | Unfunded hold cancelled. |
| `dispute.opened` | Hold frozen. Escrow status is `disputed`. |
| `dispute.resolved` | Dispute settled and remaining held moved. |
| `withdrawal.created` | Payout accepted and available reserved. |
| `withdrawal.processing` | Transfer sent to the bank. |
| `withdrawal.completed` | Bank transfer succeeded. |
| `withdrawal.failed` | Bank transfer failed; available restored. |

A dispute resolve also emits the matching `escrow.released` / `escrow.partially_released` / `escrow.refunded` events from the money movement. Deduplicate on `id`.

`escrow.refunded` always means "this refund movement happened". Final escrow `data.status` may be `refunded`, `partially_released`, or `released` depending on what was already released.

## Example: `escrow.created`

```json theme={"theme":{"light":"github-light","dark":"github-dark"}}
{
  "id": "evt_01hxyz",
  "type": "escrow.created",
  "createdAt": "2026-09-18T12:00:00.000Z",
  "data": {
    "escrowId": "esc_01hxyz",
    "reference": "order_12345",
    "status": "awaiting_funding",
    "amount": 400,
    "currency": "NGN",
    "releasedAmount": 0,
    "refundedAmount": 0,
    "remainingAmount": 400,
    "feeAmount": 0,
    "parties": [
      { "customerId": "cus_payer", "role": "payer" },
      { "customerId": "cus_payee", "role": "payee" }
    ],
    "description": "iPhone 13",
    "cancelledReason": null,
    "fundedAt": null,
    "releasedAt": null,
    "refundedAt": null
  },
  "meta": {
    "debitPayer": false
  }
}
```

## Example: `escrow.funded`

```json theme={"theme":{"light":"github-light","dark":"github-dark"}}
{
  "id": "evt_01hxyz",
  "type": "escrow.funded",
  "createdAt": "2026-09-18T12:05:00.000Z",
  "data": {
    "escrowId": "esc_01hxyz",
    "reference": "order_12345",
    "status": "held",
    "amount": 400,
    "currency": "NGN",
    "releasedAmount": 0,
    "refundedAmount": 0,
    "remainingAmount": 400,
    "feeAmount": 7.5,
    "merchantFee": 0,
    "parties": [
      { "customerId": "cus_payer", "role": "payer" },
      { "customerId": "cus_payee", "role": "payee" }
    ],
    "description": "iPhone 13",
    "cancelledReason": null,
    "fundedAt": "2026-09-18T12:05:00.000Z",
    "releasedAt": null,
    "refundedAt": null
  },
  "meta": {
    "source": "checkout",
    "paymentId": "pay_01hxyz",
    "providerReference": "T427271382724143",
    "chargedAmount": 407.5,
    "netAmount": 400,
    "fees": {
      "providerFee": 6,
      "platformFee": 1.5,
      "merchantFee": 0,
      "totalFees": 7.5
    }
  }
}
```

## Example: `payment.succeeded`

```json theme={"theme":{"light":"github-light","dark":"github-dark"}}
{
  "id": "evt_01hxyz",
  "type": "payment.succeeded",
  "createdAt": "2026-09-24T13:29:00.000Z",
  "data": {
    "paymentId": "pay_01hxyz",
    "checkoutId": "chk_01hxyz",
    "status": "succeeded",
    "amount": 400,
    "chargedAmount": 407.5,
    "netAmount": 400,
    "currency": "NGN",
    "escrowId": "esc_01hxyz",
    "walletId": null,
    "provider": "paystack",
    "paidAt": "2026-09-24T13:29:00.000Z",
    "fees": {
      "providerFee": 6,
      "platformFee": 1.5,
      "merchantFee": 0,
      "totalFees": 7.5
    }
  },
  "meta": {}
}
```

`chargedAmount` is the total the payer paid (what we sent to the provider). `netAmount` / `amount` is what funded the hold or wallet after provider, Demat platform, and optional merchant fees.

## Example: `escrow.released`

```json theme={"theme":{"light":"github-light","dark":"github-dark"}}
{
  "id": "evt_01hxyz",
  "type": "escrow.released",
  "createdAt": "2026-09-18T13:00:00.000Z",
  "data": {
    "escrowId": "esc_01hxyz",
    "status": "released",
    "amount": 400,
    "releasedAmount": 400,
    "refundedAmount": 0,
    "remainingAmount": 0,
    "feeAmount": 0
  },
  "meta": {
    "movementAmount": 400
  }
}
```

## Example: `dispute.opened`

```json theme={"theme":{"light":"github-light","dark":"github-dark"}}
{
  "id": "evt_01hxyz",
  "type": "dispute.opened",
  "createdAt": "2026-09-18T12:30:00.000Z",
  "data": {
    "disputeId": "dsp_01hxyz",
    "escrowId": "esc_01hxyz",
    "status": "open",
    "openedBy": "payer",
    "reasonCode": "ITEM_NOT_RECEIVED",
    "description": "Package not delivered",
    "resolution": null,
    "releasedAmount": 0,
    "refundedAmount": 0
  },
  "meta": {
    "escrow": {
      "escrowId": "esc_01hxyz",
      "status": "disputed",
      "remainingAmount": 400
    }
  }
}
```

## Example: `wallet.credited`

```json theme={"theme":{"light":"github-light","dark":"github-dark"}}
{
  "id": "evt_01hxyz",
  "type": "wallet.credited",
  "createdAt": "2026-09-18T12:00:00.000Z",
  "data": {
    "walletId": "wal_01hxyz",
    "customerId": "cus_01hxyz",
    "balances": { "available": 495, "held": 0 },
    "account": {
      "accountNumber": "0123456789",
      "accountName": "Jane Doe",
      "bankName": "Wema Bank",
      "bankCode": "035",
      "currency": "NGN"
    }
  },
  "meta": {
    "amount": 495,
    "currency": "NGN",
    "source": "nuban",
    "channel": "dedicated_nuban",
    "accountNumber": "0123456789",
    "grossAmount": 500,
    "providerFee": 5,
    "netAmount": 495
  }
}
```

For NUBAN inbound, `meta.amount` / `meta.netAmount` is what credited `available`. `grossAmount` is what the customer transferred; `providerFee` is the provider's deposit fee. See [Fees](/guides/fees).

Do not expose `sk_test_` or `sk_live_` in client apps.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.