Webhook
Webhook báo cho server của bạn khi có chuyện xảy ra trong Hạ Tầng: một email đã tới người nhận, khách gửi tin nhắn chat, một khoản chuyển khoản thanh toán cho đơn hàng. Bạn đăng ký một endpoint HTTPS và chọn các sự kiện muốn nhận. Hạ Tầng gửi mỗi sự kiện bằng một request POST có chữ ký, tự thử lại khi endpoint lỗi, và lưu nhật ký mọi lượt gửi.
Một endpoint nhận được sự kiện của mọi module. Chữ ký theo chuẩn Svix và Standard Webhooks, nên các thư viện có sẵn đều dùng được.
Trước khi bắt đầu
- Bạn cần là chủ sở hữu, quản trị viên, hoặc thành viên có quyền Developer (Nhân viên và phân quyền).
- Endpoint phải truy cập được từ Internet qua HTTPS. Khi phát triển trên máy, xem Thử trên máy.
1. Tạo endpoint
- Trong dashboard, mở Cài đặt → Webhooks (cũng có ở Email → Webhooks).
- Bấm Thêm endpoint, nhập URL, ví dụ
https://shop.vn/webhooks/hatang, và tích các sự kiện cần gửi. - Sao chép secret ký. Secret bắt đầu bằng
whsec_và chỉ hiện một lần.
Hoặc tạo bằng API, với key có một scope write bất kỳ:
curl -X POST https://api.hatang.dev/webhooks \
-H "Authorization: Bearer $HATANG_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"endpoint": "https://shop.vn/webhooks/hatang",
"events": ["email.delivered", "email.bounced", "chat.message.created", "payment.succeeded"],
"description": "Backend production"
}'
{
"object": "webhook",
"id": "wh_123...",
"endpoint": "https://shop.vn/webhooks/hatang",
"events": ["email.delivered", "email.bounced", "chat.message.created", "payment.succeeded"],
"status": "enabled",
"description": "Backend production",
"created_at": "2026-10-23T09:34:12.952Z",
"signing_secret": "whsec_123..."
}
Lưu secret vào biến môi trường, ví dụ HATANG_WEBHOOK_SECRET.
URL của endpoint phải dùng https và trỏ tới một host công khai. URL chứa tên đăng nhập hay mật khẩu, host localhost, .local, .internal và địa chỉ IP nội bộ bị từ chối với 422 validation_error. Muốn tạm dừng nhận, đặt status của endpoint thành disabled (PATCH /webhooks/{id}).
2. Chọn sự kiện
Mọi lượt gửi có cùng một dạng: type của sự kiện, thời điểm xảy ra, và đối tượng trong data.
{
"type": "email.delivered",
"created_at": "2026-10-23T09:34:15.120Z",
"data": {
"email_id": "01a0b904-2978-73e8-b352-4a26fdca0ab5",
"subject": "Đơn hàng của bạn đã được xác nhận",
"created_at": "2026-10-23T09:34:12.952Z"
}
}
| Module | Sự kiện |
|---|---|
email.sent, email.delivered, email.delivery_delayed, email.bounced, email.complained, email.opened, email.clicked, email.failed, email.received, contact.created, contact.updated, contact.deleted, domain.created, domain.updated, domain.deleted | |
| Hỗ trợ | chat.conversation.created, .assigned, .resolved, .closed, .reopened, .handoff, .tagged; chat.message.created; chat.visitor.identified; chat.ticket.created, .updated, .resolved, .breached; chat.rating.created; chat.flow.webhook; chat.campaign.lead |
| Sản phẩm | chat.order.created, chat.order.updated, chat.order.paid |
| CRM | chat.zns.sent, chat.zns.failed |
| Thanh toán | payment.created, .succeeded, .partially_paid, .overpaid, .expired, .canceled; transaction.received; bank_connection.activated, .revoked, .needs_reauth; invoice.* (sự kiện nháp, phát hành, cơ quan thuế và điều chỉnh) |
Tham chiếu sự kiện webhook mô tả từng sự kiện và data của nó.
Một endpoint nhận sự kiện Thanh toán của cả môi trường development lẫn production. Mọi đối tượng Thanh toán có trường environment: kiểm tra trước khi giao hàng. Thanh toán còn có thể gọi ipn_url riêng của từng thanh toán, ký theo cách khác; xem Xác minh IPN.
3. Xác minh chữ ký
Mỗi lượt gửi là một request POST với body JSON và các header sau:
POST /webhooks/hatang HTTP/1.1
content-type: application/json
user-agent: hatang-webhooks/1.0
svix-id: msg_01a0b90429d87cf89e37fc2f66040ec5
svix-timestamp: 1761212055
svix-signature: v1,Kk3W5m0rB1o8V0qk4bq2Jb0nU4y1sOq4uQ0b6rjB0yE=
webhook-id: msg_01a0b90429d87cf89e37fc2f66040ec5
webhook-timestamp: 1761212055
webhook-signature: v1,Kk3W5m0rB1o8V0qk4bq2Jb0nU4y1sOq4uQ0b6rjB0yE=
| Header | Ý nghĩa |
|---|---|
svix-id | Id duy nhất của sự kiện. Giữ nguyên qua các lần thử lại và gửi lại. |
svix-timestamp | Thời điểm ký lượt gửi này, tính bằng giây Unix |
svix-signature | Một hoặc nhiều chữ ký cách nhau bằng dấu cách, mỗi chữ ký dạng v1,<base64> |
webhook-id, webhook-timestamp, webhook-signature | Cùng giá trị, dưới tên của Standard Webhooks |
Để xác minh một lượt gửi:
- Lấy body gốc đúng như nhận được, trước khi parse JSON.
- Ghép nội dung cần ký
<svix-id>.<svix-timestamp>.<body gốc>. - Bỏ tiền tố
whsec_của secret và giải mã base64 phần còn lại: đó là khoá HMAC. - Tính HMAC-SHA256 của nội dung cần ký và mã hoá base64.
- Chấp nhận nếu kết quả bằng một chữ ký
v1bất kỳ trongsvix-signature, so sánh bằng hàm thời gian hằng. - Từ chối nếu
svix-timestamplệch quá 300 giây so với đồng hồ của bạn, để chặn việc phát lại request cũ bị bắt được.
- Node.js
- Python
- PHP
- Go
@hatang/sdk làm đủ sáu bước. constructWebhookEvent trả về sự kiện đã parse hoặc throw HatangSignatureError; verifyWebhook trả true hoặc false. Cả hai nhận header svix-* hoặc webhook-*, độ lệch mặc định 300 giây.
import express from "express";
import { constructWebhookEvent, HatangSignatureError } from "@hatang/sdk";
const app = express();
// express.raw giữ body dạng Buffer. Đừng dùng express.json() cho route này.
app.post("/webhooks/hatang", express.raw({ type: "application/json" }), async (req, res) => {
let event;
try {
event = await constructWebhookEvent(req.body, req.headers, process.env.HATANG_WEBHOOK_SECRET);
} catch (err) {
if (err instanceof HatangSignatureError) return res.status(400).send("Invalid signature");
throw err;
}
await queue.add("hatang-event", { id: req.get("svix-id"), event }); // xử lý ở nền
res.sendStatus(200);
});
Trong route handler của Next.js hay bất kỳ runtime nào có Fetch API:
import { verifyWebhook } from "@hatang/sdk";
export async function POST(request) {
const raw = await request.text();
if (!(await verifyWebhook(raw, request.headers, process.env.HATANG_WEBHOOK_SECRET))) {
return new Response("Invalid signature", { status: 400 });
}
const event = JSON.parse(raw);
// ...
return new Response(null, { status: 200 });
}
Gói 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("/webhooks/hatang")
def hatang_webhook():
try:
# byte gốc, trước khi parse; kiểm tra chữ ký và khung thời gian 5 phút
event = construct_webhook_event(request.get_data(), request.headers, SECRET)
except HatangSignatureError:
abort(400)
enqueue(request.headers["svix-id"], event) # xử lý ở nền
return "", 200
<?php
function hatang_verify_webhook(string $rawBody, array $headers, string $secret, int $tolerance = 300): bool
{
$h = array_change_key_case($headers, CASE_LOWER);
$id = $h['svix-id'] ?? $h['webhook-id'] ?? null;
$timestamp = $h['svix-timestamp'] ?? $h['webhook-timestamp'] ?? null;
$signatures = $h['svix-signature'] ?? $h['webhook-signature'] ?? null;
if (!$id || !$timestamp || !$signatures || !ctype_digit($timestamp)) {
return false;
}
if (abs(time() - (int) $timestamp) > $tolerance) {
return false;
}
$key = base64_decode(str_starts_with($secret, 'whsec_') ? substr($secret, 6) : $secret);
$expected = base64_encode(hash_hmac('sha256', "{$id}.{$timestamp}.{$rawBody}", $key, true));
foreach (explode(' ', $signatures) as $part) {
[$version, $sig] = array_pad(explode(',', $part, 2), 2, '');
if ($version === 'v1' && hash_equals($expected, $sig)) {
return true;
}
}
return false;
}
$raw = file_get_contents('php://input');
if (!hatang_verify_webhook($raw, getallheaders(), getenv('HATANG_WEBHOOK_SECRET'))) {
http_response_code(400);
exit;
}
$event = json_decode($raw, true);
// Đưa $event vào hàng đợi rồi trả lời ngay.
http_response_code(200);
package webhooks
import (
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"io"
"net/http"
"os"
"strconv"
"strings"
"time"
)
func header(h http.Header, name string) string {
if v := h.Get("svix-" + name); v != "" {
return v
}
return h.Get("webhook-" + name)
}
func VerifyWebhook(body []byte, h http.Header, secret string) bool {
id, ts, sigs := header(h, "id"), header(h, "timestamp"), header(h, "signature")
if id == "" || ts == "" || sigs == "" {
return false
}
sec, err := strconv.ParseInt(ts, 10, 64)
if err != nil {
return false
}
if age := time.Since(time.Unix(sec, 0)); age > 300*time.Second || age < -300*time.Second {
return false
}
key, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(secret, "whsec_"))
if err != nil {
return false
}
mac := hmac.New(sha256.New, key)
mac.Write([]byte(id + "." + ts + "."))
mac.Write(body)
expected := base64.StdEncoding.EncodeToString(mac.Sum(nil))
for _, part := range strings.Split(sigs, " ") {
version, sig, ok := strings.Cut(part, ",")
if ok && version == "v1" && hmac.Equal([]byte(sig), []byte(expected)) {
return true
}
}
return false
}
func Handler(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(r.Body)
if err != nil || !VerifyWebhook(body, r.Header, os.Getenv("HATANG_WEBHOOK_SECRET")) {
http.Error(w, "invalid signature", http.StatusBadRequest)
return
}
// json.Unmarshal(body, &event), đưa vào hàng đợi, rồi trả lời.
w.WriteHeader(http.StatusOK)
}
Luôn xác minh trên đúng các byte nhận được. Parse JSON rồi serialize lại sẽ đổi khoảng trắng hoặc thứ tự khoá và làm sai chữ ký. Với framework tự parse JSON, hãy tắt tính năng đó cho route webhook.
4. Trả lời nhanh
Trả về một status 2xx bất kỳ trong vòng 15 giây. Mọi trường hợp khác đều tính là lỗi: status khác, quá thời gian, lỗi kết nối, hoặc chuyển hướng (Hạ Tầng không đi theo redirect). 4 KB đầu của body bạn trả về được lưu cùng lượt gửi, tiện khi gỡ lỗi.
Làm phần việc chính ở tác vụ nền: xác minh, lưu hoặc đưa sự kiện vào hàng đợi, trả 200, rồi mới xử lý.
Thử lại
Khi một lượt gửi thất bại, Hạ Tầng thử lại theo lịch sau, tính từ lần thử trước:
| Lần thử | Chờ |
|---|---|
| 1 | Ngay lập tức |
| 2 | 5 giây |
| 3 | 5 phút |
| 4 | 30 phút |
| 5 | 2 giờ |
| 6 | 5 giờ |
| 7 | 10 giờ |
| 8 | 10 giờ |
Sau lần thử thứ tám thất bại, khoảng 27,5 giờ sau sự kiện, lượt gửi bị đánh dấu là đã hết lượt và không thử lại nữa. Bạn vẫn có thể gửi lại. Các lượt gửi đang chờ của endpoint bị tắt hoặc bị xoá sẽ bị bỏ.
Gửi lại một lượt
Trang của endpoint trong dashboard liệt kê các Lượt gửi gần đây kèm sự kiện, số lần thử, phản hồi của endpoint và thời điểm Thử lại lúc. Bấm Gửi lại để gửi một lượt lần nữa, ví dụ sau khi sửa lỗi trong handler.
Bằng API:
# 50 lượt gửi gần nhất của một endpoint
curl https://api.hatang.dev/webhooks/wh_123.../deliveries \
-H "Authorization: Bearer $HATANG_API_KEY"
# Gửi lại một lượt
curl -X POST https://api.hatang.dev/webhooks/deliveries/whd_123.../replay \
-H "Authorization: Bearer $HATANG_API_KEY"
Lượt gửi lại đi theo cùng lịch thử lại và mang cùng svix-id với bản gốc, nên handler lọc trùng theo id này sẽ không xử lý sự kiện hai lần.
Xoay secret ký
Xoay secret khi nó có thể đã lộ, hoặc theo lịch định kỳ. Bấm Đổi secret trên trang của endpoint, hoặc gọi POST /webhooks/{id}/rotate, trả về { "id": "wh_123...", "secret": "whsec_..." }.
Trong 24 giờ sau khi xoay, mọi lượt gửi được ký bằng cả secret mới lẫn secret cũ: svix-signature chứa hai chữ ký v1. Code hiện tại vẫn chạy trong lúc bạn triển khai secret mới, và đoạn mã xác minh ở trên chấp nhận lượt gửi nếu một chữ ký bất kỳ khớp.
Thử trên máy
Hạ Tầng chỉ gửi tới địa chỉ HTTPS công khai, nên hãy đưa server trên máy ra Internet qua một tunnel như Cloudflare Tunnel hoặc ngrok, rồi đăng ký URL của tunnel thành một endpoint riêng:
cloudflared tunnel --url http://localhost:3000
# hoặc
ngrok http 3000
Sau đó tạo một sự kiện thật, như gửi email cho chính bạn, hoặc gửi lại một lượt cũ từ dashboard. Dùng secret ký của chính endpoint mới trên máy, và xoá endpoint khi xong việc.
Viết handler idempotent
Coi mọi lượt gửi là "ít nhất một lần":
- Lọc trùng theo
svix-id. Cùng một sự kiện có thể tới nhiều lần, sau khi phía bạn quá thời gian, khi thử lại hoặc khi gửi lại. Lưu các id đã xử lý và bỏ qua lần lặp. - Đừng dựa vào thứ tự. Lần thử lại có thể mang sự kiện cũ tới sau sự kiện mới. So sánh
created_at, hoặc lấy trạng thái hiện tại của đối tượng qua API trước khi xử lý. - Trả
2xxcho cả sự kiện bạn bỏ qua, để chúng không bị thử lại suốt một ngày. - Kiểm tra môi trường của sự kiện Thanh toán, để thanh toán development không bao giờ kích hoạt một đơn hàng thật.