# Thông báo Webhook

> Nhận cập nhật trạng thái thanh toán và rút tiền theo thời gian thực qua webhook được ký HMAC.

Hệ thống 2328.io gửi webhook đến `url_callback` của bạn mỗi khi trạng thái thanh toán thay đổi. Đây là cách được khuyến nghị để nhận thông báo về các thanh toán thành công.

## Định dạng yêu cầu

- **Method:** `POST`
- **Content-Type:** `application/json`
- **Chữ ký:** trường `sign` trong thân yêu cầu

## Payload

Thân webhook tuân theo định dạng phản hồi `/v1/payment/info` và bổ sung `tx_explorer_url` cùng trường `sign` dùng để xác minh chữ ký.

### Thanh toán thành công

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

### Thanh toán bị hủy / thất bại

Khi thanh toán không ở trạng thái cuối `paid`, các trường `txid`, `payment_amount` và `merchant_amount` là `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"
}
```

### Tham chiếu trường

| Field | Type | Description |
|-------|------|-------------|
| `uuid` | string | UUID thanh toán |
| `order_id` | string | ID đơn hàng của bạn |
| `amount` | decimal (8 dp) | Số tiền pháp định theo `currency` |
| `currency` | string | Đồng tiền pháp định mà merchant yêu cầu |
| `url` | string | URL trang checkout được hosted |
| `expires_at` | string (ISO 8601) | Thời điểm phiên thanh toán hết hạn |
| `created_at` | string (ISO 8601) | Thời điểm phiên thanh toán được tạo |
| `payer_currency` | string | Crypto mà người trả đang dùng để thanh toán |
| `payer_amount` | decimal (8 dp) | Số crypto dự kiến |
| `network` | string | Mạng blockchain |
| `address` | string | Địa chỉ nạp tiền |
| `payment_status` | string | Một trong: `pending`, `check`, `paid`, `underpaid_check`, `underpaid`, `overpaid`, `cancel`, `aml_lock` (xem [References](/docs/references)) |
| `txid` | string \| null | Hash giao dịch blockchain, chỉ có sau khi thanh toán được xác nhận |
| `tx_explorer_url` | string \| null | URL giao dịch trên trình khám phá blockchain. Là `null` khi không có `txid` hoặc giao dịch là chuyển P2P nội bộ. |
| `payment_amount` | decimal \| null | Số tiền thực sự đã trả, chỉ có sau khi thanh toán |
| `merchant_amount` | decimal (18 dp) \| null | Số tiền được ghi có cho merchant sau khi trừ phí |
| `amount_usd` | decimal (8 dp) | Số tiền quy ra USD tại thời điểm tạo |
| `exchange_rate` | decimal | Tỷ giá Crypto / fiat đã sử dụng |
| `sign` | string (hex) | Chữ ký HMAC-SHA256 của payload |

## Xác minh chữ ký

Để xác minh chữ ký webhook:

1. Trích xuất trường `sign` từ payload
2. Loại bỏ trường `sign` khỏi object
3. Mã hóa các trường còn lại thành JSON
4. Mã hóa Base64 chuỗi JSON
5. Tính HMAC-SHA256 của chuỗi Base64 bằng API_KEY của bạn
6. So sánh chữ ký vừa tính với giá trị `sign` bằng so sánh thời gian không đổi

#### 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:** **Luôn xác minh chữ ký** trước khi ghi có bất kỳ khoản tiền nào cho người dùng. Một webhook không có chữ ký hoặc có chữ ký sai có thể là một yêu cầu giả mạo.

## Webhook rút tiền

Khi `status` của một rút tiền thay đổi, hệ thống gửi webhook `POST` đến URL `url_callback` được truyền khi tạo rút tiền. Nếu không cung cấp `url_callback`, sẽ không có webhook nào được gửi cho rút tiền đó.

> **WARNING:** Webhook rút tiền phải được xác minh bằng **Payout API key** — không phải API key thông thường. Thuật toán ký giống hệt webhook thanh toán (loại bỏ `sign`, mã hóa JSON, base64, HMAC-SHA256), chỉ khác key.

### Payload

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

### Tham chiếu trường

| Field | Type | Description |
|-------|------|-------------|
| `uuid` | string | UUID rút tiền |
| `order_id` | string | ID idempotency / tham chiếu của bạn, nếu bạn có cung cấp |
| `status` | string | `pending`, `completed`, `failed`, `cancelled` (xem [References](/docs/references)) |
| `currency` | string | Đồng tiền rút |
| `network` | string | Mạng blockchain |
| `amount` | decimal | Số tiền rút (theo `currency`) |
| `merchant_amount` | decimal | Số tiền bị trừ từ số dư merchant |
| `network_amount` | decimal | Số tiền thực sự được gửi trên chuỗi |
| `amount_usd` | decimal | Giá trị USD tại thời điểm rút tiền |
| `to_address` | string | Địa chỉ blockchain nhận tiền |
| `memo` | string \| null | Memo / destination tag, nếu có dùng |
| `txid` | string \| null | Hash giao dịch blockchain, được đặt khi `completed` |
| `tx_explorer_url` | string \| null | URL giao dịch trên trình khám phá blockchain. Là `null` khi không có `txid` hoặc giao dịch là chuyển P2P nội bộ. |
| `block_number` | integer \| null | Chiều cao block của giao dịch trên chuỗi |
| `error_type` | string \| null | Lý do khi `status = failed` (ví dụ `aml_risk`, xem [References](/docs/references)) |
| `created_at` | string (ISO 8601) | Thời điểm rút tiền được tạo |
| `updated_at` | string (ISO 8601) | Thời điểm trạng thái thay đổi gần nhất |
| `from_currency` | string | Số dư nguồn mà khoản rút tiền đã bị trừ khi sử dụng quy đổi tự động (ví dụ `USDT` cho khoản rút `BTC`) |
| `debited_amount` | decimal | Số tiền bị trừ từ số dư `from_currency` |
| `debited_currency` | string | Đồng tiền của khoản trừ |
| `sign` | string (hex) | Chữ ký HMAC-SHA256 của payload, ký bằng **Payout API key** |

## Best practices

- **Idempotency** — Luôn kiểm tra xem thanh toán đã được xử lý hay chưa (theo `order_id` hoặc `uuid`). Webhook có thể đến nhiều lần.
- **Phản hồi nhanh** — Trả về HTTP 200 càng nhanh càng tốt. Đẩy các công việc nặng sang một queue chạy nền.
- **Retry** — Nếu hệ thống không nhận được HTTP 200, webhook sẽ được gửi lại sau 2 phút. Tối đa 5 lần thử lại.
- **Xử lý bất đồng bộ** — Xử lý sự kiện webhook bất đồng bộ để tránh chặn phản hồi.
- **Bảo mật** — LUÔN xác minh chữ ký `sign` trước khi tin tưởng payload.

> **WARNING:** Webhook có thể đến không theo thứ tự. Đừng cho rằng webhook đầu tiên bạn nhận được là trạng thái cuối cùng — luôn lấy lại qua `/v1/payment/info` (hoặc `/v1/payout/status/{uuid}`) nếu cần chắc chắn.

## Hợp đồng giao hàng và xử lý

Sử dụng thứ tự sau bên trong endpoint webhook của bạn:

1. Đọc nội dung yêu cầu mà không ghi lại các bí mật hoặc chữ ký đầy đủ.
2. Xác định xem đó là sự kiện thanh toán/ví tĩnh hay sự kiện trả tiền để chọn đúng khóa API.
3. Xóa `sign`, tái tạo các byte JSON đã được ghi chép, tính HMAC-SHA256 và so sánh trong thời gian cố định.
4. Xác thực các định danh, chuỗi số thập phân và giá trị trạng thái cần thiết.
5. Chèn nguyên tử một bản ghi hộp thư/idempotency. Nếu đã tồn tại, trả về HTTP 200 mà không lặp lại các tác động phụ.
6. Cam kết thay đổi đơn hàng/sổ cái và đưa vào hàng đợi email, phân tích hoặc thông báo không quan trọng.
7. Trả về HTTP 200 nhanh chóng.

Không gọi các dịch vụ bên thứ ba chậm trong khi giữ giao dịch idempotency. Một thời gian chờ sau khi bạn cam kết nhưng trước khi trả về có thể gây ra việc thử lại; bản sao phải quan sát khóa hộp thư đã cam kết và trở thành thao tác không.

### Khóa idempotency được đề xuất

| Sự kiện | Danh tính chính | Ghi chú |
|-------|------------------|-------|
| Phiên thanh toán | `uuid` + bằng chứng trạng thái/phiên bản | Cùng một hóa đơn có thể phát ra nhiều thay đổi trạng thái hợp pháp. |
| Thanh toán bù một phần | hóa đơn `uuid` + `txid` | Có thể có nhiều chuyển khoản thuộc cùng một hóa đơn chưa thanh toán đầy đủ. |
| Nạp ví tĩnh | mạng + `txid` | `order_id` được sử dụng lại cho mỗi lần nạp vào ví đó. |
| Chi trả | chi trả `uuid` + trạng thái | Không bao giờ tạo chi trả thứ hai từ logic thử lại webhook. |

Nếu sơ đồ của bạn không có id sự kiện, lưu trữ băm payload đã xác minh như bằng chứng kiểm toán bổ sung, nhưng không thay thế các nhận dạng kinh doanh ở trên bằng dấu thời gian.

## Sắp xếp và đối chiếu

Giao hàng ít nhất một lần và các thông báo trạng thái có thể xảy ra đồng thời. Thực hiện các quy tắc nghiệp vụ theo thứ tự tăng dần thay vì “yêu cầu cuối cùng thắng”:

- không bao giờ chuyển một đơn hàng đã hoàn thành trở lại `check` vì một sự kiện cũ đến muộn;
- cho phép `underpaid_check` nhận thêm các txid mà không lặp lại các khoản tín dụng trước đó;
- xem `paid` và `overpaid` như các trạng thái thanh toán thành công, đồng thời giữ nguyên các số tiền khác nhau của chúng;
- giữ `underpaid` như kết quả thanh toán một phần cuối cùng trừ khi API có thẩm quyền sau đó báo cáo trạng thái khác;
- điều hướng `aml_lock` để xem xét và không để một nhân viên thử lại chung thực hiện nó;
- truy vấn thông tin thanh toán/chi trả bất cứ khi nào chuyển đổi không khả thi, thiếu bối cảnh, hoặc không rõ ràng về tài chính.

Chạy một cuộc đối chiếu theo lịch ngay cả khi việc gửi webhook có vẻ ổn định. So sánh trạng thái thiết bị đầu cuối cục bộ và số tiền đã ghi có với `/v1/payment/info`, `/v1/static-wallet/transactions`, hoặc `/v1/payout/status/{uuid}` và cảnh báo các khác biệt thay vì âm thầm ghi đè lịch sử sổ cái.

## Bảo mật điểm cuối webhook

- Yêu cầu HTTPS và giữ callback có thể truy cập công khai; các mục tiêu callback riêng/loopback sẽ bị từ chối trong quá trình tạo thanh toán.
- Thực thi giới hạn kích thước thân yêu cầu nhỏ và kiểu nội dung JSON.
- Giới hạn tốc độ trước khi thực hiện công việc tốn kém, nhưng để đủ khoảng trống cho các đợt bùng phát hợp lý và thử lại.
- Không bao giờ cho phép một webhook chỉ dựa trên địa chỉ IP nguồn. Danh sách trắng mạng là một biện pháp phòng thủ nhiều lớp; xác minh HMAC là bắt buộc.
- Làm mờ `sign`, khóa API, địa chỉ theo yêu cầu của chính sách, và dữ liệu cá nhân từ nhật ký ứng dụng.
- Giữ cả khóa hiện tại và khóa thay thế đã được lên lịch sẵn có sẵn trong thời gian luân phiên có kiểm soát; không bao giờ đoán khóa nào đã ký một sự kiện.
- Trả về một thông báo lỗi chung khi chữ ký không hợp lệ để điểm cuối không trở thành một kênh tra cứu khóa hoặc tài khoản.