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

# Troubleshooting: webhooks not received

> Why Bitnob webhook events fail to arrive, from non-public URLs and firewall rules to missed retries, and why polling remains your safety net.

When a webhook does not arrive, the cause is almost always on the delivery path: the URL is not publicly reachable, a firewall blocks our sender, or your endpoint rejected the event or the retries were spent. Work through these in order.

## 1. Your endpoint must be publicly reachable

Webhook notifications are only delivered to a publicly accessible URL. While building locally, events cannot reach `localhost`.

**Fix:** expose your local server with a tunnel such as [ngrok](https://ngrok.com/) or [localtunnel](https://theboroer.github.io/localtunnel-www/), then update the webhook URL setting on your dashboard. Only do this in your test environment to avoid leaking data to the public.

## 2. Your firewall may be blocking us

If your infrastructure restricts inbound traffic, allow requests from our webhook delivery addresses:

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

These addresses can change; we announce changes ahead of time. An allowlist is not a substitute for verifying the **x-bitnob-signature** header, which stays correct even if our infrastructure moves.

> [!WARNING]
>
> **Don't allowlist by IP alone.** Any host can send a request from an allowlisted address. Always validate the signature on every event, whether or not you filter by IP.

## 3. Your endpoint rejected the event, or the retries ran out

Any `2xx` counts as delivered. A `400`, `404`, `410`, `422`, or `451` is treated as a permanent rejection, and the event is **never retried**. Anything else, including a timeout, is 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. After that, the event is not sent again.

Common ways to lose an event:

* a route mismatch returned `404`, or validation in your handler returned `400` or `422`, so the first failure was final,
* your endpoint was down or redeploying for longer than the retry window,
* it did heavy processing synchronously before responding, and timed out on every attempt.

**Fix:** respond with a `2xx` immediately and process asynchronously. If processing fails temporarily, return a `5xx` so the event is retried. Make handling idempotent, since the same event can be delivered more than once. See [Retries and duplicates](https://www.bitnob.dev/api-reference/webhooks.md#retries-and-duplicates).

## 4. Verify the signature while debugging

If events arrive but appear to be dropped by your own handler, check signature verification. Validate the **x-bitnob-signature** header: an HMAC SHA-512 (hex) of the raw request body, keyed with the webhook secret set in your dashboard. Signing with your API client secret, or over re-serialized JSON, never matches.

```javascript theme={null}
const crypto = require('crypto');
const express = require('express');
const app = express();
const webhookSecret = process.env.BITNOB_WEBHOOK_SECRET;

// Read the raw bytes whatever Content-Type says: deliveries can arrive as
// "application/json, application/json", which type: 'application/json' rejects
app.post('/webhook_url', express.raw({ type: () => true }), (req, res) => {
  // Sign the raw body: re-serialising parsed JSON changes the bytes
  const expected = crypto.createHmac('sha512', webhookSecret).update(req.body).digest('hex');
  const received = req.headers['x-bitnob-signature'] || '';
  const valid =
    received.length === expected.length &&
    crypto.timingSafeEqual(Buffer.from(received), Buffer.from(expected));
  if (!valid) return res.sendStatus(401);

  res.sendStatus(200);
  // Parse the body only after the signature checks out
  const event = JSON.parse(req.body);
  // De-duplicate on event.event_id, then process
});
```

A handler that silently skips unverified events looks identical to "no webhook received". Log verification failures explicitly.

## Don't rely on webhooks entirely

> [!NOTE]
>
> We recommend that you set up a service to always query transactions, in the event that webhooks keep failing.

Polling is your safety net, not a fallback you add later. For payouts specifically, store the payout `id` and track status via `GET /api/payouts/{id}`, so a missed webhook never leaves a transaction in an unknown state.


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