Xác minh webhook HMAC-SHA256 và chống replay 300 giây cho API ngân hàng

Vì sao tiền về rồi mà đơn vẫn nằm ở trạng thái chờ?

Chúng tôi gặp tình huống này khá thường xuyên trong quá trình hỗ trợ khách hàng. Khách đã chuyển khoản xong, mở lại trang thì đơn hàng vẫn hiện trạng thái chờ thanh toán. Đội chăm sóc phải ngồi đối chiếu sao kê bằng tay, còn khách thì nhắn tin hỏi liên tục. Phần lớn nguyên nhân nằm ở khâu nhận thông báo biến động số dư, nơi một chi tiết nhỏ có thể làm cả luồng bị lệch.

Webhook là cơ chế ngân hàng hoặc đơn vị trung gian chủ động gửi một yêu cầu đến máy chủ của bạn để báo rằng vừa có tiền về. Nếu yêu cầu này không được xử lý đúng cách, đơn hàng sẽ không tự chuyển sang trạng thái đã thanh toán. Cơ chế nghe thì đơn giản, nhưng có một điểm rất dễ bị bỏ sót khi triển khai.

Vấn đề không chỉ nằm ở việc có nhận được thông báo hay không. Bạn cần chắc chắn thông báo đó thật sự đến từ ngân hàng. Bất kỳ ai cũng có thể gửi một yêu cầu POST vào địa chỉ webhook của bạn. Nếu không kiểm tra, kẻ xấu có thể tự tạo giao dịch giả để đơn hàng được ghi nhận là đã thanh toán.

Bài viết này tập trung vào phần nhỏ nhưng quan trọng: cách xác minh chữ ký HMAC-SHA256 và cách chặn việc gửi lại yêu cầu cũ. Chúng tôi cũng đi qua code mẫu cho cả Node.js và PHP, để bạn có thể đối chiếu trực tiếp với dự án của mình.

Chọn cách nhận dữ liệu chuyển khoản phù hợp

Trước khi viết dòng code đầu tiên, bạn cần quyết định cách nhận dữ liệu biến động số dư. Có ba cách thường gặp. Cách thứ nhất là webhook, nghĩa là bên cung cấp chủ động gửi tin về máy chủ của bạn. Cách thứ hai là polling, tức máy chủ tự hỏi lại vài chục giây một lần xem có giao dịch mới hay không. Cách thứ ba là gọi trực tiếp API của ngân hàng để lấy sao kê.

Theo kinh nghiệm của chúng tôi, webhook thường nhanh nhất và tiết kiệm tài nguyên nhất. Đổi lại, bạn phải tự lo bảo mật, vì tin nhắn có thể đến từ bất kỳ nguồn nào. Polling dễ triển khai hơn nhưng luôn có độ trễ và tốn số lượt gọi. Với đa số dự án nhỏ, chúng tôi khuyên bắt đầu bằng webhook kèm xác minh chữ ký, vì đây là lựa chọn cân bằng giữa tốc độ và độ an toàn.

Ngoài cách nhận dữ liệu, hãy xem kỹ mức phí, hạn mức giao dịch và việc đơn vị có giữ tiền của bạn trong vài ngày hay không. Một dịch vụ rẻ nhưng giữ tiền lâu có thể làm dòng tiền bị đứt quãng. Nên kiểm tra trong tài liệu xem họ có hỗ trợ ký HMAC và cơ chế chống gửi lại hay không, vì hai điều này quyết định bạn phải tự viết bao nhiêu phần.

Một số cổng xác nhận chuyển khoản phổ biến

SePay, Casso và PayOS là ba cái tên được nhắc đến nhiều. Điểm chung là triển khai nhanh, tài liệu tương đối rõ và phạm vi ngân hàng hỗ trợ khá rộng. Nhược điểm là bạn phụ thuộc vào ngân hàng đối tác: nếu ngân hàng của khách gặp sự cố, luồng xác nhận cũng bị ảnh hưởng. Gói miễn phí thường giới hạn số giao dịch, nên các dự án đang tăng trưởng thường phải nâng gói khá sớm.

MONA Pay hiện có gói miễn phí 500 giao dịch mỗi tháng. Các gói trả phí gồm 99k, 299k, 799k và 1,99 triệu đồng, không thu theo phần trăm và không giữ tiền của bạn. Tuy nhiên, bảng giá có thể thay đổi nên bạn nên đối chiếu tại trang chính thức. Nếu muốn tìm hiểu cách dịch vụ này kết nối và xử lý giao dịch, bạn có thể đọc thêm tại API ngân hàng và QR động của MONA Pay. Như mọi dịch vụ khác, MONA Pay vẫn phụ thuộc vào phạm vi ngân hàng được kết nối, nên hãy kiểm tra ngân hàng bạn đang dùng có nằm trong danh sách hỗ trợ hay không.

Khi đã chọn được đơn vị phù hợp, hãy tham khảo thêm các xu hướng nhập khẩu hàng Trung Quốc năm nay nếu sản phẩm của bạn đang đi theo mô hình nhập hàng, vì dòng tiền thanh toán khi đó thường phức tạp hơn bình thường.

Cách xác minh chữ ký HMAC-SHA256 đúng cách

Nguyên tắc đầu tiên là chữ ký phải được tính trên nội dung thô của yêu cầu, đúng từng byte mà bên cung cấp gửi đi. Nhiều người mắc lỗi khi parse JSON rồi tính lại chữ ký. Chỉ cần khoảng trắng hay thứ tự các khóa thay đổi là chữ ký sẽ không khớp, dù dữ liệu vẫn đúng về nội dung.

HMAC là cách dùng một khóa bí mật để ký nội dung. Chỉ ai biết khóa mới tạo ra được đúng chữ ký đó. Khi nhận yêu cầu, máy chủ của bạn tự tính lại chữ ký bằng khóa đang giữ, rồi đối chiếu với chữ ký trong header. Hai giá trị khớp nhau nghĩa là nội dung không bị sửa đổi và đến từ người có khóa.

Khi so sánh hai chuỗi chữ ký, đừng dùng phép so sánh thông thường. Hãy dùng hàm so sánh hằng thời gian, để thời gian xử lý không lộ ra bạn đã khớp được bao nhiêu ký tự đầu. Chi tiết này nhỏ, nhưng nhiều đội bỏ qua và để lại một lỗ hổng không cần thiết.

Chống replay: chặn kẻ xấu gửi lại yêu cầu cũ

Replay là tình huống kẻ xấu chép lại một yêu cầu hợp lệ đã từng gửi rồi gửi lại. Vì chữ ký vẫn đúng, nếu chỉ kiểm tra chữ ký thì yêu cầu này vẫn qua được. Cách chặn là kiểm tra dấu thời gian trong yêu cầu, rồi từ chối những yêu cầu lệch quá 300 giây so với đồng hồ máy chủ.

Con số 300 giây là mức nhiều đơn vị hay dùng. Nó đủ rộng để chịu được độ trễ mạng bình thường, nhưng không quá rộng để kẻ xấu có thời gian khai thác. Bạn nên đảm bảo đồng hồ máy chủ được đồng bộ, vì nếu máy chủ lệch giờ, yêu cầu hợp lệ cũng có thể bị từ chối nhầm.

Ngoài chữ ký và dấu thời gian, hãy lưu lại mã giao dịch đã xử lý. Ngay cả khi mọi thứ đều hợp lệ, bên cung cấp vẫn có thể gửi lại thông báo do lỗi mạng. Nếu không kiểm tra trùng lặp, bạn sẽ cộng tiền hai lần, và việc đối soát sau đó rất mất thời gian.

Code mẫu Node.js

Đoạn logic dưới đây minh họa đủ bốn bước cần làm. Bạn chỉ cần thay tên biến và đường dẫn cho khớp với dự án.

  • Đọc nội dung thô của yêu cầu trước khi parse JSON
  • Tính HMAC-SHA256 bằng khóa bí mật và so sánh bằng hàm hằng thời gian
  • Kiểm tra dấu thời gian và từ chối nếu lệch quá 300 giây
  • Kiểm tra mã giao dịch đã xử lý trước khi cập nhật đơn hàng

Khi triển khai, hãy đảm bảo middleware nhận body ở dạng raw đúng cho route webhook. Nếu framework tự parse JSON trước, bạn sẽ không còn nội dung thô để tính chữ ký, và gần như chắc chắn sẽ gặp lỗi chữ ký không khớp dù mọi thứ khác đều đúng.

Code mẫu PHP

Phiên bản PHP làm theo đúng logic trên. Bạn lấy chuỗi thô bằng file_get_contents('php://input'), tính giá trị bằng hash_hmac('sha256', ...), so sánh bằng hash_equals và đọc timestamp từ header để đối chiếu với time(). Điều cần nhớ là hash_equals chính là hàm so sánh hằng thời gian trong PHP. Đừng thay nó bằng toán tử == hay ===, vì khi đó bạn đã mở lại lỗ hổng đo thời gian.

Những sai lầm hay gặp khi ghép nhiều dịch vụ

Lỗi phổ biến nhất là đăng ký quá nhiều nền tảng cùng lúc mà không ai theo dõi chi phí. Mỗi dịch vụ chỉ tốn một chút, nhưng cộng lại sẽ thành khoản không nhỏ khi lưu lượng tăng. Chúng tôi khuyên ghi lại danh sách tài khoản và ngày gia hạn ngay từ đầu, để không bị bất ngờ khi thẻ tự trừ tiền.

Lỗi thứ hai là bỏ qua cấu hình DKIM và SPF cho email xác nhận. Không có hai cấu hình này, thư rất dễ rơi vào hộp thư rác. Khách không nhận được xác nhận sẽ gọi hỏi đội hỗ trợ, tốn thêm công sức của cả hai bên.

Lỗi thứ ba là không ghi log webhook. Khi khách báo chưa nhận được xác nhận, nếu không có dữ liệu để tra cứu, bạn gần như không thể trả lời. Hãy lưu thời điểm nhận, mã giao dịch và kết quả xử lý, ngay cả khi chỉ là một bảng nhỏ trong cơ sở dữ liệu.

Chi phí thực tế của bộ công cụ trong ba tháng đầu

Chi phí của một bộ công cụ thường gồm hai phần. Phần cố định là phí gói tháng của từng dịch vụ, phí tên miền và đôi khi là khoản nâng cấp để có hỗ trợ kỹ thuật. Phần biến động là số giao dịch xác nhận, số email gửi đi và tài nguyên máy chủ như CPU, RAM và dung lượng đĩa.

Cách ước lượng đơn giản là lấy số đơn trung bình mỗi ngày nhân với 30, rồi nhân thêm khoảng 1,5 để dự phòng cho những ngày cao điểm. Với email, hãy tính số lần gửi cho mỗi đơn, thường là xác nhận đặt hàng và xác nhận thanh toán. Với máy chủ, theo dõi mức dùng trong tuần đầu rồi điều chỉnh dần.

Nên đặt một ngưỡng rõ ràng để đổi gói. Ví dụ, khi số giao dịch chạm khoảng 80% hạn mức miễn phí thì bắt đầu chuẩn bị nâng cấp. Làm như vậy, bạn sẽ không rơi vào tình huống dịch vụ ngừng hoạt động giữa đêm chỉ vì vượt giới hạn.

Gửi email xác nhận đơn hàng: nên chọn dịch vụ nào?

Resend, SendGrid và Amazon SES là những lựa chọn phổ biến. Tài liệu đầy đủ, cộng đồng đông và dễ tìm lời giải khi gặp lỗi. Điểm cần lưu ý là giới hạn của gói miễn phí, cùng việc bạn phải tự cấu hình tên miền gửi. Phần này đôi khi khiến người mới mất khá nhiều thời gian.

MONA Mail cho gói miễn phí 3.000 email mỗi tháng. Các gói trả phí là 99k, 299k và 799k, và dịch vụ bắt buộc cấu hình DKIM. Điều này có lợi vì giúp email đáng tin cậy hơn trong mắt hộp thư đến. Khi so sánh, hãy đối chiếu các mức giá với lượng email thực tế của mình, đừng chỉ nhìn vào con số miễn phí.

Chọn nơi chạy backend: nền tảng có sẵn hay tự dựng?

Vercel, Railway và Render cho phép triển khai rất nhanh, phù hợp với prototype và dự án nhỏ. Bạn đẩy code lên là chạy. Nhược điểm là khi lưu lượng tăng, chi phí có thể lên khó đoán, và bạn cần để ý giới hạn tài nguyên của từng gói.

Tự dựng trên máy chủ riêng cho phép kiểm soát chi phí và cấu hình tốt hơn, nhưng bạn phải tự lo cập nhật, sao lưu và giám sát. Với đội nhỏ không có người vận hành hệ thống chuyên trách, phần việc này thường nặng hơn lợi ích thu được.

MONA Cloud tính tiền theo giờ: 250 đồng cho mỗi core-giờ, 150 đồng cho mỗi GB RAM-giờ, 15 đồng cho mỗi GB đĩa-giờ, mức tối thiểu 550 đồng mỗi giờ, và tặng 100 nghìn đồng credit khi bắt đầu. Khi so sánh, hãy để ý cách tính tiền theo giờ của từng bên và mức uptime họ thực sự cam kết thay vì chỉ nhìn đơn giá.

Cơ sở dữ liệu và xác thực người dùng

Supabase và Firebase có hệ sinh thái trưởng thành, nhiều hướng dẫn và thư viện đi kèm. Điểm cần đọc kỹ là mô hình giá và các giới hạn, vì chúng khá nhiều chi tiết. Nên tìm hiểu trước khi dự án tăng trưởng để tránh bất ngờ.

MONA Base hiện đang ở giai đoạn beta, chạy Postgres 17 kèm xác thực và lưu trữ tệp, tương thích với thư viện supabase-js. Vì vẫn đang beta, bạn nên thử nghiệm kỹ trước khi đưa vào môi trường thật.

Câu hỏi thường gặp

Dự án nhỏ nên tự xử lý webhook ngân hàng hay dùng cổng trung gian?

Nếu bạn có người hiểu code và chấp nhận tự lo bảo mật, tự xử lý sẽ linh hoạt hơn. Nếu không, cổng trung gian giúp giảm rủi ro vì họ đã làm sẵn phần xác minh và đối soát. Chúng tôi thường khuyên người mới bắt đầu bằng cổng trung gian, rồi tự tích hợp khi đã nắm rõ luồng dữ liệu.

Gói miễn phí có đủ để thử nghiệm không?

Thường là đủ để thử nghiệm và chạy những đơn hàng đầu tiên. Hãy đếm số giao dịch thực tế trong tuần đầu rồi mới quyết định có cần nâng gói hay không.

Làm sao biết một dịch vụ có giữ tiền của mình không?

Đọc kỹ điều khoản và trang giá, tìm nội dung về việc tiền được chuyển trực tiếp vào tài khoản của bạn hay qua tài khoản trung gian. Nếu vẫn chưa chắc, hãy hỏi thẳng bộ phận hỗ trợ và yêu cầu ghi rõ bằng văn bản.

Có cần cấu hình DKIM ngay từ đầu cho email xác nhận không?

Nên có. Thiếu DKIM và SPF, email rất dễ bị đưa vào thư rác, và đây là lỗi khó nhận ra vì không có thông báo rõ ràng.

Khi nào nên chuyển từ gói miễn phí sang gói trả phí?

Khi lượng dùng thực tế gần chạm giới hạn miễn phí, hoặc khi bạn cần hỗ trợ kỹ thuật nhanh hơn. Đặt ngưỡng cụ thể từ sớm sẽ giúp bạn chuyển đổi thong thả, không phải vội vàng khi hệ thống đã quá tải.