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

> Outgoing webhook payloads, signatures, retries, and payment-level resend

# Webhooks API

123hub sends outgoing webhooks to merchant callback URLs when payment or payout events occur. Merchants can configure callbacks through onboarding/dashboard flows, Backoffice operations, or per-payment `webhook_url`.

## Event Types

| 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 use the same `payment.*` callbacks as deposits and are identified by `data.result.payment.destination: "out"`. `payment.processing` is a payment status value, not a currently emitted merchant callback event. Some account tooling may also expose `balance.updated`, `merchant.updated`, and `webhook.test` events outside the payment lifecycle.

Opt-in public support selectors are `ticket.created`, `ticket.updated`, `ticket.status_changed`, `ticket.comment.created`, `ticket.reopened`, `refund.requested`, `refund.status_changed`, and `chargeback.created`. They use the same delivery headers/retries but a minimal public-resource payload; see [Support webhooks](/guides/support-webhooks). Existing subscriptions are not expanded automatically.

## Delivery Payload

Outgoing payment webhook deliveries use this envelope:

```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"
        },
        "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"
}
```

Internal indexing fields are stripped before delivery. Do not depend on fields prefixed with `_`.

## Delivery 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)` |

Use `X-Webhook-Signature-V3` when present. It binds replay metadata and the raw body. Use `X-Webhook-Signature-V2` or `X-Data-Hash` only for backward-compatible verification.

Webhook create/rotation reveals a dedicated webhook-only secret once. Historical
API-compatible rows do not deliver until the webhook secret is explicitly rotated;
API-key rotation is independent. For a callback auto-created by
`params.payment.webhook_url`, derive the webhook secret as lowercase
`HMAC-SHA256(apiSecret, "quadpay:merchant-webhook-signing:v1")`, using the exact
test or production API secret that authenticated that payment create. The URL is
bound to that payment and never receives another payment's events.

## Signature Verification

Verify against the exact raw request body bytes. Do not parse and re-serialize JSON before hashing.

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

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(rawBody, webhookSecret, receivedHash) {
  const expected = crypto
    .createHash("sha512")
    .update(Buffer.concat([Buffer.from(rawBody), Buffer.from(webhookSecret)]))
    .digest("hex");

  return safeCompareHex(expected, receivedHash);
}

function verifyWebhookV2(rawBody, webhookSecret, timestamp, receivedHash) {
  const timestampMs = Date.parse(timestamp || "");
  if (
    !Number.isFinite(timestampMs) ||
    Math.abs(Date.now() - timestampMs) > 5 * 60 * 1000
  ) {
    return false;
  }

  const expected = crypto
    .createHash("sha512")
    .update(String(timestamp) + rawBody.toString("utf8") + webhookSecret)
    .digest("hex");

  return safeCompareHex(expected, receivedHash);
}

function verifyWebhookV3(
  rawBody,
  webhookSecret,
  timestamp,
  nonce,
  webhookId,
  receivedHash,
) {
  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, Buffer.from(rawBody).toString("utf8")].join(
        ".",
      ),
    )
    .digest("hex");

  return safeCompareHex(expected, receivedHash);
}
```

After verification, read payment data from `payload.data.result.payment`.

## Retry Policy

Webhook delivery succeeds on any HTTP 2xx response. Non-2xx responses, timeouts, network errors, oversized payloads, URL validation failures, or an open circuit breaker are treated as failures.

Default webhook retry settings:

| Setting               | Default | Notes                                  |
| --------------------- | ------- | -------------------------------------- |
| `timeout_seconds`     | `30`    | Configurable from 5 to 60 seconds      |
| `max_retries`         | `3`     | Configurable from 1 to 10 attempts     |
| `retry_delay_seconds` | `1`     | Base delay used by exponential backoff |

The retry delay uses exponential backoff with jitter:

```text theme={null}
delay = min(max(random(0, 2^(attempt - 1) * retry_delay_seconds), 1s), 24h)
```

Retries are processed by a scheduler. Delivery order is not guaranteed, so handlers must be idempotent.

## Idempotency

Use `id` plus `data.result.payment.status.status` as a practical deduplication key. `data.request_id` is unique per generated callback body and should not be used as the business idempotency key.

## Webhook Management

Legacy callback configuration routes under `/api/v1/webhooks` are no longer part of the public API. Configure merchant callback URLs through onboarding or Backoffice operations. Payment-level re-delivery remains available through the signed endpoint below.

## Merchant Payment Webhook Resend

For payment-level re-delivery, merchants can also use the signed API endpoint:

```http theme={null}
POST /api/v1/payments/{paymentId}/webhook/resend
```

This mutation requires `X-Data-Application-Id`, `X-Data-Hash` and the
`webhooks.update` scope. `X-Data-Timestamp` plus `X-Data-Nonce` are an
optional pair; a partial pair is rejected. It is throttled to 10 requests per
minute. The JSON body is:

```json theme={null}
{
  "payment_id": "pay_01HX...",
  "operation_id": "merchant-resend-1001"
}
```

`payment_id` must equal the path parameter. Reuse `operation_id` only for an
exact retry of the same resend intent.

Without replay headers, `X-Data-Hash` remains
`SHA512(raw JSON body + API secret)`. With both optional replay headers, the
signature payload is the exact UTF-8 concatenation below, followed by the raw
JSON body; `X-Data-Hash` is `SHA512(payload + API secret)`:

```text theme={null}
quadpay-merchant-webhook-resend-v1
POST
/api/v1/payments/{paymentId}/webhook/resend
{unixTimestamp}
{nonce}

{rawJsonBody}
```

## Per-Payment Webhook URL

`payment.in` and `payment.out` accept `params.payment.webhook_url`. In production, use an absolute public HTTPS URL including the protocol. The system validates it at request time and again before delivery to reduce SSRF risk.

The first use of a new URL creates a webhook-only derived secret. Compute the
same lowercase hex value with
`HMAC-SHA256(exactAuthenticatedApiSecret, "quadpay:merchant-webhook-signing:v1")`
and use that derived value for callback verification.
