> For the complete documentation index, see [llms.txt](https://docs.fonbnk.com/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://docs.fonbnk.com/server-to-server/webhooks.md).

# Webhooks

We POST to your endpoint every time an order changes status. This is how you track an order — polling [Get order](/server-to-server/api-endpoints/get-order.md) is the fallback, not the plan.

There are two ways to say where to send them:

1. A global URL set in the merchant dashboard, used for every order.
2. The `webhookUrl` field on [Create order](/server-to-server/api-endpoints/create-order.md), which overrides the global URL for that one order.

{% hint style="info" %}
How to set the global endpoint is on [Getting Started](/server-to-server/getting-started.md). Order status changes are on by default; the `auth` and `kyc` events are opt-in — see [KYC and auth webhooks](/server-to-server/webhooks/kyc-and-auth-webhooks.md).
{% endhint %}

### Payload

{% code overflow="wrap" expandable="true" %}

```typescript
type Webhook = {
  event: 'order-status-change';
  data: {
    order: {
      _id: string;                  // the orderId
      userId: string;
      userEmail: string;
      merchantOrderParams?: string; // the orderParams you set on create
      countryIsoCode: string;
      flow: FlowType;
      type: OrderType;
      source: Source;
      status: OrderStatus;
      utm?: {                       // only on widget orders that carried utm_* params
        source?: string;
        medium?: string;
        campaign?: string;
      };
      deposit: {
        paymentChannel: PaymentChannel;
        currencyType: CurrencyType;
        currencyCode: string;
        currencyDetails: OrderCurrencyDetails;
        cashout: {
          exchangeRate: number;
          exchangeRateAfterFees: number;
          amountBeforeFees: number;
          amountAfterFees: number;
          amountBeforeFeesUsd: number;
          amountAfterFeesUsd: number;
        };
        transaction?: {             // crypto deposit, i.e. an off-ramp
          meta?: {
            transactionHash?: string;
            fromAddress?: string;
            toAddress?: string;
          };
        };
      };
      payout: {
        paymentChannel: PaymentChannel;
        currencyType: CurrencyType;
        currencyCode: string;
        currencyDetails: OrderCurrencyDetails;
        cashout: {
          exchangeRate: number;
          exchangeRateAfterFees: number;
          amountBeforeFees: number;
          amountAfterFees: number;
          amountBeforeFeesUsd: number;
          amountAfterFeesUsd: number;
        };
        transaction?: {
          meta?: {
            transactionHash?: string;
            fromAddress?: string;
            toAddress?: string;
          };
        };
      };
      refund?: {
        paymentChannel: PaymentChannel;
        currencyType: CurrencyType;
        currencyCode: string;
        currencyDetails: OrderCurrencyDetails;
        cashout: {
          exchangeRate: number;
          exchangeRateAfterFees: number;
          amountBeforeFees: number;
          amountAfterFees: number;
          amountBeforeFeesUsd: number;
          amountAfterFeesUsd: number;
        };
        transaction?: {
          meta?: {
            transactionHash?: string;
            fromAddress?: string;
            toAddress?: string;
          };
        };
      };
      createdAt: Date;
      updatedAt: Date;
    };
    userKyc: {                      // always present; its fields are what may be missing
      passedKycType?: KycType;
      passedKycHash?: string;       // unique identifier of the KYC submission that passed
      latestKycType?: KycType;
      latestKycStatus?: KycStatus;
    };
  };
}
```

{% endcode %}

`FlowType`, `OrderType`, `Source`, `OrderStatus`, `PaymentChannel`, `CurrencyType`, `OrderCurrencyDetails`, `KycType` and `KycStatus` are all on the [Types](/server-to-server/types.md) page.

A `transaction.meta` block is only added when it has at least one of the three fields, so a leg with no on-chain movement carries no `transaction` at all.

Note what is **not** here: the payload is a summary, not the whole order. `cashout` gives you the rates and the amounts but no fee breakdown, and there is no `transferInstructions`, no `statusChangeLogs`, no `expiresAt` and no `providedFieldsToCreateOrder`. Match the webhook to your own record with `_id` or `merchantOrderParams`, and call [Get order](/server-to-server/api-endpoints/get-order.md) when you need the rest.

Example payload

{% code overflow="wrap" expandable="true" %}

```json
{
  "event": "order-status-change",
  "data": {
    "order": {
      "_id": "68df90a2372f378356ef75c1",
      "userId": "68df8fcb372f378356ef7568",
      "userEmail": "someuser@example.com",
      "merchantOrderParams": "01K6MMKBKC8CX4SMJAR49DX5RZ",
      "countryIsoCode": "NG",
      "flow": "regular",
      "type": "on_ramp",
      "source": "api",
      "status": "payout_successful",
      "deposit": {
        "paymentChannel": "bank",
        "currencyType": "fiat",
        "currencyCode": "NGN",
        "currencyDetails": {
          "countryIsoCode": "NG"
        },
        "cashout": {
          "exchangeRate": 1460.2,
          "exchangeRateAfterFees": 1505.2969,
          "amountBeforeFees": 15054,
          "amountAfterFees": 14603,
          "amountBeforeFeesUsd": 10.309547,
          "amountAfterFeesUsd": 10.000685
        }
      },
      "payout": {
        "paymentChannel": "merchant_balance",
        "currencyType": "merchant_balance",
        "currencyCode": "USD",
        "currencyDetails": {
          "merchantName": "Your company"
        },
        "cashout": {
          "exchangeRate": 1,
          "exchangeRateAfterFees": 1,
          "amountBeforeFees": 10,
          "amountAfterFees": 10,
          "amountBeforeFeesUsd": 10,
          "amountAfterFeesUsd": 10
        }
      },
      "createdAt": "2025-10-03T08:56:43.212Z",
      "updatedAt": "2025-10-03T08:57:03.247Z"
    },
    "userKyc": {
      "passedKycType": "basic",
      "passedKycHash": "a5f0c1e4d2b34f7a9c1e0f6b8d2a4c31",
      "latestKycType": "basic",
      "latestKycStatus": "approved"
    }
  }
}
```

{% endcode %}

### Verifying the signature

Every request carries an <mark style="color:$warning;">**x-signature**</mark> header. Verify it before you act on the payload, using the webhook secret from the merchant dashboard.

The signature is the SHA-256 of the **raw request body followed by the hex SHA-256 of your secret**:

```pseudocode
innerHashHex = hex( SHA256( secret ) )
x-signature  = hex( SHA256( rawRequestBody + innerHashHex ) )
```

{% hint style="warning" %}
Two things trip people up here.

**Order matters.** It is `body` then `secretHash`, not the other way round. Reversing them produces a signature that never matches.

**Hash the raw body, byte for byte.** Do not parse the JSON and re-serialise it — a different key order, or different whitespace, gives a different hash. Read the raw body before your framework parses it.
{% endhint %}

**TypeScript example:**

{% code overflow="wrap" %}

```typescript
import { createHash } from 'crypto';

// rawBody must be the exact bytes we sent, before any JSON parsing.
function verifyWebhookSignature(
  rawBody: string,
  signature: string,
  secret: string,
): boolean {
  const secretHashHex = createHash('sha256').update(secret, 'utf8').digest('hex');
  const expected = createHash('sha256')
    .update(rawBody)
    .update(secretHashHex)
    .digest('hex');

  return signature === expected;
}
```

{% endcode %}

**Python example:**

{% code overflow="wrap" %}

```python
import hashlib

def verify_webhook_signature(raw_body: bytes, signature: str, secret: str) -> bool:
    secret_hash_hex = hashlib.sha256(secret.encode('utf-8')).hexdigest()
    expected = hashlib.sha256(raw_body + secret_hash_hex.encode('utf-8')).hexdigest()
    return signature == expected
```

{% endcode %}

### What your endpoint must do

* Answer with a **2xx** status. Anything else counts as a failure.
* **Answer at the URL you gave us.** We do not follow redirects — the request is sent with a redirect limit of zero, so a `301` or `302` is a failed delivery, retried and then given up on. This catches people whose host redirects `http` to `https`: register the final `https` URL.
* Answer within **20 seconds**. A slower response is treated as a timeout.
* Be idempotent. A retry can deliver the same status twice, and a delivery can arrive out of order — key on `_id` or `merchantOrderParams` and ignore a status you have already handled.

### Retries

A failed delivery is retried up to **10 attempts in total** — the first plus nine retries — with an exponential backoff starting at one second: 1s, 2s, 4s, 8s, 16s, 32s, 64s, 128s, 256s. Every attempt and its result is visible in the merchant dashboard under Webhooks.

{% hint style="info" %}
We refuse to POST to a URL that resolves to a private or internal address. If deliveries never arrive, check the dashboard's delivery results first — a blocked target is recorded there, as is a refused redirect.
{% endhint %}


---

# Agent Instructions
This documentation is published with GitBook. GitBook is the documentation platform designed so that both humans and AI agents can read, navigate, and reason over technical content effectively. Learn more at gitbook.com.

## Querying This Documentation
If you need additional information that is not directly available in this page, you can query the documentation dynamically by asking a question.

Perform an HTTP GET request on the current page URL with the `ask` query parameter, and the optional `goal` query parameter:

```
GET https://docs.fonbnk.com/server-to-server/webhooks.md?ask=<question>&goal=<endgoal>
```

`ask` is the immediate question: it should be specific, self-contained, and written in natural language.
`goal` is optional and describes the broader end goal you are ultimately trying to accomplish on behalf of the user. GitBook uses it to tailor the answer towards what is most useful for that goal.

The response will contain a direct answer to the question and relevant excerpts and sources from the documentation.

Use this mechanism when the answer is not explicitly present in the current page, you need clarification or additional context, or you want to retrieve related documentation sections.
