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

# Webhooks

Receive and securely verify payment, withdrawal, and Binance Pay status updates sent by 0xProcessing.

0xProcessing sends a `POST` request to your configured webhook URL whenever the status of a payment, withdrawal, or Binance Pay transaction changes. This page explains the request format, signature verification, delivery requirements, and retry behavior.

The request headers and signature verification process are the same for all webhook types. The payload and secret depend on the webhook type.

For instructions on configuring webhook URLs and secrets in the Merchant dashboard, see [Setting up Webhooks](/quick-start/setting-up-webhooks.md).

## Request format

```http
POST /your/webhook/url HTTP/1.1
Content-Type: application/json
X-Webhook-Id: payment-123456-Success
X-Webhook-Timestamp: 1760012345
X-Webhook-Signature: v1=5f2b9c0e...e81a

{"PaymentId":123456,"ShopId":"...","Amount":1.5,"Status":"Success"}
```

### Request headers

| Header                | Description                                                                                                                                                                                                                                                                                                   |
| --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `X-Webhook-Id`        | Unique event identifier in the format `{type}-{ID}-{Status}`, for example, `payment-123456-Success`, `withdraw-789-Canceled`, or `binancepay-42-Success`. The value remains unchanged across automatic retries and manual resends. Store it and use it as an idempotency key to prevent duplicate processing. |
| `X-Webhook-Timestamp` | Time at which the request was sent, represented as a Unix timestamp in seconds (UTC). The value is updated for every delivery attempt.                                                                                                                                                                        |
| `X-Webhook-Signature` | HMAC-SHA256 signature in the format `v1=<hex>`. The header may contain multiple comma-separated signatures. Accept the request if any `v1` signature is valid.                                                                                                                                                |

## Webhook types

| Webhook type                                                                         | `X-Webhook-Id` prefix | Dashboard setting                           | Secret                           |
| ------------------------------------------------------------------------------------ | --------------------- | ------------------------------------------- | -------------------------------- |
| Payments, including fixed-amount forms, variable-amount payments, and static wallets | `payment`             | **Settings → API → Webhook URL**            | **Webhook password**             |
| Withdrawals                                                                          | `withdraw`            | **Settings → API → Withdrawal Webhook URL** | **Webhook password**             |
| Binance Pay                                                                          | `binancepay`          | **Settings → API → Binance Pay webhooks**   | **Binance Pay webhook password** |

{% hint style="warning" %}
Always use the secret configured for the corresponding webhook type. Store webhook secrets securely and never expose them in client-side code or public repositories.
{% endhint %}

## Payloads

The JSON payload depends on the event type. Expand the relevant group and select the payment or withdrawal method for complete field descriptions, statuses, and examples.

<details>

<summary>Deposit webhook payloads</summary>

* [Payment form with a fixed amount](/0xprocessing-api-v1/webhooks/webhooks-for-payment-form-with-fixed-amount.md)
* [Payment without a fixed amount](/0xprocessing-api-v1/webhooks/webhooks-for-payment-without-fixed-amount.md)
* [Static wallet deposit](/0xprocessing-api-v1/webhooks/webhooks-for-static-wallet.md)
* [Binance Pay deposit](/0xprocessing-api-v1/binance-pay/webhooks.md#deposit)

</details>

<details>

<summary>Withdrawal webhook payloads</summary>

* [Standard withdrawal](/0xprocessing-api-v1/webhooks/webhooks-for-withdrawals.md)
* [Withdrawal to a Binance Pay ID](/0xprocessing-api-v1/binance-pay/webhooks.md#withdrawbinanceid)
* [On-chain withdrawal through Binance Pay](/0xprocessing-api-v1/binance-pay/webhooks.md#withdrawonchain)

</details>

## Verify the webhook signature

0xProcessing signs each webhook request with HMAC-SHA256. Generate the expected signature using the secret for the corresponding webhook type and the following message:

```
{timestamp}.{rawBody}
```

The signature is calculated as follows:

```
signature = hex(HMAC-SHA256(key = secret, message = timestamp + "." + rawBody))
```

Where:

* `timestamp` is the exact string value of the `X-Webhook-Timestamp` header.
* `rawBody` is the request body exactly as received, byte for byte.
* The key and message use UTF-8 encoding.
* The result is encoded as lowercase hexadecimal.

### Verification procedure

1. Read and preserve the raw request body before parsing the JSON. Re-serializing parsed JSON can change the body and cause signature verification to fail.
2. Reject the request if `X-Webhook-Timestamp` or `X-Webhook-Signature` is missing.
3. Verify that the difference between `X-Webhook-Timestamp` and the current time is no more than 300 seconds. Keep your server clock synchronized using NTP.
4. Calculate the expected signature.
5. Extract every `v1` value from `X-Webhook-Signature` and compare each value with the expected signature using a constant-time comparison function. Accept the request if any value matches.
6. Parse and process the payload only after the signature has been verified successfully.

If verification fails, return `401 Unauthorized` and do not process the event.

{% hint style="danger" %}
Always verify the signature before trusting any value in the payload, including the amount, currency, status, address, or transaction identifier.
{% endhint %}

### Test vector

Use the following values to test your implementation:

| Value                          | Test data                                                             |
| ------------------------------ | --------------------------------------------------------------------- |
| Secret                         | `secret`                                                              |
| `X-Webhook-Timestamp`          | `1760012345`                                                          |
| Raw request body               | `{"a":1}`                                                             |
| Expected `X-Webhook-Signature` | `v1=1f492b0951067135f187a8a117b0895bbf07f6defe3c7877b37c7f7f873991ec` |

## Implementation examples

### Python (Flask)

```python
import hashlib
import hmac
import time

from flask import Flask, abort, request

app = Flask(__name__)
SECRET = b"your-webhook-secret"
TOLERANCE = 300


def verify(raw_body: bytes, timestamp: str, signature_header: str) -> bool:
    if not timestamp or not signature_header:
        return False

    try:
        ts = int(timestamp)
    except ValueError:
        return False

    if abs(time.time() - ts) > TOLERANCE:
        return False

    message = timestamp.encode("utf-8") + b"." + raw_body
    expected = hmac.new(SECRET, message, hashlib.sha256).hexdigest()

    for part in signature_header.split(","):
        version, separator, value = part.strip().partition("=")
        if separator and version == "v1" and hmac.compare_digest(value, expected):
            return True

    return False


@app.post("/webhook")
def webhook():
    raw_body = request.get_data()

    if not verify(
        raw_body,
        request.headers.get("X-Webhook-Timestamp"),
        request.headers.get("X-Webhook-Signature"),
    ):
        abort(401)

    event_id = request.headers.get("X-Webhook-Id")
    payload = request.get_json()

    # Process the event only if event_id has not been processed before.
    return "", 200
```

### Node.js (Express)

```javascript
const crypto = require("crypto");
const express = require("express");

const app = express();
const SECRET = "your-webhook-secret";
const TOLERANCE = 300;

function verify(rawBody, timestamp, signatureHeader) {
  if (!timestamp || !signatureHeader) return false;

  const ts = Number(timestamp);
  if (!Number.isInteger(ts) || Math.abs(Date.now() / 1000 - ts) > TOLERANCE) {
    return false;
  }

  const expected = crypto
    .createHmac("sha256", SECRET)
    .update(`${timestamp}.`)
    .update(rawBody)
    .digest("hex");

  const expectedBuffer = Buffer.from(expected, "ascii");

  return signatureHeader.split(",").some((part) => {
    const [version, value = ""] = part.trim().split("=", 2);
    const valueBuffer = Buffer.from(value, "ascii");

    return (
      version === "v1" &&
      valueBuffer.length === expectedBuffer.length &&
      crypto.timingSafeEqual(valueBuffer, expectedBuffer)
    );
  });
}

app.post(
  "/webhook",
  express.raw({ type: "application/json" }),
  (req, res) => {
    if (
      !verify(
        req.body,
        req.get("X-Webhook-Timestamp"),
        req.get("X-Webhook-Signature")
      )
    ) {
      return res.sendStatus(401);
    }

    const eventId = req.get("X-Webhook-Id");
    const payload = JSON.parse(req.body.toString("utf8"));

    // Process the event only if eventId has not been processed before.
    return res.sendStatus(200);
  }
);
```

### PHP

```php
<?php
$secret = 'your-webhook-secret';
$tolerance = 300;

$rawBody = file_get_contents('php://input');
$timestamp = $_SERVER['HTTP_X_WEBHOOK_TIMESTAMP'] ?? '';
$signatureHeader = $_SERVER['HTTP_X_WEBHOOK_SIGNATURE'] ?? '';

function verifyWebhook(
    string $rawBody,
    string $timestamp,
    string $signatureHeader,
    string $secret,
    int $tolerance
): bool {
    if ($timestamp === '' || $signatureHeader === '' || !ctype_digit($timestamp)) {
        return false;
    }

    if (abs(time() - (int) $timestamp) > $tolerance) {
        return false;
    }

    $expected = hash_hmac('sha256', $timestamp . '.' . $rawBody, $secret);

    foreach (explode(',', $signatureHeader) as $part) {
        [$version, $value] = array_pad(explode('=', trim($part), 2), 2, '');
        if ($version === 'v1' && hash_equals($expected, $value)) {
            return true;
        }
    }

    return false;
}

if (!verifyWebhook($rawBody, $timestamp, $signatureHeader, $secret, $tolerance)) {
    http_response_code(401);
    exit;
}

$eventId = $_SERVER['HTTP_X_WEBHOOK_ID'] ?? '';
$payload = json_decode($rawBody, true);

// Process the event only if $eventId has not been processed before.
http_response_code(200);
```

### C# (ASP.NET Core)

```csharp
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;

[HttpPost("webhook")]
public async Task<IActionResult> Webhook()
{
    using var stream = new MemoryStream();
    await Request.Body.CopyToAsync(stream);
    var rawBody = stream.ToArray();

    var timestamp = Request.Headers["X-Webhook-Timestamp"].ToString();
    var signatureHeader = Request.Headers["X-Webhook-Signature"].ToString();

    if (!Verify(rawBody, timestamp, signatureHeader))
    {
        return Unauthorized();
    }

    var eventId = Request.Headers["X-Webhook-Id"].ToString();
    var payload = JsonSerializer.Deserialize<WebhookPayload>(rawBody);

    // Process the event only if eventId has not been processed before.
    return Ok();
}

private bool Verify(byte[] rawBody, string timestamp, string signatureHeader)
{
    if (!long.TryParse(timestamp, out var unixTimestamp) ||
        string.IsNullOrEmpty(signatureHeader))
    {
        return false;
    }

    var now = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
    if (Math.Abs(now - unixTimestamp) > 300)
    {
        return false;
    }

    var prefix = Encoding.UTF8.GetBytes($"{timestamp}.");
    using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(_secret));
    hmac.TransformBlock(prefix, 0, prefix.Length, null, 0);
    hmac.TransformFinalBlock(rawBody, 0, rawBody.Length);

    var expected = Encoding.ASCII.GetBytes(
        Convert.ToHexString(hmac.Hash!).ToLowerInvariant()
    );

    return signatureHeader
        .Split(',')
        .Select(part => part.Trim().Split('=', 2))
        .Any(parts =>
            parts.Length == 2 &&
            parts[0] == "v1" &&
            CryptographicOperations.FixedTimeEquals(
                Encoding.ASCII.GetBytes(parts[1]),
                expected
            )
        );
}
```

## Response and retry behavior

* Return **`200 OK`** to acknowledge successful delivery. Any other status code, including `201` and `204`, is treated as a delivery failure.
* Respond within **3 seconds**. If processing takes longer, store or enqueue the event, return `200 OK`, and process it asynchronously.
* If delivery fails, 0xProcessing retries the webhook periodically for a total of **31 attempts**.
* If all payment webhook delivery attempts fail, 0xProcessing sends a notification to the Merchant account email address.
* The same event may be delivered more than once. Process webhooks idempotently by storing `X-Webhook-Id` and skipping events that have already been processed.
