Chào ae, tôi lại ngoi lên đây,
Nếu anh em làm E-commerce lâu năm, đặc biệt là ôm mấy con hàng WordPress/WooCommerce, chắc chắn không dưới một lần nếm mùi tích hợp cổng thanh toán. Nghe thì có vẻ “cơ bản”: Đọc API docs → Bắn request tạo đơn → Tạo Endpoint hứng Webhook → Đổi trạng thái đơn. Xong! Dễ như ăn kẹo đúng không?
Nhưng không anh em ạ, hệ thống thực tế không giống tutorial trên mạng. Hôm nay tôi sẽ kể cho anh em nghe một case study debug “đẫm máu” mà tôi vừa trải qua. Một pha bắt bug mà logic PHP chuẩn 100%, test bằng Postman trả về mã 200 xanh mướt, nhưng khi khách hàng thanh toán thật thì… bặt vô âm tín.
Hành trình lôi cổ con bug này xuyên qua 3 tầng hệ thống — Nginx → WAF → WordPress Hooks — thực sự là một bài học xương máu. Chúng ta bắt đầu với cửa ải đầu tiên: Cú lừa từ tầng Network.
Bức Tranh Hoàn Hảo Và Vết Gợn Đầu Tiên
Dự án tôi đang xử lý là một web WooCommerce bán hàng thực chiến, không phải môi trường demo. Mọi thứ đang chạy ngon — cho đến khi khách hàng nhắn tin:
“Anh ơi, em quẹt ZaloPay tiền trừ ting ting rồi mà web vẫn báo Chờ thanh toán.”
Cái câu đó, bất kỳ developer nào làm payment integration cũng biết là nó nặng cỡ nào. Không phải lỗi UI. Không phải lỗi logic. Đây là tiền thật của khách hàng thật, và hệ thống đang không xử lý đúng.
Tôi mở dashboard WooCommerce lên. Đơn hàng vẫn trơ ra ở Pending Payment. Không có log lỗi nào từ plugin. Không có email xác nhận nào được gửi đi. Hệ thống hoạt động hoàn toàn bình thường — như thể Webhook của ZaloPay chưa bao giờ tồn tại.
Đây không phải bug bình thường. Đây là kiểu bug mà càng nhìn vào càng thấy không có gì sai — cho đến khi bạn tụt xuống tận tầng Network để nhìn thẳng vào gói tin. Bài viết này ghi lại toàn bộ hành trình debug thực chiến đó, không bỏ sót bước nào.
Tại Sao Webhook ZaloPay Lại Khó Debug Đến Vậy?
Tích hợp thanh toán có một đặc thù chết người: luồng xử lý bị tách làm đôi.
Phần đầu (người dùng redirect sang cổng thanh toán) bạn còn có thể test trực tiếp. Nhưng phần sau — Webhook từ server ZaloPay gọi ngược về server của bạn — là một tiến trình hoàn toàn bất đồng bộ, chạy sau hậu trường, không có giao diện, không có người nhìn, và không có bất kỳ thứ gì để bạn can thiệp trực tiếp trong lúc nó xảy ra.
Khi nó im lặng, bạn có đúng ba nguồn thông tin: Log Nginx, Log PHP, và Log phía ZaloPay — mà Log phía ZaloPay thì Merchant Portal không cho bạn xem. Đó là lý do tại sao đây là loại bug dễ khiến một developer lành mạnh mất hướng sau vài chục phút.
Phản xạ đầu tiên của tôi không phải là mở code lên sửa. Mà là: xác định xem lỗi nằm ở đâu đã.
Bước 0: Cô Lập Lỗi Trước Khi Mổ Server
Trước khi SSH vào VPS và bắt đầu đào bới, cần xác định một điều cơ bản: lỗi nằm ở phía ZaloPay hay phía server của mình?
Câu trả lời đến từ webhook.site — một công cụ cho phép tạo một URL công khai, hứng mọi HTTP request đến và hiển thị toàn bộ header lẫn body theo thời gian thực.
Quy trình cô lập:
- Vào webhook.site, lấy URL ngẫu nhiên được cấp.
- Thay URL đó vào phần Callback URL được gửi sang Zalopay cùng với các dữ liệu đơn hàng, chi tiết anh em có thể sang document chính thức của Zalopay để đọc.
- Tạo một đơn hàng test và thực hiện thanh toán.
Kết quả: Chưa đầy 2 giây, webhook.site nhận được một payload JSON đầy đủ — app_trans_id, mac, amount, toàn bộ.
Verdict: ZaloPay không có vấn đề gì. Lỗi nằm 100% ở phía server. Lúc này mới có cơ sở để mổ VPS.
Nguyên tắc: Khi tích hợp API bên thứ ba (ZaloPay, MoMo, VNPay…), luôn cô lập lỗi bằng một endpoint trung gian trước khi đụng vào code hay server. Tiết kiệm ít nhất một giờ debug mù.
Bước 1: Nginx Access Log “Im Lặng” — Dấu Hiệu Bất Thường Đầu Tiên
Vấn đề của Webhook thanh toán nằm ở chỗ này: không giống phần redirect mà bạn còn test được trực tiếp, Webhook là tiến trình bất đồng bộ chạy hoàn toàn sau hậu trường — khi nó im lặng, bạn chỉ có đúng hai nguồn để bới: Log Nginx và Log PHP.
Vậy nên việc đầu tiên, tôi mở Access Log của Nginx ra soi, để xem ZaloPay có thực sự gọi vào server không.
bash
tail -f /var/log/nginx/domain.com-access.log
Kết quả: Không có gì. Không một dòng nào từ dải IP của ZaloPay. Chỉ có log của bot crawl dạo.
Tình huống lúc này:
- ❌ Không có log ở tầng ứng dụng (PHP/WordPress).
- ❌ Không có log ở tầng web server (Nginx).
- ❌ Không có quyền xem log phía ZaloPay.
Khi cả ba cửa đều bị bịt, chỉ còn một cách: tụt xuống thẳng tầng Network.
Bước 2: TCPDUMP — Nhìn Thẳng Vào Gói Tin Mạng
tcpdump là công cụ bắt gói tin trực tiếp tại card mạng, không qua bất kỳ tầng trung gian nào. Nếu ZaloPay có gửi request đến IP của server, tcpdump sẽ thấy — dù Nginx có log hay không.
Lệnh giăng lưới, lọc đúng port 443 và dải IP của hệ thống ZaloPay:
bash
tcpdump -n -i any port 443 and src net 118.102.0.0/16
Tạo một đơn test và thanh toán. Màn hình SSH nổ ngay:
11:53:15.057552 IP 118.102.5.66.60772 > 103.68.xxx.xxx.443: Flags [P.], length 268
11:53:15.081858 IP 118.102.5.66.60772 > 103.68.xxx.xxx.443: Flags [P.], length 683
11:53:15.094082 IP 118.102.5.66.60772 > 103.68.xxx.xxx.443: Flags [F.], length 0
Gói tin length 683 chứa HTTP Header và JSON payload của Webhook.
Kết luận bắt buộc từ dữ liệu thực tế:
- ✅ ZaloPay đã gửi request.
- ✅ Server đã nhận — không mất một byte nào.
- ❓ Nhưng Nginx không ghi log và không truyền cho PHP.
Vậy gói tin đó đi về đâu sau khi chạm vào server?
Bước 3: Nguyên Nhân Gốc Rễ — Lỗi SNI Trong Nginx
Để hiểu tại sao, cần biết cách Nginx xử lý HTTPS.
SNI (Server Name Indication) Là Gì?
Khi bạn hoặc Postman gọi vào https://domain.com, trong quá trình TLS Handshake, client sẽ đính kèm một thông tin quan trọng: tên miền đang muốn kết nối đến. Thông tin này gọi là SNI (Server Name Indication).
Nginx đọc SNI, so khớp với danh sách server_name trong các file config, rồi đưa request đến đúng virtual host tương ứng để xử lý.
Tại Sao ZaloPay Dính Lỗi SNI?
Core hệ thống ZaloPay được viết bằng Golang. HTTP Client mặc định của Golang — trong một số cấu hình hoặc phiên bản — không tự động đính kèm SNI header trong quá trình TLS Handshake khi gọi đến IP trực tiếp.
Kết quả:
- Gói tin ZaloPay chạm vào IP của server.
- Nginx nhận được kết nối TCP, nhưng không đọc được SNI — không biết request này muốn vào
domain.comhay virtual host nào khác. - Theo cơ chế mặc định, Nginx ném request vào Default Server — trong trường hợp này là một file config
000-catch-allđược tự động tạo ra bởi control panel (HestiaCP/cPanel…). catch-alltrả về lỗi (thường là444hoặc redirect rỗng), không ghi vào access log của domain.com.
Đây là lý do tại sao Access Log của domain.com hoàn toàn im lặng dù gói tin đã đến server: request chưa bao giờ vào đúng virtual host.
Cách Fix: Chỉ Định Default Server Cho Đúng Virtual Host
Khi không có SNI, Nginx cần biết phải đẩy request vào virtual host nào. Giải pháp: chỉ định default_server cho virtual host chính.
Bước 1 — Gỡ quyền default_server khỏi catch-all:
Mở file /etc/nginx/sites-available/000-catch-all (hoặc tương đương), tìm và xóa default_server khỏi các dòng listen:
nginx
# Trước
listen 443 ssl default_server;
# Sau
listen 443 ssl;
Bước 2 — Gán default_server cho virtual host chính:
Mở file config Nginx của domain.com, thêm default_server vào directive listen 443:
nginx
server {
listen 443 ssl default_server;
listen [::]:443 ssl default_server;
server_name domain.com www.domain.com;
# Phần còn lại giữ nguyên
}
Bước 3 — Kiểm tra cú pháp và reload:
bash
nginx -t && systemctl reload nginx
Sau khi reload, mọi request HTTPS không có SNI sẽ mặc định được đưa vào domain.com thay vì rơi vào catch-all.
Kết Quả Sau Fix — Và Cái Bẫy Tiếp Theo
Test lại. Lần này Access Log của domain.com đã có phản ứng:
POST /wp-json/cuatui/v1/zalopay-webhook HTTP/1.1" 403 Forbidden
ZaloPay đã vào đúng virtual host. Nhưng một kẻ chặn đường mới xuất hiện: HTTP 403.
Code đúng. Mạng thông. SNI đã fix. Nhưng tại sao request của ZaloPay bị từ chối?
Thủ phạm của mã lỗi 403 này không phải từ WordPress hay code PHP — mà bắt nguồn từ một lệnh truy nã toàn cầu nhắm vào đúng cái User-Agent mà HTTP Client của Golang mang theo. Chi tiết ở [Kỳ 2]: Lệnh Truy Nã “Go-http-client” Và Cú Vả Điếng Người Từ ModSecurity/8G Firewall.
Tóm Tắt Kỹ Thuật — Để Không Phải Debug Lại Từ Đầu
| Triệu chứng | Nguyên nhân | Giải pháp |
|---|---|---|
| Webhook ZaloPay không có log trong Nginx | HTTP Client Golang thiếu SNI khi TLS Handshake | Chỉ định default_server cho virtual host chính |
tcpdump thấy gói tin nhưng Nginx không log | Request bị ném vào Default Server / catch-all | Xem mục cấu hình bên trên |
| Test Postman OK nhưng Webhook thật thất bại | Postman gửi SNI, Golang client thì không | Nguyên nhân gốc là khác biệt TLS client behavior |
Checklist debug Webhook thanh toán theo thứ tự:
- Dùng
webhook.sitexác nhận đối tác có bắn request không.- Dùng
tcpdumpxác nhận gói tin có đến server không.- So sánh IP đến trong
tcpdumpvới Access Log của từng virtual host.- Nếu log im lặng dù
tcpdumpthấy gói tin → nghi ngờ ngay vấn đề SNI và Default Server.
Mạng đã thông. SNI đã fix. Tưởng xong — nhưng con 403 kia mới là pha twist thật sự. Hẹn anh em ở Kỳ 2
Bình luận (0)