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
signtrong thân yêu cầu
Payload
Thân webhook giống hệt phản hồi /v1/payment/info, kèm theo một trường sign dùng cho việc xác minh chữ ký.
Thanh toán thành công
{
"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",
"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:
{
"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,
"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) |
txid | string | null | Hash giao dịch blockchain, chỉ có sau khi thanh toán được xác nhận |
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:
- Trích xuất trường
signtừ payload - Loại bỏ trường
signkhỏi object - Mã hóa các trường còn lại thành JSON
- Mã hóa Base64 chuỗi JSON
- Tính HMAC-SHA256 của chuỗi Base64 bằng API_KEY của bạn
- So sánh chữ ký vừa tính với giá trị
signbằng so sánh thời gian không đổi
<?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);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);
});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}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)
}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
endLuô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 đó.
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
{
"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",
"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) |
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 |
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) |
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_idhoặcuuid). 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ý
signtrước khi tin tưởng payload.
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.