Bỏ qua để đến nội dung

Chữ ký và xác minh

~5 phút đọc

Mỗi webhook Dòng Tiền Pro gửi tới bạn được ký theo chuẩn Standard Webhooks. Bạn phải xác minh chữ ký trước khi tin nội dung: nếu không, bất kỳ ai biết địa chỉ endpoint đều có thể giả một giao dịch "tiền về". Việc xác minh chỉ cần thư viện băm chuẩn của ngôn ngữ; không cần SDK của Dòng Tiền Pro.

HeaderGiá trị
webhook-idMã thông điệp msg_…, giữ nguyên qua mọi lần thử lại và phát lại.
webhook-timestampThời điểm ký, epoch giây.
webhook-signaturev1,<base64>. Có thể chứa nhiều chữ ký cách nhau bằng khoảng trắng (xem phần xoay secret).
user-agentDongTien-Webhooks/1.0
content-typeapplication/json

Secret của endpoint có dạng whsec_<base64>, hiện khi bạn tạo endpoint và có thể hiện lại sau khi xác thực lại tài khoản.

  1. Lấy body thô: đúng các byte bạn nhận được, chưa qua phân tích JSON hay serialize lại.

  2. Kiểm tra webhook-timestamp: là số nguyên và lệch so với đồng hồ của bạn không quá 5 phút (300 giây) về cả hai phía. Quá hạn thì từ chối để chặn gửi lại bản ghi cũ.

  3. Ghép chuỗi cần ký: ${webhook-id}.${webhook-timestamp}.${body}, nối bằng dấu chấm.

  4. Lấy khoá: bỏ tiền tố whsec_ rồi giải mã phần còn lại từ base64 thành byte. Khoá HMAC là các byte đó, không phải chuỗi whsec_….

  5. Tính HMAC-SHA256 của chuỗi ở bước 3 với khoá ở bước 4, mã hoá base64. Chữ ký mong đợi là v1, cộng chuỗi base64 đó.

  6. Đối chiếu chữ ký mong đợi với từng chữ ký có tiền tố v1, trong webhook-signature bằng phép kiểm tra bằng nhau theo thời gian hằng (constant-time). Khớp một chữ ký là hợp lệ; không khớp chữ ký nào thì từ chối.

Chỉ trả 2xx sau khi xác minh thành công. Trả mã khác thì Dòng Tiền Pro hiểu là giao chưa được và thử lại.

Các đoạn dưới đây được nhúng thẳng từ tệp nguồn nên luôn khớp với mã đã kiểm thử. Đặt secret trong biến môi trường, ví dụ WEBHOOK_SECRET, và truyền body thô của yêu cầu vào hàm.

Dùng thư viện standardwebhooks (npm install standardwebhooks). Hàm verifyWebhook ném WebhookVerificationError khi thiếu header, quá hạn hoặc sai chữ ký.

verify-webhook.ts
import { Webhook } from "standardwebhooks";
/**
* Xác minh một webhook của Dòng Tiền Pro bằng thư viện `standardwebhooks`.
*
* `rawBody` là chuỗi nhận được từ socket, CHƯA qua JSON.parse/JSON.stringify.
* Ném `WebhookVerificationError` khi thiếu header, timestamp lệch quá 5 phút hoặc sai chữ ký.
*/
export function verifyWebhook(
secret: string,
rawBody: string,
headers: Record<string, string>,
): unknown {
return new Webhook(secret).verify(rawBody, headers);
}
/** Handler dạng Fetch API (Cloudflare Workers, Bun, Deno, Node 20+): 2xx là xác nhận, mọi mã khác là "giao lại". */
export async function handleWebhook(request: Request, secret: string): Promise<Response> {
const rawBody = await request.text();
try {
const event = verifyWebhook(secret, rawBody, Object.fromEntries(request.headers));
// Khử trùng theo header `webhook-id` rồi xử lý `event` (xem trang "Thứ tự và trùng lặp").
void event;
} catch {
return new Response("invalid signature", { status: 400 });
}
return new Response(null, { status: 204 });
}

Lỗi phổ biến nhất là xác minh trên body đã bị phân tích rồi ghép lại: khoảng trắng và thứ tự khoá thay đổi nên chữ ký không còn khớp.

Môi trườngCách lấy body thô
ExpressĐăng ký express.raw({ type: "application/json" }) cho route webhook, rồi dùng req.body.toString("utf8").
Cloudflare Workers, Hono, Bun, Denoawait request.text() trước khi đọc bất kỳ thứ gì khác.
Flaskrequest.get_data().
FastAPIawait request.body().
PHPfile_get_contents('php://input').

Khi bạn xoay secret, endpoint nhận hai secret song song trong 24 giờ. Trong thời gian đó mỗi webhook mang hai chữ ký: webhook-signature: v1,<chữ ký secret cũ> v1,<chữ ký secret mới>. Các đoạn mẫu ở trên đã xử lý sẵn: chúng thử từng chữ ký và chấp nhận khi một chữ ký khớp, nên bạn có thể đổi WEBHOOK_SECRET sang secret mới bất cứ lúc nào trong 24 giờ đó mà không mất sự kiện.

Dùng bộ giá trị dưới đây để kiểm tra bản cài đặt của bạn. Với đồng hồ đặt ở 30 giây sau webhook-timestamp, kết quả phải là hợp lệ. Sửa một ký tự của body, đổi secret hoặc đặt đồng hồ lệch hơn 300 giây thì phải bị từ chối.

secret: whsec_ZHRwLWRvY3MtZ29sZGVuLXZlY3Rvci1rZXktMDE=
webhook-id: msg_0f5k2m9q4h7t3n8r6v1w0x5yze
webhook-timestamp: 1792377668
webhook-signature: v1,4XFdg5LnK3tVNph2IaRYT0ZayX48nyzXzSKxfnhCe88=
body: {"id":"evt_0f5k2m9q4h7t3n8r6v1w0x5yzd","type":"transaction.confirmed","api_version":"2026-10-19","timestamp":"2026-10-19T02:41:08Z","data":{"object":{"id":"tx_0f5k2m9q4h7t3n8r6v1w0x5yzd","object":"transaction","account":{"id":"acc_0f5k2m9q4h7t3n8r6v1w0x5yzd","label":"ACB •••4521","last4":"4521","bank":"ACB"},"company":{"id":"org_0f5k2m9q4h7t3n8r6v1w0x5yzd","mst":null},"amount":4500000,"currency":"VND","direction":"in","status":"confirmed","memo_code":"DTP7K2M9N","description":"NGUYEN THI HONG NHUNG DTP7K2M9N thanh toan don 10492","occurred_at":"2026-10-19T02:41:07Z"}}}

Secret trong vector là giá trị giả chỉ dùng để kiểm thử. Các ví dụ TypeScript và Python ở trên được chạy với bộ vector này, cùng các ca từ chối (sai secret, body bị sửa, body serialize lại, timestamp quá cũ hoặc quá mới, thiếu header), trong bộ kiểm thử của tài liệu; đoạn PHP được chạy khi môi trường kiểm thử có PHP.

Triệu chứngNguyên nhân thường gặp
Luôn sai chữ kýXác minh trên JSON đã phân tích rồi serialize lại, hoặc proxy đã đổi body.
Luôn sai chữ kýDùng chuỗi whsec_… làm khoá thay vì giải mã base64 phần sau tiền tố.
Sai chữ ký ở endpoint này, đúng ở endpoint kiaMỗi endpoint có secret riêng.
Từ chối vì timestampĐồng hồ máy chủ của bạn lệch quá 5 phút; đồng bộ bằng NTP.
Chỉ lỗi với phát lạiPhát lại ký lại với timestamp mới nhưng giữ webhook-id; kiểm tra bạn không lưu timestamp cũ để so.