Mọi yêu cầu webhook mang header Hd-Signature. Bạn phải xác thực chữ ký trước khi tin nội dung: nếu không, bất kỳ ai biết URL nhận cũng có thể giả danh hoadon.pro.
Định dạng header
Phần tiêu đề “Định dạng header”Hd-Signature: t=1760000000,v1=ba718eea26d2803db5a211030e2a9e1f28ded05e44ec92c362516fef2fa058fft: thời điểm gửi, tính bằng giây Unix.v1: chữ ký, làhex(HMAC_SHA256(secret, t + "." + body))— nốit, một dấu chấm và nội dung gốc (raw body) của yêu cầu.
secret là secret ký của điểm nhận, hiển thị khi thêm webhook. Mỗi điểm nhận có secret riêng.
Các bước xác thực
Phần tiêu đề “Các bước xác thực”- Đọc nội dung gốc của yêu cầu dưới dạng byte hoặc chuỗi, trước khi phân tích JSON. Phân tích rồi tuần tự hoá lại sẽ làm đổi nội dung và chữ ký sai.
- Tách
tvàv1từ header. - Tính
HMAC-SHA256của chuỗit + "." + bodybằng secret, ra chuỗi hex. - So sánh bằng hàm so sánh thời gian cố định với
v1. - Kiểm tra
tkhông cũ quá một ngưỡng (khuyến nghị 5 phút) để chống phát lại yêu cầu cũ. - Chỉ khi cả hai đều đạt mới xử lý sự kiện; ngược lại trả
400và bỏ qua.
Mã mẫu
Phần tiêu đề “Mã mẫu”import crypto from 'node:crypto';
const TOLERANCE_SECONDS = 300;
export function verifyHoadonSignature(rawBody, header, secret) { const parts = Object.fromEntries(header.split(',').map((p) => p.split('=', 2))); const t = parts.t; const v1 = parts.v1; if (!t || !v1) return false;
const age = Math.abs(Date.now() / 1000 - Number(t)); if (!Number.isFinite(age) || age > TOLERANCE_SECONDS) return false;
const expected = crypto.createHmac('sha256', secret).update(`${t}.${rawBody}`).digest('hex'); const a = Buffer.from(expected, 'hex'); const b = Buffer.from(v1, 'hex'); return a.length === b.length && crypto.timingSafeEqual(a, b);}Với Express, nhận nội dung gốc bằng express.raw({ type: 'application/json' }) và truyền req.body.toString('utf8').
import hashlibimport hmacimport time
TOLERANCE_SECONDS = 300
def verify_hoadon_signature(raw_body: bytes, header: str, secret: str) -> bool: parts = dict(p.split("=", 1) for p in header.split(",")) t, v1 = parts.get("t"), parts.get("v1") if not t or not v1: return False if abs(time.time() - int(t)) > TOLERANCE_SECONDS: return False
signed = t.encode() + b"." + raw_body expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest() return hmac.compare_digest(expected, v1)Với Flask dùng request.get_data(), với FastAPI dùng await request.body() để lấy nội dung gốc.
<?php
function verifyHoadonSignature(string $rawBody, string $header, string $secret): bool{ $parts = []; foreach (explode(',', $header) as $part) { [$key, $value] = array_pad(explode('=', $part, 2), 2, ''); $parts[$key] = $value; } $t = $parts['t'] ?? ''; $v1 = $parts['v1'] ?? ''; if ($t === '' || $v1 === '' || !ctype_digit($t)) { return false; } if (abs(time() - (int) $t) > 300) { return false; }
$expected = hash_hmac('sha256', $t . '.' . $rawBody, $secret); return hash_equals($expected, $v1);}
// $rawBody = file_get_contents('php://input');// $header = $_SERVER['HTTP_HD_SIGNATURE'] ?? '';using System.Security.Cryptography;using System.Text;
public static class HoadonSignature{ private const long ToleranceSeconds = 300;
public static bool Verify(string rawBody, string header, string secret) { string? t = null, v1 = null; foreach (var part in header.Split(',')) { var kv = part.Split('=', 2); if (kv.Length != 2) continue; if (kv[0] == "t") t = kv[1]; else if (kv[0] == "v1") v1 = kv[1]; } if (t is null || v1 is null || !long.TryParse(t, out var timestamp)) return false; if (Math.Abs(DateTimeOffset.UtcNow.ToUnixTimeSeconds() - timestamp) > ToleranceSeconds) return false;
using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secret)); var expected = hmac.ComputeHash(Encoding.UTF8.GetBytes($"{t}.{rawBody}"));
byte[] provided; try { provided = Convert.FromHexString(v1); } catch (FormatException) { return false; }
return CryptographicOperations.FixedTimeEquals(expected, provided); }}Trong ASP.NET Core, đọc nội dung gốc bằng StreamReader(Request.Body) trước khi để framework phân tích JSON.
Vector kiểm thử
Phần tiêu đề “Vector kiểm thử”Dùng bộ giá trị dưới đây để kiểm tra mã của bạn trước khi nhận webhook thật (bỏ bước kiểm tra độ cũ của t khi chạy thử).
| Mục | Giá trị |
|---|---|
| Secret | whsec_test_secret_0123456789 |
t | 1760000000 |
| Nội dung gốc | {"id":"evt_01J9ZQ8K","type":"invoice.created","created_at":"2026-10-15T09:30:12Z","org_id":"org_01J9Z","data":{}} |
Chữ ký v1 mong đợi | ba718eea26d2803db5a211030e2a9e1f28ded05e44ec92c362516fef2fa058ff |
Lỗi thường gặp
Phần tiêu đề “Lỗi thường gặp”- Chữ ký luôn sai: bạn đang ký lại nội dung đã phân tích JSON thay vì nội dung gốc, hoặc framework đã tự đổi mã hoá ký tự.
- Sai secret: mỗi điểm nhận có secret riêng; kiểm tra bạn đang dùng đúng điểm nhận.
- Sai múi giờ khi kiểm tra
t:tlà giây Unix (UTC), không phải giờ địa phương.