Cách tích hợp webhook SePay vào server

Tích hợp webhook SePay vào server: cấu trúc payload, phản hồi hợp lệ, chống trùng lặp giao dịch và code mẫu đầy đủ bằng PHP và Node.js.

||
Cần test trước?

Dùng Test mode để thử webhook bằng giao dịch mô phỏng mà không ảnh hưởng dữ liệu thật.

Mỗi khi có giao dịch, endpoint (URL nhận webhook) của bạn nhận một HTTP POST từ SePay. Trả về HTTP 200 kèm body {"success": true} trong vòng 30 giây là kết thúc. Nếu thất bại, SePay tự gửi lại theo lịch retry.

Payload

Các bước tích hợp WebHooks

Mỗi khi có giao dịch khớp với cấu hình webhook, SePay sẽ gửi HTTP POST đến endpoint của bạn:

JSON
{
  "id": 92704,
  "gateway": "Vietcombank",
  "transactionDate": "2024-07-02 11:08:33",
  "accountNumber": "1017588888",
  "subAccount": "",
  "code": "SEVN63DC8E5C",
  "content": "SEVN63DC8E5C chuyen tien",
  "transferType": "in",
  "description": "NGUYEN VAN A chuyen tien",
  "transferAmount": 5000000,
  "accumulated": 105000000,
  "referenceCode": "FT24012345678"
}
TrườngKiểuNull / rỗng?Ghi chú
idintegerkhôngID giao dịch trên SePay. Giá trị này không đổi qua mọi lần retry và replay, dùng làm khóa chống trùng.
gatewaystringkhôngTên ngân hàng của giao dịch (ví dụ Vietcombank, BIDV, TPBank).
transactionDatestringkhôngĐịnh dạng YYYY-MM-DD HH:mm:ss, giờ Việt Nam.
accountNumberstringkhôngSố tài khoản ngân hàng.
subAccountstringcó thể rỗngVA khớp giao dịch. VA chính thức: số VA khách chuyển vào. TKP (VA nội dung): mã định danh trong nội dung chuyển khoản. Rỗng "" nếu không khớp.
codestringcó thể nullMã thanh toán (ví dụ DH123456), trích từ nội dung theo tiền tố ở Công ty → Cấu hình chung → Cấu trúc mã thanh toán. Không liên quan VA. null khi không khớp cấu hình nào.
contentstringkhôngNội dung chuyển khoản gốc từ ngân hàng, SePay không qua xử lý.
transferTypestringkhôngin (tiền vào) hoặc out (tiền ra).
descriptionstringcó thể rỗngMô tả đầy đủ từ ngân hàng. Một số ngân hàng không hỗ trợ, khi đó rỗng.
transferAmountintegerkhôngSố tiền giao dịch, đơn vị VNĐ, luôn dương.
accumulatedintegercó thể 0Số dư sau giao dịch. Một số ngân hàng không hỗ trợ trả số dư, khi đó là 0.
referenceCodestringcó thể rỗngMã tham chiếu từ ngân hàng.
Phân biệt null với chuỗi rỗng

code = null nghĩa là không có mã thanh toán, khác với mã rỗng. Cả null"" đều falsy trong PHP/JS, nếu cần phân biệt thì dùng $code === null thay vì if ($code).

Phản hồi hợp lệ

SePay chỉ tính là thành công khi phản hồi đủ 3 điều:

  1. HTTP status 200 hoặc 201.
  2. Body là JSON có success: true, đúng y nguyên dạng {"success": true}.
  3. Hoàn tất trong 30 giây.

Sai một trong ba điều trên là tính thất bại, kể cả khi server bạn đã nhận được request:

Phản hồiSePay hiểu là
200 hoặc 201 + {"success": true}Thành công
200 + body khác (ví dụ {"status": "ok"} hoặc rỗng)Thất bại (sai body)
Status khác 200/201 (kể cả 202, redirect, 4xx, 5xx)Thất bại
Không trả trong 30sTimeout
200 chỉ có nghĩa là đã nhận

Trả 200 ngay rồi đẩy xử lý sang queue hoặc background job. SePay chỉ cần biết endpoint đã nhận request. Xử lý đồng bộ chờ lâu dễ timeout, không nên.

Content-Type

Giá trịCách đọc
application/json (mặc định)json_decode($body) hoặc req.body
multipart/form-data$_POST['field'] hoặc req.body.field
application/x-www-form-urlencodedparse_str() hoặc form parser

Chống trùng lặp

Cùng một giao dịch có thể gửi webhook nhiều lần do:

  • Retry tự động khi endpoint trả lỗi (xem Xử lý lỗi)
  • Gửi lại thủ công từ trang quản lý webhook. Admin có thể replay cả log đã thành công
  • Nhiều webhook trỏ về cùng endpoint trong cùng một công ty

Đặt transaction_id là cột UNIQUE rồi dùng INSERT IGNORE. Cách này chặn trùng ngay từ database, an toàn cả khi 2 webhook đến đồng thời:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<?php
$payload = json_decode(file_get_contents('php://input'), true);
 
$stmt = $pdo->prepare('INSERT IGNORE INTO webhook_logs (transaction_id, payload) VALUES (?, ?)');
$stmt->execute([$payload['id'], json_encode($payload)]);
 
if ($stmt->rowCount() === 0) {
// Đã xử lý trước đó, trả OK để SePay không retry
echo json_encode(['success' => true]);
exit;
}
 
// ... business logic cho lần đầu tiên ...
 
echo json_encode(['success' => true]);

Nếu không kiểm tra trùng, bạn có thể cộng tiền hai lần hoặc xác nhận đơn hàng lặp lại. Endpoint không idempotent (an toàn khi gọi nhiều lần) thì chỉ một cú bấm replay cũng có thể tạo ra hàng loạt giao dịch giả.

Code mẫu

Hướng dẫn đầy đủ sẵn cho production (schema, HMAC, idempotent, checklist production):

Laravel user có thể dùng package sepayvn/laravel-sepay.

Tiếp theo