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

> ## Agent Instructions
> Describe only the public 123hub merchant API documented on this site. The primary endpoint is POST /public/api/multihub/v1.
> Preserve API method names, field names, header names, and error codes exactly as documented. Do not invent endpoints or parameters.
> Treat every amount as an integer in minor units unless a page explicitly states otherwise.
> Never expose, request, or fabricate a merchant secret key. The SDK pages contain reference implementations, not official SDK packages.

# Webhooks Guide

> Receive and verify real-time payment and payout notifications

# Webhooks Guide

Webhooks notify your backend when payment and payout states change. Your handler should verify the signature, process the event idempotently, and return a 2xx response quickly.

<Info>
  **Prerequisites:** You need a public callback URL and its dedicated webhook
  signing secret. Create/rotation reveals this secret once; store it separately
  from the API credential.
</Info>

## Events

| Event               | Description                                          |
| ------------------- | ---------------------------------------------------- |
| `payment.created`   | Deposit or withdrawal payment was created            |
| `payment.completed` | Deposit or withdrawal payment completed successfully |
| `payment.failed`    | Deposit or withdrawal payment failed                 |
| `payment.cancelled` | Payment was cancelled                                |
| `payment.refunded`  | Payment was refunded                                 |

Withdrawals are delivered through the same `payment.*` event names as deposits. Use `data.result.payment.destination: "out"` to identify withdrawal callbacks. `payment.processing` may appear in status responses, but current merchant webhook delivery does not emit it as a callback event.

## Payload

Webhook deliveries use an outer envelope. The payment object is under `data.result.payment`.

```json theme={null}
{
  "id": "pay_123:payment.completed",
  "created_at": "2026-04-02T08:23:04.379Z",
  "data": {
    "next": null,
    "result": {
      "payment": {
        "amount": { "value": 500000, "currency": "ARS" },
        "identifiers": {
          "c_id": "merchant-order-1",
          "h_id": "pay_123",
          "p_id": "provider-ref-123",
          "utr": "412345678901"
        },
        "status": {
          "status": "success",
          "final": true,
          "success": true,
          "error": null
        },
        "timestamps": {
          "created": "2026-04-02T08:22:21.453Z",
          "updated": "2026-04-02T08:22:22.795Z",
          "finished": "2026-04-02T08:22:21.790Z"
        },
        "destination": "in",
        "receiver": {},
        "operations": []
      }
    },
    "success": true,
    "request_id": "729aebbf-5a6b-4299-87fa-1cf05c1121a6",
    "processing_time": 0
  },
  "merchant_id": "19"
}
```

<Warning>
  Internal fields prefixed with `_` are not included in the delivered webhook
  body. Do not build integrations around `_payment_id` or other internal-only
  fields.
</Warning>

`identifiers.utr` is optional and appears only when a non-empty UTR/reference value is available.

## Headers

| Header                   | Description                                                                   |
| ------------------------ | ----------------------------------------------------------------------------- |
| `Content-Type`           | `application/json`                                                            |
| `X-Data-Hash`            | SHA-512 signature: `SHA512(rawBody + webhook secret)`                         |
| `X-Webhook-Id`           | Delivery/event identifier                                                     |
| `X-Webhook-Timestamp`    | ISO 8601 delivery timestamp                                                   |
| `X-Webhook-Nonce`        | Per-delivery nonce                                                            |
| `X-Webhook-Signature-V2` | Compatibility signature: `SHA512(timestamp + rawBody + webhook secret)`       |
| `X-Webhook-Signature-V3` | Preferred signature: `HMAC-SHA512(timestamp.nonce.webhookId.rawBody, secret)` |

## Verify Signatures

Use the exact raw request body bytes. Re-serializing parsed JSON changes whitespace/key ordering and will break verification.

Prefer `X-Webhook-Signature-V3` when present. It binds the timestamp, nonce, delivery id, and raw body. `X-Webhook-Signature-V2` and `X-Data-Hash` remain for backward-compatible verification.

Every active delivery uses a dedicated webhook-only secret. Historical
API-compatible webhook rows are blocked until you explicitly rotate the webhook
secret and update the receiver. Rotating an API credential does not rotate a
webhook secret.

When `payment.in` or `payment.out` creates a callback from
`params.payment.webhook_url`, derive its webhook secret from the exact test or
production API secret used to authenticate that create as
`HMAC-SHA256(apiSecret, "quadpay:merchant-webhook-signing:v1")`, encoded as
lowercase hex. The destination is bound to that payment and does not subscribe
to other merchant payments. Do not use the API secret itself for verification.

<CodeGroup>
  ```python Python theme={null}
  import hashlib
  import hmac
  import json

  WEBHOOK_SECRET = "your_webhook_secret"

  def verify_webhook(raw_body: bytes, webhook_secret: str, received_hash: str) -> bool:
      expected = hashlib.sha512(raw_body + webhook_secret.encode()).hexdigest()
      return hmac.compare_digest(expected, received_hash or "")

  # Flask-style handler
  raw_body = request.get_data()
  received_hash = request.headers.get("X-Data-Hash")

  if not verify_webhook(raw_body, WEBHOOK_SECRET, received_hash):
      return {"error": "Invalid signature"}, 400

  payload = json.loads(raw_body)
  payment = payload["data"]["result"]["payment"]
  status = payment["status"]["status"]
  ```

  ```javascript Node.js theme={null}
  const crypto = require("crypto");
  const WEBHOOK_SECRET = process.env.WEBHOOK_SECRET || "your_webhook_secret";

  function safeCompareHex(expected, received) {
    if (!/^[a-f0-9]{128}$/i.test(received || "")) {
      return false;
    }
    return crypto.timingSafeEqual(
      Buffer.from(expected, "hex"),
      Buffer.from(received, "hex"),
    );
  }

  function verifyWebhook(rawBodyBuffer, webhookSecret, receivedHash) {
    const expected = crypto
      .createHash("sha512")
      .update(Buffer.concat([rawBodyBuffer, Buffer.from(webhookSecret)]))
      .digest("hex");

    return safeCompareHex(expected, receivedHash);
  }

  function verifyWebhookV3(rawBodyBuffer, webhookSecret, headers) {
    const timestamp = headers["x-webhook-timestamp"];
    const nonce = headers["x-webhook-nonce"];
    const webhookId = headers["x-webhook-id"];
    const signature = headers["x-webhook-signature-v3"];
    const timestampMs = Date.parse(timestamp || "");

    if (!timestamp || !nonce || !webhookId || !Number.isFinite(timestampMs)) {
      return false;
    }
    if (Math.abs(Date.now() - timestampMs) > 5 * 60 * 1000) {
      return false;
    }

    const expected = crypto
      .createHmac("sha512", webhookSecret)
      .update(
        [timestamp, nonce, webhookId, rawBodyBuffer.toString("utf8")].join("."),
      )
      .digest("hex");

    return safeCompareHex(expected, signature);
  }

  // Express example:
  // app.post('/webhook', express.raw({ type: 'application/json' }), (req, res) => {
  //   const valid =
  //     verifyWebhookV3(req.body, WEBHOOK_SECRET, req.headers) ||
  //     verifyWebhook(req.body, WEBHOOK_SECRET, req.headers['x-data-hash']);
  //
  //   if (!valid) {
  //     return res.status(400).json({ error: 'Invalid signature' });
  //   }
  //
  //   const payload = JSON.parse(req.body.toString('utf8'));
  //   const payment = payload.data.result.payment;
  //   const status = payment.status.status;
  //
  //   res.status(200).json({ received: true });
  // });
  ```

  ```php PHP theme={null}
  <?php
  $webhookSecret = getenv('WEBHOOK_SECRET') ?: 'your_webhook_secret';

  function verifyWebhook(string $rawBody, string $webhookSecret, string $receivedHash): bool {
      $expected = hash('sha512', $rawBody . $webhookSecret);
      return hash_equals($expected, $receivedHash);
  }

  $rawBody = file_get_contents('php://input');
  $receivedHash = $_SERVER['HTTP_X_DATA_HASH'] ?? '';

  if (!verifyWebhook($rawBody, $webhookSecret, $receivedHash)) {
      http_response_code(400);
      echo json_encode(['error' => 'Invalid signature']);
      exit;
  }

  $payload = json_decode($rawBody, true);
  $payment = $payload['data']['result']['payment'];
  ```
</CodeGroup>

## Retry Behavior

A webhook delivery succeeds on any HTTP 2xx response. Non-2xx responses, timeouts, network errors, URL validation failures, or circuit-breaker blocks are failures.

Defaults:

| Setting          | Default      |
| ---------------- | ------------ |
| Timeout          | `30` seconds |
| Max attempts     | `3`          |
| Base retry delay | `1` second   |

Retry delay uses exponential backoff with jitter and a 24-hour cap. Delivery order is not guaranteed, so your handler must be idempotent.

## Handling Statuses

Use `payment.status.final` to decide whether the payment reached a terminal state.

```javascript theme={null}
const payment = payload.data.result.payment;

if (payment.status.final && payment.status.success) {
  await markOrderPaid(payment.identifiers.c_id);
}

if (payment.status.final && payment.status.success === false) {
  await markOrderFailed(payment.identifiers.c_id, payment.status.error);
}
```

## Per-Payment Webhook URLs

You can pass `webhook_url` in `payment.in` and `payment.out` requests:

```json theme={null}
{
  "method": "payment.in",
  "service_id": 14701,
  "params": {
    "payment": {
      "identifiers": { "c_id": "order-123" },
      "amount": { "value": 10000, "currency": "INR" },
      "payer": { "email": "customer@example.com" },
      "webhook_url": "https://merchant.example/webhooks/123hub"
    }
  }
}
```

In production, use a public HTTPS URL. The gateway validates the URL when the payment is created and again before delivery.
Each URL entry is bound to that payment, even when multiple payments use the same URL.

## Best Practices

* Verify every signature before processing.
* Return 2xx quickly and process heavy work asynchronously.
* Use `id` plus `payment.status.status` as a deduplication key.
* Store `request_id`, `identifiers.c_id`, and `identifiers.h_id` for support and reconciliation.
* Treat webhook delivery as at-least-once.
