Chuyển tới nội dung chính

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​

  1. Trong dashboard, mở Cài đặt → Webhooks (cũng có ở Email → Webhooks).
  2. 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.
  3. 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",
"from": "Shop <[email protected]>",
"to": ["[email protected]"],
"subject": "Đơn hàng của bạn đã được xác nhận",
"created_at": "2026-10-23T09:34:12.952Z"
}
}
ModuleSự kiện
Emailemail.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ẩmchat.order.created, chat.order.updated, chat.order.paid
CRMchat.zns.sent, chat.zns.failed
Thanh toánpayment.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ôi trường Thanh toá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-idId 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-timestampThời điểm ký lượt gửi này, tính bằng giây Unix
svix-signatureMộ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-signatureCùng giá trị, dưới tên của Standard Webhooks

Để xác minh một lượt gửi:

  1. Lấy body gốc đúng như nhận được, trước khi parse JSON.
  2. Ghép nội dung cần ký <svix-id>.<svix-timestamp>.<body gốc>.
  3. Bỏ tiền tố whsec_ của secret và giải mã base64 phần còn lại: đó là khoá HMAC.
  4. Tính HMAC-SHA256 của nội dung cần ký và mã hoá base64.
  5. Chấp nhận nếu kết quả bằng một chữ ký v1 bất kỳ trong svix-signature, so sánh bằng hàm thời gian hằng.
  6. Từ chối nếu svix-timestamp lệ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.

@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).

Xác minh trên body gốc

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ờ
1Ngay lập tức
25 giây
35 phút
430 phút
52 giờ
65 giờ
710 giờ
810 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ả 2xx cho 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.

Bước tiếp theo​

Xem trang này dạng Markdown