Webhook
Webhook gửi các sự kiện Thanh toán của toàn doanh nghiệp tới một hoặc nhiều endpoint: mọi payment, mọi giao dịch ngân hàng, mọi thay đổi tài khoản ngân hàng và hóa đơn điện tử, ở cả hai môi trường. Dùng webhook cho giao dịch không qua đơn (transaction.received) và những gì IPN không bao quát. Trang này hướng dẫn tạo endpoint, xác minh lần gửi, và liệt kê mọi sự kiện Thanh toán.
Cách webhook hoạt động trên mọi sản phẩm Hạ Tầng được mô tả ở Webhook.
Tạo endpoint
Trên dashboard, mở Webhooks và thêm endpoint, hoặc gọi POST /webhooks. Một endpoint nhận cả hai môi trường; mỗi object thanh toán cho biết môi trường qua trường environment.
- cURL
- Node.js
- Python
- PHP
curl https://api.hatang.dev/webhooks \
-H "Authorization: Bearer $HATANG_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"endpoint": "https://shop.example.vn/hatang/webhooks",
"events": ["payment.succeeded", "payment.partially_paid", "payment.overpaid", "transaction.received", "invoice.issued"],
"description": "Backend cửa hàng"
}'
const { data: hook, error } = await hatang.webhooks.create({
endpoint: "https://shop.example.vn/hatang/webhooks",
events: ["payment.succeeded", "payment.partially_paid", "payment.overpaid", "transaction.received", "invoice.issued"],
description: "Backend cửa hàng",
});
// Lưu hook.signing_secret (whsec_…) trên server.
hook = requests.post("https://api.hatang.dev/webhooks", headers=HEADERS, json={
"endpoint": "https://shop.example.vn/hatang/webhooks",
"events": ["payment.succeeded", "payment.partially_paid", "payment.overpaid", "transaction.received", "invoice.issued"],
"description": "Backend cửa hàng",
}, timeout=10).json()
$hook = json_decode($client->post('/webhooks', ['json' => [
'endpoint' => 'https://shop.example.vn/hatang/webhooks',
'events' => ['payment.succeeded', 'payment.partially_paid', 'payment.overpaid', 'transaction.received', 'invoice.issued'],
'description' => 'Backend cửa hàng',
]])->getBody(), true);
Phản hồi (201) có signing_secret (whsec_…). Secret chỉ hiển thị khi tạo endpoint hoặc khi xoay secret, nên hãy lưu ngay trên server.
Xác minh lần gửi
Lần gửi theo chuẩn Standard Webhooks / Svix. Mỗi lần gửi có svix-id, svix-timestamp và svix-signature (cùng giá trị đó ở webhook-id, webhook-timestamp và webhook-signature).
- Node.js
- Python
- PHP
import { constructWebhookEvent, HatangSignatureError } from "@hatang/sdk";
app.post("/hatang/webhooks", express.raw({ type: "application/json" }), async (req, res) => {
try {
const event = await constructWebhookEvent(req.body, req.headers, process.env.HATANG_WEBHOOK_SECRET!); // whsec_…
if (await alreadyHandled(req.get("svix-id"))) return res.sendStatus(200);
await queue.add("hatang-webhook", event); // { type, created_at, data }
res.sendStatus(200);
} catch (err) {
if (err instanceof HatangSignatureError) return res.sendStatus(401);
throw err;
}
});
Thư viện svix cũng dùng được: new Webhook(secret).verify(rawBody, headers).
import os
from flask import Flask, request, abort
from hatang import HatangSignatureError, construct_webhook_event
SECRET = os.environ["HATANG_WEBHOOK_SECRET"] # whsec_...
app = Flask(__name__)
@app.post("/hatang/webhooks")
def hatang_webhooks():
try:
event = construct_webhook_event(request.get_data(), request.headers, SECRET) # { type, created_at, data }
except HatangSignatureError:
abort(401)
# chống trùng theo header svix-id, rồi đưa việc vào queue
return "", 200
<?php
$secret = base64_decode(substr(getenv('HATANG_WEBHOOK_SECRET'), strlen('whsec_')));
$raw = file_get_contents('php://input');
$id = $_SERVER['HTTP_SVIX_ID'] ?? '';
$ts = $_SERVER['HTTP_SVIX_TIMESTAMP'] ?? '0';
if (abs(time() - (int) $ts) > 300) { http_response_code(401); exit; }
$expected = base64_encode(hash_hmac('sha256', "$id.$ts.$raw", $secret, true));
$ok = false;
foreach (explode(' ', $_SERVER['HTTP_SVIX_SIGNATURE'] ?? '') as $part) {
[$version, $sig] = array_pad(explode(',', $part, 2), 2, '');
if ($version === 'v1' && hash_equals($expected, $sig)) { $ok = true; }
}
if (!$ok) { http_response_code(401); exit; }
$event = json_decode($raw, true); // ['type' => ..., 'created_at' => ..., 'data' => ...]
// chống trùng theo $id, rồi đưa việc vào queue
http_response_code(200);
Tự xác minh: base64-decode phần sau whsec_ của secret, tính HMAC-SHA256 của {svix-id}.{svix-timestamp}.{raw body}, base64-encode kết quả, rồi so với từng v1,<chữ ký> trong svix-signature (cách nhau bởi khoảng trắng). Từ chối timestamp lệch quá 5 phút so với hiện tại.
POST /webhooks/{id}/rotate (hoặc dashboard) tạo secret mới và trả về trong trường secret. Trong 24 giờ, lần gửi được ký bằng cả secret mới và secret cũ, nên svix-signature có hai chữ ký và chữ ký nào cũng xác minh được. Hãy triển khai secret mới trong khoảng thời gian đó.
Cách gửi
| Body | { "type": "...", "created_at": "...", "data": { ... } } |
| Thành công | Bất kỳ mã 2xx nào trong 15 giây. Redirect không được theo |
| Thử lại | Sau 5 giây, 5 phút, 30 phút, 2 giờ, 5 giờ, 10 giờ và 10 giờ: 8 lần |
| Chống trùng | svix-id giữ nguyên qua mọi lần thử của một lần gửi |
| Nhật ký | GET /webhooks/{id}/deliveries, và Webhooks trên dashboard |
Sự kiện Thanh toán
Payment
| Sự kiện | Khi nào | data |
|---|---|---|
payment.created | Tạo payment | Payment |
payment.succeeded | Đã nhận đủ tiền | Payment, kèm transaction |
payment.partially_paid | Nhận thiếu | Payment, kèm transaction |
payment.overpaid | Nhận thừa | Payment, kèm transaction |
payment.expired | Quá expires_at mà chưa có tiền | Payment |
payment.canceled | Bạn đã hủy | Payment |
Khi payment được ghi nhận thủ công (mark-paid), data có manual: { by, note, amount } thay cho transaction.
Giao dịch và tài khoản ngân hàng
| Sự kiện | Khi nào | data |
|---|---|---|
transaction.received | Có giao dịch mới trên tài khoản đã liên kết, theo bộ lọc của tài khoản | Giao dịch, kèm merchant_code |
bank_connection.activated | Liên kết tài khoản thành công | Kết nối ngân hàng |
bank_connection.revoked | Tài khoản bị hủy liên kết | Kết nối ngân hàng |
bank_connection.needs_reauth | Ngân hàng yêu cầu liên kết lại | Kết nối ngân hàng |
Hóa đơn điện tử
| Sự kiện | Khi nào |
|---|---|
invoice.draft_created / invoice.draft_updated / invoice.draft_deleted | Nháp thay đổi |
invoice.issued | Đã ký, có số hóa đơn |
invoice.tax_accepted / invoice.tax_rejected | Phản hồi của cơ quan thuế |
invoice.canceled / invoice.replaced / invoice.adjusted | Hóa đơn bị hủy, thay thế hoặc điều chỉnh |
invoice.failed | Nhà cung cấp từ chối một bước |
invoice.conflict | Nháp bị sửa ở cả hai phía; giải quyết bằng POST /einvoices/{id}/resolve |
data là hóa đơn (object: "einvoice"), kèm payment_id hoặc transaction_id nếu có liên kết. Sự kiện phát sinh trên trang nhà cung cấp có source: "provider".
Ví dụ: payment thanh toán thành công
{
"type": "payment.succeeded",
"created_at": "2026-09-19T09:34:12.950Z",
"data": {
"object": "payment",
"id": "pay_01a0b904297873e8b3524a26fdca0ab5",
"environment": "production",
"status": "paid",
"amount": 150000,
"paid_amount": 150000,
"code": "HTBF7RDGVQ",
"metadata": { "order_id": "1001" },
"invoice": null,
"paid_at": "2026-09-19T09:34:12.940Z",
"transaction": {
"object": "transaction",
"id": "txn_01a0b90429dc79c7a5689c7a0c485a01",
"amount": 150000,
"description": "CK HTBF7RDGVQ thanh toan",
"reference": "FT1789810452930810",
"payment_id": "pay_01a0b904297873e8b3524a26fdca0ab5",
"match_status": "matched"
}
}
}
Object payment và giao dịch ở đây đã được rút gọn; webhook gửi đầy đủ mọi trường.
Bước tiếp theo
- Nhận kết quả: so sánh webhook với IPN và polling.
- Nhận giao dịch không cần tạo đơn:
transaction.received. - Tham chiếu webhook.