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

# Webhooks and events

> Subscribe to BMONI events, verify the signature on every delivery, and stop polling for state that gets pushed to you.

This page shows you how to subscribe to events, verify that a delivery genuinely came from BMONI, and handle retries safely.

Most integrations poll because they never discovered these endpoints. Almost everything worth polling for is available as an event.

<Warning>
  Subscribe to the `employee.*` event names, not `wallet.*`. Money-movement events are renamed on their way to a partner-scoped subscription, so a subscription to `wallet.deposit.completed` receives nothing. See [The two event families](#the-two-event-families).
</Warning>

## Subscribe

```http theme={null}
POST /v1/webhooks/config
{
  "callbackUrl": "https://api.example.com/webhooks/bmoni",
  "events": [
    "employee.deposit.completed",
    "employee.withdrawal.completed",
    "onboarding.completed",
    "kyc.action_required"
  ],
  "partnerId": "b7e6a1d0-4f3c-4c2a-9e8b-1a2b3c4d5e6f",
  "active": true
}
```

| Field         |                                                                                                                                                                                          |
| ------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `callbackUrl` | required — the HTTPS endpoint that receives deliveries.                                                                                                                                  |
| `events`      | required — at least one event type from the tables below.                                                                                                                                |
| `partnerId`   | the partner this subscription is scoped to. **Supply it.** Omitting it creates the legacy global subscription, which receives raw `wallet.*` events for every user rather than your own. |
| `active`      | defaults to `true`. Set `false` to stop deliveries without deleting the subscription.                                                                                                    |

The response includes a `secretKey` — a 64-character hex string (32 random bytes), with no prefix:

```json theme={null}
{
  "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
  "partnerId": "b7e6a1d0-4f3c-4c2a-9e8b-1a2b3c4d5e6f",
  "callbackUrl": "https://api.example.com/webhooks/bmoni",
  "secretKey": "87f88be98b96faf6d6ece5b26bf4a9fe20739ae9634fb7b530a24aac4f71ed32",
  "active": true,
  "events": ["employee.deposit.completed"],
  "createdAt": "2026-08-07T12:00:00.000Z",
  "updatedAt": "2026-08-07T12:00:00.000Z"
}
```

<Warning>
  Store `secretKey` immediately, in your secret manager rather than your database. Every delivery is signed with it, and a delivery you cannot verify is a delivery you must not trust.
</Warning>

One subscription exists per partner scope. A second `POST` returns `409` — `Webhook config already exists for this partner scope. Use PATCH to update.` Use `PATCH /v1/webhooks/config` to change the URL, the event list, or `active`.

<Note>
  `PATCH` cannot change `partnerId`. Re-scoping a subscription across partners would redirect another partner's deliveries, so it is rejected by design.
</Note>

To rotate the secret, `POST /v1/webhooks/config/rotate-secret`. Deliveries signed with the old secret stop verifying the moment the new one is issued, so deploy the new secret before rotating.

## Verify every delivery

A delivery arrives as `POST` to your `callbackUrl`:

```json theme={null}
{
  "id": "stable-event-id",
  "eventType": "employee.deposit.completed",
  "payload": { "userId": "…", "amount": "1000.00" },
  "timestamp": "2026-08-07T12:00:00.000Z"
}
```

| Header                |                                                                                |
| --------------------- | ------------------------------------------------------------------------------ |
| `X-Webhook-Signature` | HMAC-SHA256 of the raw request body, keyed with your `secretKey`, hex-encoded. |
| `X-Webhook-Id`        | The same value as the body's `id`. Use it for deduplication.                   |
| `X-Source-Event-Id`   | The upstream event identifier. Empty for legacy rows.                          |

<Warning>
  Compute the HMAC over the **raw request body bytes**, exactly as received. Parsing the JSON and re-serialising it changes key order and whitespace, which produces a different digest and a signature that never matches. Capture the raw body before your framework parses it.
</Warning>

Compare digests in constant time — a plain `===` on the hex string leaks timing information that can be used to forge a signature one byte at a time.

<CodeGroup>
  ```javascript Express theme={null}
  import crypto from 'node:crypto'
  import express from 'express'

  const app = express()

  // express.raw, not express.json — the parsed body cannot reproduce the digest.
  app.post(
    '/webhooks/bmoni',
    express.raw({ type: 'application/json' }),
    (req, res) => {
      const expected = crypto
        .createHmac('sha256', process.env.BMONI_WEBHOOK_SECRET)
        .update(req.body) // the raw Buffer
        .digest('hex')

      const received = req.get('X-Webhook-Signature') ?? ''

      // Length check first: timingSafeEqual throws on a length mismatch.
      const ok =
        received.length === expected.length &&
        crypto.timingSafeEqual(Buffer.from(received), Buffer.from(expected))

      if (!ok) return res.sendStatus(401)

      const event = JSON.parse(req.body.toString('utf8'))

      // Acknowledge before doing the work — see the retry rules below.
      res.sendStatus(200)
      void handleEvent(event)
    },
  )
  ```

  ```python FastAPI theme={null}
  import hashlib
  import hmac
  import json
  import os

  from fastapi import FastAPI, Header, HTTPException, Request

  app = FastAPI()
  SECRET = os.environ["BMONI_WEBHOOK_SECRET"].encode()


  @app.post("/webhooks/bmoni")
  async def bmoni_webhook(
      request: Request,
      x_webhook_signature: str = Header(default=""),
  ):
      raw = await request.body()  # raw bytes, before any parsing

      expected = hmac.new(SECRET, raw, hashlib.sha256).hexdigest()
      if not hmac.compare_digest(x_webhook_signature, expected):
          raise HTTPException(status_code=401, detail="Invalid signature")

      event = json.loads(raw)
      # Return 200 promptly, then process out of band.
      enqueue(event)
      return {"received": True}
  ```

  ```javascript Node (no framework) theme={null}
  import crypto from 'node:crypto'

  // rawBody must be the exact Buffer/string received on the wire —
  // re-serializing a parsed object produces a different digest.
  function isValidSignature(rawBody, signatureHeader, secret) {
    const expected = crypto
      .createHmac('sha256', secret)
      .update(rawBody)
      .digest('hex')

    const received = signatureHeader ?? ''

    // Length check first: timingSafeEqual throws on a length mismatch.
    return (
      received.length === expected.length &&
      crypto.timingSafeEqual(Buffer.from(received), Buffer.from(expected))
    )
  }
  ```

  ```java Java theme={null}
  import javax.crypto.Mac;
  import javax.crypto.spec.SecretKeySpec;
  import java.nio.charset.StandardCharsets;
  import java.security.MessageDigest;

  public class WebhookVerifier {
    // rawBody must be the exact bytes received — read it before any JSON binding.
    public static boolean isValidSignature(byte[] rawBody, String signatureHeader, String secret)
        throws Exception {
      Mac mac = Mac.getInstance("HmacSHA256");
      mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
      byte[] hash = mac.doFinal(rawBody);

      StringBuilder hex = new StringBuilder();
      for (byte b : hash) hex.append(String.format("%02x", b));

      // MessageDigest.isEqual is constant-time.
      return MessageDigest.isEqual(
          hex.toString().getBytes(StandardCharsets.UTF_8),
          signatureHeader.getBytes(StandardCharsets.UTF_8));
    }
  }
  ```

  ```go Go theme={null}
  package main

  import (
  	"crypto/hmac"
  	"crypto/sha256"
  	"encoding/hex"
  )

  // rawBody must be the exact bytes received — read it before decoding JSON.
  func isValidSignature(rawBody []byte, signatureHeader string, secret string) bool {
  	mac := hmac.New(sha256.New, []byte(secret))
  	mac.Write(rawBody)
  	expected := hex.EncodeToString(mac.Sum(nil))

  	// hmac.Equal is constant-time.
  	return hmac.Equal([]byte(signatureHeader), []byte(expected))
  }
  ```
</CodeGroup>

## Retries, and what your status code means

Your response code decides whether a failed delivery is ever retried. The delivery times out after **10 seconds**.

| Your response                          | Retried?                                                  |
| -------------------------------------- | --------------------------------------------------------- |
| `2xx`                                  | No — treated as delivered.                                |
| `5xx`, or a network failure or timeout | **Yes.** Treated as transient.                            |
| `408 Request Timeout`                  | **Yes.** Transient despite being a `4xx`.                 |
| `429 Too Many Requests`                | **Yes.** Transient, so rate-limiting is safe to signal.   |
| Any other `4xx` — `400`, `401`, `404`  | **No.** Treated as a permanent failure and never retried. |

<Warning>
  Returning `400` or `401` because your handler hit an internal error permanently discards that event. If processing fails for a reason that might succeed later — a database timeout, a downstream outage — return `5xx`, never `4xx`.
</Warning>

Because of the 10-second timeout, acknowledge first and process afterwards. A handler that finishes its work before responding will start timing out under load, and every timeout becomes a redelivery.

### Deduplicate on `id`

Retries redeliver the same event with the same `id`, so handlers must be idempotent. Record the `id` and ignore one you have already processed.

```javascript theme={null}
async function handleEvent(event) {
  // Unique index on event_id makes this safe under concurrent deliveries.
  const inserted = await db.insertIgnore('processed_events', { event_id: event.id })
  if (!inserted) return // already handled
  await applyEvent(event)
}
```

## The two event families

Money-movement events exist under two names. Which one you receive depends on your subscription's scope.

* **Partner-scoped** — the subscription carries a `partnerId`. Money-movement events are remapped to `employee.*` names and delivered only for users belonging to that partner. **This is what you want.**
* **Legacy global** — the subscription omits `partnerId`. It receives the original `wallet.*` names for every user.

The remapping is exact:

| Delivered to a partner scope     | Original name                  |
| -------------------------------- | ------------------------------ |
| `employee.deposit.completed`     | `wallet.deposit.completed`     |
| `employee.deposit.failed`        | `wallet.deposit.failed`        |
| `employee.deposit.refunded`      | `wallet.deposit.refunded`      |
| `employee.withdrawal.completed`  | `wallet.withdrawal.completed`  |
| `employee.withdrawal.failed`     | `wallet.withdrawal.failed`     |
| `employee.withdrawal.processing` | `wallet.withdrawal.processing` |

<Warning>
  Subscribing a partner-scoped config to `wallet.deposit.completed` is accepted by the API and then never fires, because the event is renamed before the subscription is matched. This is the single most likely reason a webhook integration appears silent.
</Warning>

## Event types

### Money movement

| Event                            | Fires when                                       |
| -------------------------------- | ------------------------------------------------ |
| `employee.deposit.completed`     | Funds have landed in the user's smart wallet.    |
| `employee.deposit.failed`        | A deposit did not complete.                      |
| `employee.deposit.refunded`      | A deposit was reversed.                          |
| `employee.withdrawal.processing` | A withdrawal has been accepted and is in flight. |
| `employee.withdrawal.completed`  | A withdrawal has settled at the destination.     |
| `employee.withdrawal.failed`     | A withdrawal did not complete.                   |

### Onboarding and identity

| Event                  | Fires when                                           |
| ---------------------- | ---------------------------------------------------- |
| `onboarding.completed` | The user finished onboarding and the rail is active. |
| `onboarding.failed`    | Onboarding could not complete.                       |
| `kyc.action_required`  | Verification needs something further from the user.  |

<Tip>
  These three replace polling `GET /v1/users/{userId}/onboarding/status`. Subscribing to them is the single highest-value change for an integration that currently polls, because onboarding is the longest wait in the lifecycle.
</Tip>

### Employer linking

| Event                     | Fires when                                               |
| ------------------------- | -------------------------------------------------------- |
| `employee.linked`         | An employee was linked to the partner.                   |
| `employee.vba.registered` | A virtual bank account was provisioned for the employee. |
| `employee.unlinked`       | An employee was unlinked.                                |

### Cards

| Event                      | Fires when                         |
| -------------------------- | ---------------------------------- |
| `card.fulfillment.updated` | A card's fulfilment state changed. |

<Note>
  The legacy `wallet.*` names — `wallet.deposit.completed`, `wallet.deposit.failed`, `wallet.deposit.refunded`, `wallet.withdrawal.completed`, `wallet.withdrawal.failed`, `wallet.withdrawal.processing` — are accepted only by the global subscription. Do not use them in a partner-scoped one.
</Note>

## Inspect delivery history

When an event seems missing, check whether it was delivered before assuming it was never produced.

```http theme={null}
GET /v1/webhooks/events?page=1&limit=50
```

```json theme={null}
{
  "data": [
    {
      "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
      "eventType": "employee.deposit.completed",
      "payload": { "userId": "…" },
      "status": "failed",
      "attempts": 3,
      "lastAttemptAt": "2026-08-07T12:04:00.000Z",
      "errorMessage": "timeout of 10000ms exceeded",
      "createdAt": "2026-08-07T12:00:00.000Z"
    }
  ],
  "total": 42
}
```

`status` is `pending`, `delivered`, or `failed`. When it is `failed`, `errorMessage` carries the reason — a timeout, a TLS failure, or the status code your endpoint returned. That is usually enough to tell a subscription problem from a handler problem.

<Note>
  There is no on-demand test trigger yet, so you cannot currently fire a synthetic event at your endpoint. Until one exists, exercise your handler by driving a real sandbox action — a small deposit produces `employee.deposit.completed`. Adding a test trigger is tracked as platform work.
</Note>

## Related

* [Errors and status codes](/api-reference/errors) — including the read-before-retry rule that webhooks let you avoid.
* [Integration flow](/api-reference/integration-flow) — the lifecycle these events track.
* [Sandbox test data](/api-reference/sandbox-test-data) — driving a real event in the sandbox.

***

*Last reviewed: 7 August 2026.*
