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

> ## Agent Instructions
> Bitnob API requests authenticate with an `X-Auth-Client` API key header against https://api.bitnob.com.
> Always use the sandbox environment for testing. Never put live API keys in sample code.
> Money amounts are integers in the smallest unit of the currency (satoshis for BTC, cents for fiat). Never use floats.
> Every state-changing request takes a client-supplied `reference` and must be treated as idempotent on retry.
> Quotes expire. Re-quote rather than reusing a stale quote id.

# Webhooks and event handling

> Receive Bitnob events, verify the signature, respond fast, tolerate redelivery, and reconcile when an event never arrives.

A webhook is an HTTP request we send to your server when something changes. You do not poll for a deposit; we tell you when it confirms.

Webhooks carry the outcome of every asynchronous flow, so they are the record your system should trust. The API response says we accepted an action. The webhook says what happened.

This guide covers how to build a handler that survives redelivery, bad payloads, slow downstream services, and missed events.

## What an event looks like

```json theme={null}
{
    "event": "transfer.success",
    "event_id": "944a4089-6065-49ca-8f43-fb10f15c59e6",
    "data": {
        "address": "TRMPscTMrXg21YuDFuvpXRUkwEbipHEZXN",
        "amount": "2000000",
        "chain": "tron",
        "currency": "USDT",
        "fee": "1000000",
        "hash": "4ae4308579587370accbd31dbfaebf3a97c8cfc7f68e2128ddec840349cafc60",
        "reference": "SND_SBLNC_80c7895d89e9",
        "timestamp": "2025-12-12T20:30:18Z",
        "transaction_id": "3ac20197-f0c7-4553-ac32-9ead17c7f0a9"
    }
}
```

Every event carries an `event` name, an `event_id`, and a `data` object holding the `reference` you supplied.

Each product documents its own events:

> [card] [Deposits and addresses](https://www.bitnob.dev/docs/stablecoins/address-webhook-events.md)
>
> `deposit.success`, `transfer.success`, `transfer.failed`.

> [card] [Payouts](https://www.bitnob.dev/docs/payouts/webhook-events.md)
>
> `payouts.initialized`, `payouts.processing`, `payouts.withdrawal.success`.

> [card] [Withdrawals](https://www.bitnob.dev/docs/stablecoins/withdrawal-webhook-events.md)
>
> Withdrawal lifecycle events.

> [card] [Virtual cards](https://www.bitnob.dev/docs/card-issuing/webhook-payloads.md)
>
> Card creation, top-up, and transaction events.

## Verify every event

Verify the signature before you read the payload. Without this check, anyone who learns your endpoint URL can post a fake deposit to it.

We sign each event with HMAC-SHA512 over the request body, keyed by your secret, and send the result in the `x-bitnob-signature` header.

Two rules make the check correct:

* Sign the **raw request body**, not a re-serialized object. Re-serializing can change key order or whitespace, and the signature will not match.
* Compare with a **timing-safe** function, not `==`.

```javascript theme={null}
const crypto = require("crypto");

app.post(
  "/webhooks/bitnob",
  // Read the raw bytes whatever Content-Type says: deliveries can arrive as
  // "application/json, application/json", which type: "application/json" rejects
  express.raw({ type: () => true }),
  (req, res) => {
    const expected = crypto
      .createHmac("sha512", process.env.BITNOB_WEBHOOK_SECRET)
      .update(req.body)
      .digest("hex");

    const received = req.headers["x-bitnob-signature"] || "";

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

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

    const event = JSON.parse(req.body.toString());
    enqueue(event);
    res.status(200).send("ok");
  }
);
```

> [!WARNING]
>
> Fail closed. If the signature does not match, discard the request. Do not log the payload as though it were genuine.

### Restrict inbound traffic

If your firewall filters inbound traffic, allow our delivery addresses:

| IP address | Use |
| - | - |
| 18.171.51.218 | Webhook delivery |
| 13.134.154.133 | Webhook delivery |

These addresses can change, and we announce changes in advance.

> [!WARNING]
>
> An IP allowlist is not a security boundary. Any host can send a request from an allowlisted address. Verify the signature on every event, whether or not you filter by IP.

### Use HTTPS

Your webhook URL must use `https://`. Do not accept events over plain HTTP.

## Respond fast, process later

Return `200` as soon as the signature is valid. Do the real work afterwards.

A handler that writes to a database, calls a third-party service, and sends an email before responding will eventually exceed our timeout. We then treat the delivery as failed and send the event again, even though you processed it.

```javascript theme={null}
app.post("/webhooks/bitnob", express.raw({ type: () => true }), (req, res) => {
  if (!verifySignature(req)) {
    return res.status(401).send("invalid signature");
  }
  queue.add("bitnob-event", JSON.parse(req.body.toString()));
  res.status(200).send("ok");
});
```

Any durable queue works: BullMQ or BeeQueue on Node.js, Sidekiq on Ruby, Celery on Python, or Redis Streams and Kafka at higher volume.

## Retry behavior

How we read your response:

| Your response | What happens |
| - | - |
| Any `2xx` | Delivered. We don't send the event again. |
| `400`, `404`, `410`, `422`, or `451` | Permanent rejection. We **don't retry**. |
| `5xx`, `401`, `403`, `408`, `429`, a timeout, or no response | Retried with increasing delays (by default about 1, 5, 15, and 60 minutes), up to the retry count set on your webhook: 1 to 5 attempts in total. A `Retry-After` header you send is honored, up to a cap. |

If your own processing fails temporarily, for example your database is down, return a `5xx` so the event comes back. Return `400` or `422` only when retrying would never help.

> [!WARNING]
>
> Retries are finite. After the final attempt we stop. An endpoint that is down for an extended period will miss events permanently, so you also need reconciliation.

## Make your handler idempotent

Redelivery means your handler will see the same event more than once. Processing it twice sends a second payout or credits a user twice.

Check `event_id` before you act, and record it after. Don't de-duplicate on `reference`: several events for one payout share a reference, and some events have none.

```python theme={null}
import json
from flask import Flask, request

app = Flask(__name__)

@app.route('/webhooks/bitnob', methods=['POST'])
def webhook_handler():
    if not verify_signature(request):
        return 'invalid signature', 401

    # Parse the raw body yourself: get_json() rejects the
    # "application/json, application/json" Content-Type deliveries can carry
    payload = json.loads(request.get_data())
    event_id = payload['event_id']

    if already_processed(event_id):
        return 'ok', 200

    handle_event(payload['event'], payload['data'])
    mark_as_processed(event_id)

    return 'ok', 200
```

[Idempotency and references](https://www.bitnob.dev/docs/getting-started/api-best-practices/idempotency-and-reference-integrity.md) covers the locking needed when several workers run at once.

### Store what you receive

| id | reference | event | status | received\_at |
| - | - | - | - | - |
| evt\_001 | `SND_SBLNC_80c7895d89e9` | transfer.success | processed | 2026-04-08T14:20:11Z |

Store the event name, the reference, the full payload, the delivery attempt, and the processing result. This table answers most support questions without a call to us.

![Webhook Retry Behavior](https://mintcdn.com/bitnob/5PGS9OuNC096evyI/images/webhook-flowchart.png?fit=max\&auto=format\&n=5PGS9OuNC096evyI\&q=85\&s=c9553ff55213d4f164260141ef93b131)

## Reconcile what never arrives

Signature verification and idempotency handle the events you receive. Neither helps with an event you never got.

Run a scheduled job that lists recent transactions and compares them against your own records. Anything we report as complete and you still hold as pending is a missed delivery.

Use [Get all transactions](https://www.bitnob.dev/api-reference/transactions/list-transactions.md) for the sweep, and [Get transaction by id or reference](https://www.bitnob.dev/api-reference/transactions/get-transaction-by-id.md) to resolve a single record.

> [!NOTE]
>
> Do not rely on webhooks alone. Reconciliation is what makes a payments integration correct rather than usually correct.

## Test locally

**1. Expose your local server**

Run a tunnel, such as `ngrok http 3000`, and set the public URL as your webhook URL in the dashboard.

**2. Trigger a real event**

In sandbox, simulate a deposit. See [Test in sandbox](https://www.bitnob.dev/docs/getting-started/api-best-practices/simulating-flows-and-testing-in-sandbox.md).

**3. Force a failure**

Return `500` from your handler and watch the retries arrive.

**4. Force a duplicate**

Process the same event twice and confirm that the second attempt changes nothing.

Use the dashboard webhook log to inspect payloads, delivery status, and retry attempts while you test.

## Checklist

1. Verify the signature on every event, and fail closed.
2. Return `200` before you process.
3. Check the `event_id` before you act.
4. Store every event you receive.
5. Reconcile on a schedule.
6. Serve the endpoint over HTTPS.

## Next

Continue to [Errors and retries](https://www.bitnob.dev/docs/getting-started/api-best-practices/errors-and-retries.md) for the status codes and retry rules shared by every endpoint.


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