Chữ ký và xác minh
chu ky va xac minhXác minh chữ ký webhook Standard Webhooks của Dòng Tiền Pro: thuật toán HMAC-SHA256, dung sai 5 phút, ví dụ TypeScript, Python và PHP kèm vector kiểm thử. xac minh chu ky webhook standard webhooks cua dong tien pro: thuat toan hmac-sha256, dung sai 5 phut, vi du typescript, python va php kem vector kiem thu.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.
Header của mỗi lần giao
Phần tiêu đề “Header của mỗi lần giao”| Header | Giá trị |
|---|---|
webhook-id | Mã thông điệp msg_…, giữ nguyên qua mọi lần thử lại và phát lại. |
webhook-timestamp | Thời điểm ký, epoch giây. |
webhook-signature | v1,<base64>. Có thể chứa nhiều chữ ký cách nhau bằng khoảng trắng (xem phần xoay secret). |
user-agent | DongTien-Webhooks/1.0 |
content-type | application/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.
Thuật toán
Phần tiêu đề “Thuật toán”-
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.
-
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ũ. -
Ghép chuỗi cần ký:
${webhook-id}.${webhook-timestamp}.${body}, nối bằng dấu chấm. -
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ỗiwhsec_…. -
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 đó. -
Đối chiếu chữ ký mong đợi với từng chữ ký có tiền tố
v1,trongwebhook-signaturebằ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.
Ví dụ xác minh
Phần tiêu đề “Ví dụ xác minh”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ý.
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 });}Chỉ dùng node:crypto. Tham số now (mili giây) chỉ để kiểm thử; bỏ qua nó khi chạy thật.
import { createHmac, timingSafeEqual } from "node:crypto";
const TOLERANCE_SECONDS = 300;
export class WebhookVerificationError extends Error {}
/** * Xác minh webhook Standard Webhooks chỉ bằng `node:crypto`. * Ký trên `${webhook-id}.${webhook-timestamp}.${body}` với khoá là phần base64 sau `whsec_`. * `rawBody` là chuỗi nhận được nguyên vẹn; `now` (mili giây) chỉ để kiểm thử. */export function verifyWebhook( secret: string, rawBody: string, headers: Record<string, string | undefined>, now: number = Date.now(),): unknown { const h: Record<string, string | undefined> = {}; for (const [name, value] of Object.entries(headers)) h[name.toLowerCase()] = value; const id = h["webhook-id"]; const timestamp = h["webhook-timestamp"]; const signatures = h["webhook-signature"]; if (!id || !timestamp || !signatures) throw new WebhookVerificationError("Missing required headers");
if (!/^\d{1,12}$/.test(timestamp)) throw new WebhookVerificationError("Invalid timestamp"); if (Math.abs(Math.floor(now / 1000) - Number(timestamp)) > TOLERANCE_SECONDS) { throw new WebhookVerificationError("Timestamp outside the tolerance window"); }
const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64"); if (key.length === 0) throw new WebhookVerificationError("Empty secret"); const expected = Buffer.from( createHmac("sha256", key).update(`${id}.${timestamp}.${rawBody}`).digest("base64"), );
for (const part of signatures.split(" ")) { const [version, signature] = part.split(","); if (version !== "v1" || !signature) continue; const given = Buffer.from(signature); if (given.length === expected.length && timingSafeEqual(given, expected)) return JSON.parse(rawBody); } throw new WebhookVerificationError("No matching signature found");}Dùng thư viện standardwebhooks (pip install standardwebhooks).
from standardwebhooks import Webhook, WebhookVerificationError
def verify_webhook(secret: str, raw_body: bytes, headers: dict[str, str]): """Xác minh webhook của Dòng Tiền Pro bằng thư viện `standardwebhooks`.
`raw_body` là byte nhận được, CHƯA qua json.loads/json.dumps. Ném WebhookVerificationError khi thiếu header, timestamp lệch quá 5 phút hoặc sai chữ ký. """ try: return Webhook(secret).verify(raw_body, headers) except ValueError as exc: # chữ ký sai định dạng hoặc body không phải JSON raise WebhookVerificationError(str(exc)) from excChỉ dùng thư viện chuẩn, Python 3.10 trở lên. Tham số now (giây) chỉ để kiểm thử.
import base64import hashlibimport hmacimport jsonimport time
TOLERANCE_SECONDS = 300
class WebhookVerificationError(Exception): pass
def verify_webhook(secret: str, raw_body: bytes, headers: dict[str, str], now: float | None = None): """Xác minh webhook Standard Webhooks chỉ bằng thư viện chuẩn của Python.
Ký trên `{webhook-id}.{webhook-timestamp}.{body}` với khoá là phần base64 sau `whsec_`. `raw_body` là byte nhận được nguyên vẹn; `now` (giây) chỉ để kiểm thử. """ h = {name.lower(): value for name, value in headers.items()} msg_id = h.get("webhook-id") timestamp = h.get("webhook-timestamp") signatures = h.get("webhook-signature") if not (msg_id and timestamp and signatures): raise WebhookVerificationError("Missing required headers")
if not (timestamp.isascii() and timestamp.isdigit()): raise WebhookVerificationError("Invalid timestamp") current = time.time() if now is None else now if abs(int(current) - int(timestamp)) > TOLERANCE_SECONDS: raise WebhookVerificationError("Timestamp outside the tolerance window")
key = base64.b64decode(secret.removeprefix("whsec_") + "==") if not key: raise WebhookVerificationError("Empty secret") signed = f"{msg_id}.{timestamp}.".encode() + raw_body expected = base64.b64encode(hmac.new(key, signed, hashlib.sha256).digest())
for part in signatures.split(" "): version, _, signature = part.partition(",") if version == "v1" and hmac.compare_digest(signature.encode(), expected): return json.loads(raw_body) raise WebhookVerificationError("No matching signature found")Dùng hash_hmac và hash_equals, không cần gói cài thêm. Tham số $now (giây) chỉ để kiểm thử.
<?php
declare(strict_types=1);
/** * Xác minh webhook Standard Webhooks của Dòng Tiền Pro bằng hash_hmac + hash_equals. * Đây là đoạn mẫu, không phải thư viện: sao chép vào mã của bạn. * * Ký trên "{webhook-id}.{webhook-timestamp}.{body}" với khoá là phần base64 sau "whsec_". * $rawBody là nội dung nhận được nguyên vẹn (file_get_contents('php://input')), chưa qua json_decode. * $now (giây) chỉ để kiểm thử. * * @param array<string, string> $headers * @throws RuntimeException khi thiếu header, timestamp lệch quá 5 phút hoặc sai chữ ký */function verify_webhook(string $secret, string $rawBody, array $headers, ?int $now = null): mixed{ $h = array_change_key_case($headers, CASE_LOWER); $id = $h['webhook-id'] ?? ''; $timestamp = $h['webhook-timestamp'] ?? ''; $signatures = $h['webhook-signature'] ?? ''; if ($id === '' || $timestamp === '' || $signatures === '') { throw new RuntimeException('Missing required headers'); }
if (preg_match('/^[0-9]{1,12}$/', $timestamp) !== 1) { throw new RuntimeException('Invalid timestamp'); } if (abs(($now ?? time()) - (int) $timestamp) > 300) { throw new RuntimeException('Timestamp outside the tolerance window'); }
$key = base64_decode(preg_replace('/^whsec_/', '', $secret) ?? '', true); if ($key === false || $key === '') { throw new RuntimeException('Invalid secret'); } $expected = base64_encode(hash_hmac('sha256', $id . '.' . $timestamp . '.' . $rawBody, $key, true));
foreach (explode(' ', $signatures) as $part) { [$version, $signature] = array_pad(explode(',', $part, 2), 2, ''); if ($version === 'v1' && hash_equals($expected, $signature)) { return json_decode($rawBody, true, 512, JSON_THROW_ON_ERROR); } } throw new RuntimeException('No matching signature found');}Lấy body thô
Phần tiêu đề “Lấy body thô”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ường | Cá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, Deno | await request.text() trước khi đọc bất kỳ thứ gì khác. |
| Flask | request.get_data(). |
| FastAPI | await request.body(). |
| PHP | file_get_contents('php://input'). |
Xoay secret
Phần tiêu đề “Xoay secret”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.
Vector kiểm thử
Phần tiêu đề “Vector kiểm thử”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_0f5k2m9q4h7t3n8r6v1w0x5yzewebhook-timestamp: 1792377668webhook-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.
Lỗi thường gặp
Phần tiêu đề “Lỗi thường gặp”| Triệu chứng | Nguyê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 kia | Mỗ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ại | Phá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. |