# Webhook 通知

> 通过 HMAC 签名的 webhook 实时接收支付与提现状态更新。

每当支付状态变更时，2328.io 会向您配置的 `url_callback` 发送 webhook。这是获取支付状态通知的推荐方式。

## 请求格式

- **方法：** `POST`
- **Content-Type：** `application/json`
- **签名：** 请求体中的 `sign` 字段

## 载荷

webhook 请求体遵循 `/v1/payment/info` 响应格式，并额外包含 `tx_explorer_url` 和用于签名校验的 `sign` 字段。

### 支付成功

```json
{
  "uuid": "db17d490-15b6-47b9-9015-91d1d8b119f2",
  "order_id": "ORDER-12345",
  "amount": "180.00000000",
  "currency": "RUB",
  "url": "https://go.2328.io/db17d490-15b6-47b9-9015-91d1d8b119f2",
  "expires_at": "2026-05-09T16:56:58+03:00",
  "created_at": "2026-05-09T15:56:58+03:00",
  "payer_currency": "TON",
  "payer_amount": "0.95256917",
  "network": "TON",
  "address": "UQA0RevhkCQx-EltyNgPPeG8dqtnCz7ZslOzMdNQlLxVaNBb",
  "payment_status": "paid",
  "txid": "41c2a327323480af8e705d05deb09c238a41779928832abef4bb77c862357b11",
  "tx_explorer_url": "https://tonviewer.com/transaction/41c2a327323480af8e705d05deb09c238a41779928832abef4bb77c862357b11",
  "payment_amount": "0.95256917",
  "merchant_amount": "0.949711462490000000",
  "amount_usd": "2.41324380",
  "exchange_rate": "0.01340691",
  "sign": "6f8c15b6e53b506d5bfa38ed3fb3b50697af73434262153c02e412541372f04d"
}
```

### 已取消 / 失败的支付

未到达终态 `paid` 时，`txid`、`payment_amount` 和 `merchant_amount` 为 `null`：

```json
{
  "uuid": "48edaf2d-2c49-4638-8f86-88636f661c1f",
  "order_id": "ORDER-12345",
  "amount": "2800.00000000",
  "currency": "RUB",
  "url": "https://go.2328.io/48edaf2d-2c49-4638-8f86-88636f661c1f",
  "expires_at": "2026-05-09T06:19:04+03:00",
  "created_at": "2026-05-09T05:19:04+03:00",
  "payer_currency": "ETH",
  "payer_amount": "0.01620968",
  "network": "ETH-ERC20",
  "address": "0x37c20d6d96d130Bc5B33D832e43b8e16aACe0c59",
  "payment_status": "cancel",
  "txid": null,
  "tx_explorer_url": null,
  "payment_amount": null,
  "merchant_amount": null,
  "amount_usd": "37.53934800",
  "exchange_rate": "0.01340691",
  "sign": "40ce68ad9691ad54e684329d75ab5adaf5b01409a2d18d3e0110b8c1be605342"
}
```

### 字段说明

| 字段 | 类型 | 说明 |
|------|------|------|
| `uuid` | string | 支付 UUID |
| `order_id` | string | 您的订单 ID |
| `amount` | decimal (8 位小数) | `currency` 对应的法币金额 |
| `currency` | string | 商户请求的法币币种 |
| `url` | string | 托管支付页面 URL |
| `expires_at` | string (ISO 8601) | 支付会话的过期时间 |
| `created_at` | string (ISO 8601) | 支付会话的创建时间 |
| `payer_currency` | string | 付款人使用的加密货币 |
| `payer_amount` | decimal (8 位小数) | 预期支付的加密货币数量 |
| `network` | string | 区块链网络 |
| `address` | string | 收款地址 |
| `payment_status` | string | 取值之一：`pending`、`check`、`paid`、`underpaid_check`、`underpaid`、`overpaid`、`cancel`、`aml_lock`（详见 [References](/docs/references)） |
| `txid` | string \| null | 链上交易哈希，仅在支付确认后才有值 |
| `tx_explorer_url` | string \| null | 区块链浏览器中的交易链接。若没有 `txid` 或该转账为内部 P2P，则为 `null`。 |
| `payment_amount` | decimal \| null | 实际支付金额，仅在支付到账后才有值 |
| `merchant_amount` | decimal (18 位小数) \| null | 扣除手续费后入账给商户的金额 |
| `amount_usd` | decimal (8 位小数) | 创建时的美元等值 |
| `exchange_rate` | decimal | 创建时使用的加密货币 / 法币汇率 |
| `sign` | string (hex) | 载荷的 HMAC-SHA256 签名 |

## 验证签名

验证 webhook 签名的步骤：

1. 从载荷中提取 `sign` 字段
2. 从对象中移除 `sign` 字段
3. 将剩余字段编码为 JSON
4. 将 JSON 进行 Base64 编码
5. 使用您的 API_KEY 对 Base64 字符串计算 HMAC-SHA256
6. 使用恒定时间比较函数（如 `hash_equals`）将计算结果与收到的 `sign` 比较

#### php

```php
<?php
function verifyWebhookSign(array $data, string $apiKey): bool {
    $receivedSign = $data['sign'] ?? '';
    unset($data['sign']);

    $json = json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
    $base64 = base64_encode($json);
    $calculated = hash_hmac('sha256', $base64, $apiKey);

    return hash_equals($calculated, $receivedSign);
}

$apiKey = 'YOUR_API_KEY';
$payload = json_decode(file_get_contents('php://input'), true);

if (!verifyWebhookSign($payload, $apiKey)) {
    http_response_code(401);
    exit;
}

switch ($payload['payment_status']) {
    case 'paid':
    case 'overpaid':
        // Credit the order — check idempotency by order_id first
        break;
    case 'underpaid_check':
    case 'underpaid':
    case 'cancel':
        break;
}

http_response_code(200);
```

#### js

```js
import crypto from "crypto";
import express from "express";

const app = express();
app.use(express.json());

function verifyWebhookSign(payload, apiKey) {
  const { sign, ...rest } = payload;
  const json = JSON.stringify(rest);
  const base64 = Buffer.from(json).toString("base64");
  const calculated = crypto
    .createHmac("sha256", apiKey)
    .update(base64)
    .digest("hex");
  return crypto.timingSafeEqual(
    Buffer.from(calculated),
    Buffer.from(sign || ""),
  );
}

app.post("/webhook", (req, res) => {
  if (!verifyWebhookSign(req.body, process.env.API_KEY)) {
    return res.sendStatus(401);
  }

  const { order_id, payment_status, txid } = req.body;

  if (payment_status === "paid" || payment_status === "overpaid") {
    // Credit the order — check idempotency by order_id first
  }

  res.sendStatus(200);
});
```

#### python

```python
import json
import hmac
import hashlib
import base64
from fastapi import FastAPI, Request, HTTPException

app = FastAPI()
API_KEY = "YOUR_API_KEY"

def verify_webhook_sign(payload: dict, api_key: str) -> bool:
    received = payload.pop("sign", "")
    body = json.dumps(payload, separators=(",", ":"), ensure_ascii=False)
    b64 = base64.b64encode(body.encode("utf-8")).decode()
    calculated = hmac.new(api_key.encode(), b64.encode(), hashlib.sha256).hexdigest()
    return hmac.compare_digest(calculated, received)

@app.post("/webhook")
async def webhook(request: Request):
    payload = await request.json()
    if not verify_webhook_sign(payload, API_KEY):
        raise HTTPException(401)

    if payload["payment_status"] in ("paid", "overpaid"):
        # Credit the order — check idempotency by order_id first
        pass

    return {"ok": True}
```

#### go

```go
package main

import (
    "bytes"
    "crypto/hmac"
    "crypto/sha256"
    "encoding/base64"
    "encoding/hex"
    "encoding/json"
    "io"
    "net/http"
)

func verifyWebhookSign(body []byte, apiKey string) (map[string]any, bool) {
    var payload map[string]any
    if err := json.Unmarshal(body, &payload); err != nil {
        return nil, false
    }
    received, _ := payload["sign"].(string)
    delete(payload, "sign")

    var buf bytes.Buffer
    enc := json.NewEncoder(&buf)
    enc.SetEscapeHTML(false)
    enc.Encode(payload)
    reencoded := bytes.TrimRight(buf.Bytes(), "\n")

    b64 := base64.StdEncoding.EncodeToString(reencoded)
    h := hmac.New(sha256.New, []byte(apiKey))
    h.Write([]byte(b64))
    calculated := hex.EncodeToString(h.Sum(nil))

    return payload, hmac.Equal([]byte(calculated), []byte(received))
}

func webhookHandler(w http.ResponseWriter, r *http.Request) {
    body, _ := io.ReadAll(r.Body)
    payload, ok := verifyWebhookSign(body, apiKey)
    if !ok {
        http.Error(w, "invalid signature", http.StatusUnauthorized)
        return
    }

    status, _ := payload["payment_status"].(string)
    if status == "paid" || status == "overpaid" {
        // Credit the order — check idempotency first
    }
    w.WriteHeader(http.StatusOK)
}
```

#### ruby

```ruby
require "json"
require "openssl"
require "base64"
require "sinatra"

API_KEY = "YOUR_API_KEY"

def verify_webhook_sign(payload, api_key)
  received = payload.delete("sign") || ""
  body = payload.to_json
  b64 = Base64.strict_encode64(body)
  calculated = OpenSSL::HMAC.hexdigest("SHA256", api_key, b64)
  OpenSSL.fixed_length_secure_compare(calculated, received)
end

post "/webhook" do
  payload = JSON.parse(request.body.read)
  halt 401 unless verify_webhook_sign(payload, API_KEY)

  if %w[paid overpaid].include?(payload["payment_status"])
    # Credit the order — check idempotency by order_id first
  end

  status 200
end
```

> **DANGER:** **为用户入账资金前务必校验签名。** 未签名或签名错误的 webhook 可能是伪造请求。

## 提现 webhook

当提现的 `status` 发生变化时，系统会向创建提现时传入的 `url_callback` 发送 `POST` webhook。如果未提供 `url_callback`，则不会为该提现发送任何 webhook。

> **WARNING:** 提现 webhook 必须使用 **Payout API key** 校验签名，**不是**普通 API key。签名算法与支付 webhook 完全相同（移除 `sign` → JSON → Base64 → HMAC-SHA256），仅签名密钥不同。

### 载荷

```json
{
  "uuid": "019dff1f-0dbd-7277-8d45-271e7775388f",
  "order_id": "4dfdcc84402b1185b71cbe399321533e",
  "status": "completed",
  "currency": "TRX",
  "network": "TRX-TRC20",
  "amount": "3.00",
  "merchant_amount": "3.00",
  "network_amount": "3.00",
  "amount_usd": "1.04",
  "to_address": "THauRv5tcucQRohXg8NiyGTk16DX1XQG5x",
  "memo": null,
  "txid": "9242e533703704ef3eaba840f70b4a26333e72c943377ee375fea17badb53def",
  "tx_explorer_url": "https://tronscan.org/#/transaction/9242e533703704ef3eaba840f70b4a26333e72c943377ee375fea17badb53def",
  "block_number": null,
  "error_type": null,
  "created_at": "2026-05-07T00:08:38+03:00",
  "updated_at": "2026-05-07T00:08:54+03:00",
  "from_currency": "USDT",
  "debited_amount": "1.050735",
  "debited_currency": "USDT",
  "sign": "925ad7bf3d6841864101f7cc2c7e30652e70a06cdb04dbe07a0129480000ce4a"
}
```

### 字段说明

| 字段 | 类型 | 说明 |
|------|------|------|
| `uuid` | string | 提现 UUID |
| `order_id` | string | 您提供的幂等性 / 业务 ID（如有） |
| `status` | string | `pending`、`completed`、`failed`、`cancelled`（详见 [References](/docs/references)） |
| `currency` | string | 提现币种 |
| `network` | string | 区块链网络 |
| `amount` | decimal | 提现金额（以 `currency` 为单位） |
| `merchant_amount` | decimal | 从商户余额扣除的金额 |
| `network_amount` | decimal | 实际链上发送的金额 |
| `amount_usd` | decimal | 提现时的美元等值 |
| `to_address` | string | 收款方区块链地址 |
| `memo` | string \| null | 备注 / destination tag（如使用） |
| `txid` | string \| null | 链上交易哈希，`completed` 时设置 |
| `tx_explorer_url` | string \| null | 区块链浏览器中的交易链接。若没有 `txid` 或该转账为内部 P2P，则为 `null`。 |
| `block_number` | integer \| null | 链上交易所在区块高度 |
| `error_type` | string \| null | `status = failed` 时的失败原因（例如 `aml_risk`，详见 [References](/docs/references)） |
| `created_at` | string (ISO 8601) | 提现创建时间 |
| `updated_at` | string (ISO 8601) | 状态最近一次变更时间 |
| `from_currency` | string | 使用自动兑换时，提现实际扣款的源余额（例如 `BTC` 提现的 `USDT`） |
| `debited_amount` | decimal | 从 `from_currency` 余额扣除的金额 |
| `debited_currency` | string | 扣款币种 |
| `sign` | string (hex) | 载荷的 HMAC-SHA256 签名，使用 **Payout API key** 计算 |

## 最佳实践

- **幂等性** — 务必检查支付是否已被处理（按 `order_id` 或 `uuid`）。webhook 可能被多次发送。
- **快速响应** — 尽快返回 HTTP 200。耗时操作转交后台队列。
- **重试** — 若系统未收到 HTTP 200，2 分钟后会重新发送，最多重试 5 次。
- **异步处理** — 异步处理 webhook 事件，避免阻塞响应。
- **安全** — 在信任载荷之前务必验证 `sign` 签名。

> **WARNING:** webhook 可能乱序到达。不要假设第一条 webhook 即是最终状态 — 如需确认，可通过 `/v1/payment/info`（或 `/v1/payout/status/{uuid}`）重新拉取。

## 交货加工合同

在 webhook 端点内使用以下顺序：

1. 读取请求正文而不记录机密或完整签名。
2. 确定它是支付/静态钱包事件还是支付事件，以便您选择正确的 API 密钥。
3. 删除 `sign`，重现记录的 JSON 字节，计算 HMAC-SHA256，并在恒定时间内进行比较。
4. 验证所需的标识符、十进制字符串和状态值。
5. 原子插入 inbox/idempotency 记录。如果已经存在，则返回 HTTP 200，而不会产生重复的副作用。
6. 提交订单/账本变更并将非关键电子邮件、分析或通知排队。
7. 快速返回 HTTP 200。

持有 idempotency 交易时请勿调用慢速第三方服务。提交之后但返回之前的 timeout 可能会导致 retry；副本必须遵守提交的 inbox 密钥并成为空操作。

### 推荐 idempotency 按键

| 活动 | 主要身份 | 注释 |
|-------|------------------|-------|
| 付款环节 | `uuid` + 状态/版本证据 | 同一个 invoice 可以发出多个合法的状态更改。 |
| 部分付款充值 | invoice `uuid` + `txid` | 多笔转账可属于同一欠缴 invoice。 |
| 静态钱包存款 | 网络+`txid` | `order_id` 被该钱包的每笔存款重复使用。 |
| 支出 | 支付 `uuid` + 状态 | 切勿从 webhook retry 逻辑创建第二个支出。 |

如果您的架构没有事件 ID，请将经过验证的 payload 哈希存储为附加审核证据，但不要用时间戳替换上面的业务标识。

## 订购和 reconciliation

至少传递一次，并且状态消息可能会出现争用。实施单调的业务规则而不是“最后一个请求获胜”：

- 切勿因为较旧的事件迟到而将已履行的订单移回 `check`；
- 允许 `underpaid_check` 接收额外的 txid，而无需重复之前的积分；
- 将 `paid` 和 `overpaid` 视为成功的 settlement 状态，同时保留其不同的金额；
- 保留`underpaid`作为最终的部分支付结果，除非权威API稍后报告另一个状态；
- 路由 `aml_lock` 进行审核，不要让通用的 retry 工作人员完成它；
- 当过渡不可能、缺少上下文或财务上不明确时，查询付款/支出信息。

即使 webhook 交付看起来正常，也运行计划的 reconciliation。将您的本地终端状态和贷记金额与 `/v1/payment/info`、`/v1/static-wallet/transactions` 或 `/v1/payout/status/{uuid}` 进行比较，并对差异发出警报，而不是默默地覆盖账本历史记录。

## Webhook 端点安全

- 需要 HTTPS 并保持回调可公开访问；私有/环回回调目标在付款创建期间被拒绝。
- 强制执行较小的请求正文限制和 JSON 内容类型。
- 在昂贵的工作之前进行速率限制，但为合法的突发和 retries 留出足够的空间。
- 切勿仅通过源 IP 授权 webhook。网络许可名单是纵深防御； HMAC 验证是强制性的。
- 编辑 `sign`、API 密钥、策略需要时的地址以及应用程序日志中的个人元数据。
- 在受控轮换窗口期间保持当前和明确计划的替换密钥可用；永远不要猜测哪个密钥签署了事件。
- 返回无效签名的通用错误主体，以便端点不会成为密钥或帐户预言机。