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

# 回调通知

支付或提现到达终态时，JGoPay 会向您的 `callback_url` 发送回调通知。

## 投递机制

* 回调以 `POST` 请求发送到您的 `callback_url`
* 支付请求中未提供 `callback_url` 时，使用商户的默认回调地址
* 如果您的服务器未返回 2xx 状态码，最多重试 **3 次**，采用指数退避

### 重试计划

| 次数    | 延迟     |
| ----- | ------ |
| 第 1 次 | 立即     |
| 第 2 次 | 约 1 秒后 |
| 第 3 次 | 约 5 秒后 |

## 回调内容

### 充值回调

```json
{
  "webhook_id": "d290f1ee-6c54-4b01-90e6-d701748f0851",
  "payment_id": "PAY_MYR12345",
  "reference_id": "ORDER20260325001",
  "amount": 100.00,
  "currency": "MYR",
  "status": "SUCCESS",
  "transaction_type": "DEPOSIT",
  "gateway_reference": "TXN_20260325_ABC123",
  "completed_at": "2026-03-25T12:15:30Z",
  "created_at": "2026-03-25T12:03:00Z",
  "timestamp": "2026-03-25T12:15:31.000Z",
  "signature": "a1b2c3d4e5f6g7h8..."
}
```

### 提现回调

```json
{
  "webhook_id": "f47ac10b-58cc-4372-a567-0e02b2c3d479",
  "payment_id": "PAY_WD1A2B3C4D",
  "reference_id": "WD20260325001",
  "amount": 500.00,
  "currency": "MYR",
  "status": "SUCCESS",
  "transaction_type": "WITHDRAWAL",
  "gateway_reference": "GW_WD_789012",
  "bank_code": "MY_MBB",
  "account_number": "****7890",
  "completed_at": "2026-03-25T11:00:00Z",
  "created_at": "2026-03-25T10:30:00Z",
  "timestamp": "2026-03-25T11:00:01.000Z",
  "signature": "x9y8z7w6v5u4..."
}
```

### 字段说明

| 字段                  | 类型     | 说明                              |
| ------------------- | ------ | ------------------------------- |
| `webhook_id`        | string | 本次投递的唯一标识（UUID v4），用于去重         |
| `payment_id`        | string | JGoPay 支付标识                     |
| `reference_id`      | string | 您的原始订单号                         |
| `amount`            | number | 金额                              |
| `currency`          | string | 货币代码（`MYR`）                     |
| `status`            | string | 终态（见下文）                         |
| `transaction_type`  | string | `DEPOSIT`（充值）或 `WITHDRAWAL`（提现） |
| `gateway_reference` | string | 网关的交易流水号（可能为 `null`）            |
| `bank_code`         | string | 银行代码（仅提现）                       |
| `account_number`    | string | 脱敏后的账号（仅提现）                     |
| `completed_at`      | string | 完成时间，ISO 8601（未完成为 `null`）      |
| `created_at`        | string | 创建时间，ISO 8601                   |
| `timestamp`         | string | 回调生成时间，ISO 8601 UTC             |
| `signature`         | string | 用于验签的 HMAC-SHA256 签名            |

## HTTP 请求头

每个回调请求包含以下请求头：

| 请求头                   | 说明                                   |
| --------------------- | ------------------------------------ |
| `Content-Type`        | `application/json`                   |
| `X-Webhook-Signature` | HMAC-SHA256 签名（与请求体中 `signature` 一致） |
| `X-Webhook-ID`        | 与请求体中 `webhook_id` 一致 — 用于去重         |
| `X-Webhook-Timestamp` | 与请求体中 `timestamp` 一致 — 用于防重放         |
| `X-Payment-ID`        | 支付标识                                 |

## 终态列表

仅在到达终态时发送回调：

| 状态          | 说明     | 您的处理      |
| ----------- | ------ | --------- |
| `SUCCESS`   | 支付完成   | 发货/完成订单   |
| `FAILED`    | 支付被拒绝  | 展示错误，允许重试 |
| `EXPIRED`   | 支付会话超时 | 展示已过期提示   |
| `CANCELLED` | 支付被取消  | 允许重试      |
| `REFUNDED`  | 支付已退款  | 处理退款      |

## 确认回调

返回任意 **HTTP 2xx** 状态码（200-299）即视为接收成功。响应体内容不做校验。

```
HTTP/1.1 200 OK
```

如果我们未收到 2xx 响应，将按上述重试计划重试回调。

## 幂等性

回调可能被多次投递（网络重试、边缘情况）。您的处理程序必须幂等 — 同一回调处理两次不能导致订单被履约两次。

去重分两个层面：

* **投递去重** — 用 `webhook_id` 识别同一条回调的重复投递。
* **业务幂等** — 用 `payment_id` 确保同一笔支付永远不会被履约两次，即使收到的是不同的回调事件。这是关键校验。

### 示例（Node.js）

```javascript
app.post('/webhook', async (req, res) => {
  const payload = req.body;

  // 1. 先验签（见下方「签名验证」）
  if (!verifyWebhookSignature(payload, process.env.API_KEY)) {
    return res.status(401).send('Invalid signature');
  }

  // 2. 检查该支付是否已处理
  const existing = await db.query(
    'SELECT id FROM processed_payments WHERE payment_id = $1',
    [payload.payment_id]
  );

  if (existing.rows.length > 0) {
    // 已处理 — 确认收到但跳过履约
    return res.status(200).send('OK');
  }

  // 3. 履约订单
  await db.query('BEGIN');
  try {
    await db.query(
      'INSERT INTO processed_payments (payment_id, status, processed_at) VALUES ($1, $2, NOW())',
      [payload.payment_id, payload.status]
    );
    await fulfillOrder(payload.reference_id, payload.status);
    await db.query('COMMIT');
  } catch (err) {
    await db.query('ROLLBACK');
    return res.status(500).send('Processing failed');
  }

  return res.status(200).send('OK');
});
```

### 规则

* 履约前务必检查 `payment_id` — 即使同一笔支付收到多条不同回调，也能防止重复处理。
* 将 `payment_id` 的落库和履约放在同一个数据库事务中，避免竞态。
* `reference_id` 是您的订单号 — 它用于创建支付时的*请求*去重，不用于回调去重。

## 签名验证

每个回调都带有 `signature` 字段用于验证真实性。签名是 HMAC-SHA256 哈希，签名密钥由您的 API 密钥派生。

### 工作原理

签名密钥由您的**原始 API 密钥**（创建商户时保存的那个）经两步派生：

1. 对原始 API 密钥做 SHA-256 **哈希**
2. 用 HKDF 从该哈希**派生**签名密钥

```
原始 API 密钥（创建商户时保存）
    ↓ SHA-256
API 密钥哈希
    ↓ HKDF(sha256, salt='', info='webhook-signature', length=32)
签名密钥（用于 HMAC-SHA256）
```

| 参数         | 值                     |
| ---------- | --------------------- |
| **哈希算法**   | SHA-256               |
| **输入密钥材料** | 原始 API 密钥的 SHA-256 哈希 |
| **Salt**   | 空                     |
| **Info**   | `webhook-signature`   |
| **输出长度**   | 32 字节                 |

### 签名原文

以下字段用 `|` 拼接：

```
webhook_id|payment_id|reference_id|amount|currency|status|timestamp|completed_at
```

* `amount` 转为字符串（例如 `100`）
* `completed_at` 为 null 时用空字符串

### 验签示例

以下示例均以您的**原始 API 密钥**为输入，在内部完成哈希计算。

**Node.js：**

```javascript
const crypto = require('crypto');

function verifyWebhookSignature(payload, apiKey) {
  const dataString = [
    payload.webhook_id,
    payload.payment_id,
    payload.reference_id,
    payload.amount.toString(),
    payload.currency,
    payload.status,
    payload.timestamp,
    payload.completed_at || '',
  ].join('|');

  // 第 1 步：对原始 API 密钥做 SHA-256 哈希
  const apiKeyHash = crypto.createHash('sha256').update(apiKey).digest();

  // 第 2 步：用 HKDF 派生签名密钥
  const signingKey = crypto.hkdfSync('sha256', apiKeyHash, '', 'webhook-signature', 32);

  // 第 3 步：计算期望签名
  const expectedSignature = crypto
    .createHmac('sha256', Buffer.from(signingKey))
    .update(dataString)
    .digest('hex');

  // 使用恒定时间比较，防止时序攻击
  const sigBuf = Buffer.from(payload.signature, 'utf8');
  const expBuf = Buffer.from(expectedSignature, 'utf8');
  if (sigBuf.length !== expBuf.length) return false;
  return crypto.timingSafeEqual(sigBuf, expBuf);
}

// 用法：
// const apiKey = 'KhMc4WlA161g1rUNOJzbXkf65zV1gNDGzL4KSXASvwU='; // 您的原始 API 密钥
// const isValid = verifyWebhookSignature(webhookPayload, apiKey);
```

**Python**（需要 `cryptography` 包：`pip install cryptography`）：

```python
import hmac, hashlib
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
from cryptography.hazmat.primitives import hashes

def verify_webhook(payload, api_key):
    data_string = '|'.join([
        payload['webhook_id'],
        payload['payment_id'],
        payload['reference_id'],
        str(payload['amount']),
        payload['currency'],
        payload['status'],
        payload['timestamp'],
        payload['completed_at'] or '',
    ])

    # 第 1 步：对原始 API 密钥做 SHA-256 哈希
    api_key_hash = hashlib.sha256(api_key.encode()).digest()

    # 第 2 步：用 HKDF 派生签名密钥
    signing_key = HKDF(
        algorithm=hashes.SHA256(),
        length=32,
        salt=b'',
        info=b'webhook-signature',
    ).derive(api_key_hash)

    # 第 3 步：计算并比较
    expected = hmac.new(signing_key, data_string.encode(), hashlib.sha256).hexdigest()
    return hmac.compare_digest(payload['signature'], expected)
```

**PHP：**

```php
function verifyWebhook(array $payload, string $apiKey): bool {
    $dataString = implode('|', [
        $payload['webhook_id'],
        $payload['payment_id'],
        $payload['reference_id'],
        (string) $payload['amount'],
        $payload['currency'],
        $payload['status'],
        $payload['timestamp'],
        $payload['completed_at'] ?? '',
    ]);

    // 第 1 步：对原始 API 密钥做 SHA-256 哈希
    $apiKeyHash = hash('sha256', $apiKey, true);

    // 第 2 步：用 HKDF 派生签名密钥
    $signingKey = hash_hkdf('sha256', $apiKeyHash, 32, 'webhook-signature', '');

    // 第 3 步：计算并比较
    $expected = hash_hmac('sha256', $dataString, $signingKey);
    return hash_equals($expected, $payload['signature']);
}
```

## 防重放

每个回调都包含 `webhook_id`（每次投递唯一）和 `timestamp`（生成时间）。用它们防止重放攻击：

1. **去重：** 存储已处理的 `webhook_id`。收到已处理过的 `webhook_id` 时直接跳过。
2. **时效校验：** 拒绝 `timestamp` 距您服务器当前时间超过 5 分钟的回调，防止攻击者重放截获的回调。

```javascript
// 示例：拒绝超过 5 分钟的回调
const webhookTime = new Date(payload.timestamp).getTime();
const now = Date.now();
if (now - webhookTime > 5 * 60 * 1000) {
  console.log('Stale webhook rejected');
  return;
}
```

## 最佳实践

* **务必先验签**再处理回调
* **快速响应** — 先返回 200，再做重活；必要时将任务放入队列
* **处理重复投递** — 用 `webhook_id` 去重，存储已处理的 ID 并跳过重复
* **不要只依赖回调** — 关键流程用状态查询接口做兜底


---

# 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.jgopay.com/jian-ti-zhong-wen/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.
