Cách bảo mật webhook thanh toán crypto
Điều gì bảo vệ endpoint webhook thanh toán: xác minh chữ ký webhook, xử lý idempotent, mã trạng thái chính xác, thử lại và ghi log. Dựa trên cách webhook Qvard thực sự hoạt động.

Webhook là thời điểm cửa hàng của bạn quyết định một đơn hàng đã được thanh toán. Bất kỳ ai gọi được endpoint đó với nội dung đúng đều có thể thử lấy hàng miễn phí, nên nó đáng được chăm chút hơn hầu hết các route. Bài này mô tả điều gì bảo vệ webhook thanh toán, dựa trên cách webhook Qvard hoạt động hiện nay, kể cả header X-Qvard-Signature, giúp bạn kiểm tra thông báo có thật sự đến từ Qvard.
Webhook Qvard là gì và không phải là gì
Khi một thanh toán hoàn tất, Qvard gửi yêu cầu POST tới callback URL đã lưu trong dự án của bạn. Nội dung nhỏ: id bạn đã đưa khi tạo thanh toán và status của thanh toán. Webhook được gửi khi thanh toán hoàn tất, đó là tín hiệu để đánh dấu đơn là đã thanh toán.
Yêu cầu mang header X-Qvard-Signature dạng t=<unix>,v1=<hex>: HMAC-SHA256 của dấu thời gian và nội dung thô, tính bằng khóa bí mật của bạn. Hai giá trị v1 được gửi trong 24 giờ sau khi xoay khóa, và một trong hai đều hợp lệ. Người bán dùng khóa cũ (không có tiền tố qvard_sk_) còn nhận header x-api-key trên webhook; vẫn nên xác minh chữ ký. Hãy xác minh trên nội dung thô trước khi phân tích JSON. Bài này mô tả cách xác minh chữ ký mà Qvard gửi hiện nay.
Hai đặc điểm của thiết kế quyết định mọi thứ còn lại: endpoint của bạn là một URL công khai, và bí mật duy nhất bảo vệ nó là khóa bí mật dùng để xác minh chữ ký.
Những mối đe dọa thực sự quan trọng
Một danh sách ngắn hữu ích hơn một danh sách dài:
- Yêu cầu giả mạo. Ai đó tìm thấy endpoint của bạn và gửi
{ "id": "1234", "status": "completed" }để đơn được đánh dấu đã thanh toán. Việc xác minh chữ ký ngăn điều này. - Khóa bị lộ. Cùng một khóa bí mật ký webhook và xác thực các lệnh gọi API của bạn. Nếu nó xuất hiện trong kho mã, log hay mã front-end thì cả hai đều bị lộ.
- Gửi lặp. Không phải tấn công nhưng là nguyên nhân phổ biến nhất gây thiệt hại thật: cùng một sự kiện đến hai lần và đơn được thực hiện hai lần.
- Endpoint chậm hoặc lỗi. Các lần gửi bị lỡ để đơn đã thanh toán không được thực hiện.
- Ghi log cẩu thả. Ghi cả yêu cầu vào log sẽ đưa khóa vào các tệp log.
Mỗi phần tiếp theo đóng một trong các mối đe dọa này.
Kiểm tra khóa và so sánh an toàn
Đọc header X-Qvard-Signature ở mọi yêu cầu và xác minh trên nội dung thô trước khi làm bất cứ việc gì khác. Hai chi tiết quan trọng:
- So sánh thời gian không đổi. Phép
===thông thường có thể làm lộ thông tin về số ký tự đầu khớp nhau. Hãy dùng hàm thời gian không đổi của ngôn ngữ bạn (crypto.timingSafeEqualtrong Node.js,hash_equalstrong PHP,hmac.compare_digesttrong Python). Kiểm tra độ dài trước nếu hàm yêu cầu kích thước bằng nhau. - Từ chối lặng lẽ. Trả lời
400không giải thích. Đừng nói chữ ký bị thiếu hay sai.
Ví dụ bên dưới cho thấy toàn bộ trình xử lý: kiểm tra chữ ký, giới hạn nhỏ cho nội dung, kiểm tra các trường, cập nhật chỉ thay đổi đơn chưa thanh toán và câu trả lời đúng 200. Giữ khóa trong biến môi trường hoặc trình quản lý bí mật, không bao giờ để trong kho mã hay mã front-end.
import express from 'express';
import { Qvard } from '@qvard/node';
const secret = process.env.QVARD_SECRET_KEY;
const qvard = new Qvard(secret);
const app = express();
app.post('/webhooks/qvard', express.raw({ type: 'application/json', limit: '10kb' }), async (req, res) => {
let event;
try {
event = qvard.webhooks.constructEvent(req.body, req.get('X-Qvard-Signature'), secret);
} catch {
return res.sendStatus(400);
}
const { id, status } = event;
if (typeof id !== 'string' || status !== 'completed') return res.sendStatus(200);
// Idempotent: the update only matches an order that is not paid yet.
const changed = await db.orders.markPaidIfNot(id);
if (changed) queue.push({ type: 'fulfil', orderId: id }); // slow work happens after the answer
return res.sendStatus(200); // exactly 200
});Làm cho trình xử lý idempotent
Qvard coi một lần gửi là thành công chỉ khi endpoint của bạn trả lời đúng HTTP 200. Mọi trạng thái khác, quá thời gian hay chuyển hướng đều tính là thất bại và được thử lại: tổng cộng năm lần trong khoảng sáu phút. Hệ quả là trình xử lý của bạn đôi khi sẽ thấy cùng một sự kiện nhiều lần, ví dụ khi máy chủ đã làm xong việc nhưng câu trả lời bị mất.
Hãy thiết kế với điều đó:
- Làm cho việc đổi trạng thái có điều kiện: «đánh dấu đơn này đã thanh toán nếu nó chưa được thanh toán», lý tưởng là trong một câu lệnh cơ sở dữ liệu, để hai lần gửi song song không thể cùng thắng.
- Chỉ bắt đầu thực hiện (email, khóa, quyền truy cập) khi bản cập nhật thực sự đã thay đổi một dòng.
- Bỏ qua sự kiện của các đơn bạn không biết, nhưng vẫn trả 200; nếu không Qvard cứ thử lại thứ bạn sẽ không bao giờ xử lý.
- Chỉ hành động khi
completed. Không trạng thái nào khác được đánh dấu đơn là đã thanh toán.
Đây cũng là biện pháp chống yêu cầu phát lại: một sự kiện hợp lệ lặp lại không thay đổi gì ở lần thứ hai.
Trả lời nhanh, làm việc sau
Endpoint của bạn phải trả lời trước khi Qvard bỏ cuộc, nên hãy giữ nó ngắn: kiểm tra, cập nhật, đưa vào hàng đợi, trả lời. Các phần chậm như gửi email, gọi hệ thống khác và tạo tệp hãy làm từ hàng đợi sau khi đã trả lời.
Hãy nghĩ cả về những gì endpoint không biết. Nội dung chỉ mang id và status, nên số tiền kỳ vọng lấy từ đơn hàng của chính bạn, không phải từ webhook. Không bao giờ lấy số tiền, tiền tệ hay sản phẩm từ nội dung yêu cầu.
Chỉ phục vụ endpoint qua HTTPS và đảm bảo không có proxy hay chuyển hướng phía trước: chuyển hướng không được theo, nên 301 từ http sang https là một lần gửi thất bại.
Log, giám sát và khôi phục
Hãy xem webhook như dòng tiền và giữ lại dấu vết.
- Ghi quyết định của bạn, không ghi bí mật. Lưu
id,status, thời gian và quyết định (đã áp dụng, trùng lặp, đơn không rõ, khóa bị từ chối). Không bao giờ ghi khóa bí mật của bạn. - Cảnh báo khi bị từ chối. Một loạt câu trả lời
400đột ngột nghĩa là có người đang dò endpoint hoặc bạn đổi khóa ở chỗ này mà chưa đổi ở mọi nơi. - Đối soát. Mỗi ngày một lần, so sánh các đơn bạn coi là đã thanh toán với số dư và các khoản thanh toán trong bảng điều khiển Qvard.
- Khôi phục. Nếu máy chủ của bạn ngừng lâu hơn khoảng thử lại, hãy gửi lại thông báo của khoản thanh toán đó từ bảng điều khiển và để trình xử lý idempotent lo.
Nếu nghi khóa bị lộ, hãy thay nó và rà soát hệ thống nào của bạn đã dùng nó.
Danh sách kiểm tra trước khi chạy thật
- Endpoint dùng HTTPS, công khai và truy cập được từ ngoài mạng của bạn.
- Xác minh header
X-Qvard-Signaturetrên nội dung thô và trả400nếu không khớp. - Giới hạn kích thước nội dung và kiểm tra
idvàstatus. - Chỉ đánh dấu đơn đã thanh toán khi
completedvà chỉ nếu chưa thanh toán. - Trả lời đúng 200 nhanh chóng và làm việc chậm sau đó.
- Không bao giờ tin số tiền hay sản phẩm trong nội dung.
- Không bao giờ ghi khóa vào log.
- Bạn đã thử một câu trả lời lỗi, một lần gửi trùng và một đơn không rõ.
Về toàn bộ luồng quanh webhook, xem cách nhận thanh toán crypto trên website. Định dạng yêu cầu và phản hồi nằm trong tài liệu cho nhà phát triển.
FAQ
Qvard có ký webhook bằng HMAC không?
Có. Mỗi webhook mang header X-Qvard-Signature (t=<unix>,v1=<hex>): HMAC-SHA256 của dấu thời gian và nội dung thô, tính bằng khóa bí mật của bạn. Hãy xác minh trên nội dung thô theo thời gian không đổi và giữ khóa bí mật thật kín.
Endpoint của tôi phải trả mã trạng thái nào?
Đúng 200. Mọi trạng thái khác, quá thời gian hay chuyển hướng đều tính là gửi thất bại và được thử lại, tổng cộng năm lần trong khoảng sáu phút.
Nếu máy chủ của tôi ngừng khi thanh toán hoàn tất thì sao?
Việc thử lại bao phủ khoảng sáu phút. Sau đó hãy gửi lại thông báo của khoản thanh toán từ bảng điều khiển. Trình xử lý idempotent làm cho việc này an toàn.
Tôi có thể tin số tiền trong webhook không?
Webhook chỉ mang id thanh toán và trạng thái. Hãy dùng số tiền từ đơn của chính bạn và chỉ đánh dấu đã thanh toán khi trạng thái là completed.
