Hướng dẫn đầy đủ để dựng một endpoint PHP nhận webhook SePay: xác thực HMAC-SHA256, chống trùng giao dịch an toàn khi đồng thời, lưu vào MySQL. Không cần framework, chỉ dùng PHP 8+ và PDO.
Yêu cầu
- PHP 8.0+
- MySQL 5.7+ hoặc MariaDB 10.3+
- Extension
pdo_mysql,hash(có sẵn PHP core) - URL endpoint công khai (HTTPS cho production)
1. Tạo database
CREATE DATABASE sepay_webhook CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;USE sepay_webhook;CREATE TABLE transactions (id BIGINT AUTO_INCREMENT PRIMARY KEY,sepay_id BIGINT NOT NULL UNIQUE,gateway VARCHAR(100) NOT NULL,transaction_date DATETIME NOT NULL,account_number VARCHAR(100),sub_account VARCHAR(250),code VARCHAR(250),amount_in BIGINT NOT NULL DEFAULT 0,amount_out BIGINT NOT NULL DEFAULT 0,accumulated BIGINT NOT NULL DEFAULT 0,content TEXT,reference_code VARCHAR(255),body JSON NOT NULL,created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,INDEX idx_code (code),INDEX idx_account (account_number, transaction_date)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
Lý do:
sepay_id UNIQUE(khoá duy nhất): khi webhook retry gửi cùng giao dịch,INSERT IGNOREsẽ lặng lẽ bỏ qua bản ghi trùng thay vì báo lỗi.BIGINTcho tiền: VND không có phần thập phân,INTmax 2.1 tỷ sẽ tràn với đơn B2B.body JSON: lưu payload gốc để debug hoặc query sau bằngJSON_EXTRACT.
2. Tạo webhook trên Dashboard
Dashboard → Webhooks → Thêm:
| Trường | Giá trị |
|---|---|
| Tên | Webhook server PHP |
| URL | https://your-server.com/webhook.php |
| Loại sự kiện | Tiền vào (hoặc Cả hai) |
| Tài khoản | Chọn tài khoản cần theo dõi |
| Xác thực | HMAC-SHA256 |
Copy Secret Key (chỉ hiện đầy đủ 1 lần), lưu vào biến môi trường server:
# /etc/nginx/fastcgi_params hoặc systemd envSEPAY_WEBHOOK_SECRET=<secret_key_của_bạn>
3. Endpoint PHP
Tạo file webhook.php:
<?php// Bật error log, tắt hiển thị lỗi ra clientini_set('log_errors', '1');ini_set('display_errors', '0');header('Content-Type: application/json');$pdo = new PDO('mysql:host=localhost;dbname=sepay_webhook;charset=utf8mb4','db_user','db_password',[PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,PDO::ATTR_EMULATE_PREPARES => false,]);try {// 1. Đọc raw body (bytes gốc, chưa parse) để chữ ký HMAC khớp được$body = file_get_contents('php://input');if ($body === false || $body === '') {http_response_code(400);echo json_encode(['success' => false, 'message' => 'Empty body']);exit;}// 2. Xác thực HMAC-SHA256$signature = $_SERVER['HTTP_X_SEPAY_SIGNATURE'] ?? '';$timestamp = (int) ($_SERVER['HTTP_X_SEPAY_TIMESTAMP'] ?? 0);$secret = getenv('SEPAY_WEBHOOK_SECRET');// Chống replay: timestamp lệch quá 5 phút bị từ chốiif (abs(time() - $timestamp) > 300) {http_response_code(401);echo json_encode(['success' => false, 'message' => 'Request expired']);exit;}$expected = 'sha256=' . hash_hmac('sha256', $timestamp . '.' . $body, $secret);if (!hash_equals($expected, $signature)) {http_response_code(401);echo json_encode(['success' => false, 'message' => 'Invalid signature']);exit;}// 3. Parse JSON$data = json_decode($body, true);if (!is_array($data) || empty($data['id'])) {http_response_code(400);echo json_encode(['success' => false, 'message' => 'Invalid payload']);exit;}// 4. Chống trùng giao dịch ở tầng database: INSERT IGNORE bỏ qua nếu sepay_id đã tồn tại$sql = 'INSERT IGNORE INTO transactions(sepay_id, gateway, transaction_date, account_number, sub_account,code, amount_in, amount_out, accumulated, content, reference_code, body)VALUES (:id, :gateway, :date, :account, :sub,:code, :in, :out, :accumulated, :content, :ref, :body)';$stmt = $pdo->prepare($sql);$stmt->execute(['id' => $data['id'],'gateway' => $data['gateway'],'date' => $data['transactionDate'],'account' => $data['accountNumber'],'sub' => $data['subAccount'] ?? '','code' => $data['code'],'in' => $data['transferType'] === 'in' ? $data['transferAmount'] : 0,'out' => $data['transferType'] === 'out' ? $data['transferAmount'] : 0,'accumulated' => $data['accumulated'] ?? 0,'content' => $data['content'],'ref' => $data['referenceCode'] ?? '','body' => $body,]);if ($stmt->rowCount() === 0) {// rowCount = 0 nghĩa là đã xử lý trước đó. Trả OK để SePay không retry.echo json_encode(['success' => true]);exit;}// 5. Business logic: chỉ chạy khi giao dịch lần đầu được lưu (INSERT thành công)if ($data['transferType'] === 'in' && !empty($data['code'])) {// Ví dụ: cập nhật đơn hàng$update = $pdo->prepare("UPDATE orders SET status = 'paid', paid_at = NOW()WHERE code = :code AND status = 'pending' AND amount <= :amount");$update->execute(['code' => $data['code'],'amount' => $data['transferAmount'],]);// TODO: enqueue job gửi email, cập nhật kho, etc.}echo json_encode(['success' => true]);} catch (Throwable $e) {error_log('SePay webhook error: ' . $e->getMessage());http_response_code(500);echo json_encode(['success' => false, 'message' => 'Internal error']);}
HMAC-SHA256 ký trên bytes gốc. Dùng file_get_contents('php://input'). Nếu framework (Laravel, Symfony, ...) đã parse body, request phải read được raw body TRƯỚC khi framework parse. Laravel: $request->getContent() trả raw.
4. Test endpoint
Gửi thử từ Dashboard
Webhook → ⋮ → Gửi thử. SePay gửi payload mẫu, hiện kết quả HTTP status + response body ngay.
Giao dịch thật
Chuyển 10.000₫ vào tài khoản đã liên kết. Mở Lịch sử gửi → bấm dòng log mới nhất. Status Thành công = OK.
Query DB check:
SELECT * FROM transactions ORDER BY id DESC LIMIT 5;
5. Checklist production
- URL HTTPS (Let's Encrypt miễn phí)
- Secret Key lưu biến môi trường, KHÔNG hardcode
-
display_errors = 0(tránh lộ info trong response) - Log errors vào file riêng (
error_log) - Whitelist IP SePay ở firewall/WAF
- Cron đối soát giao dịch 15-30 phút/lần
- Monitor via Giám sát + cảnh báo Telegram/Slack/Discord
Tiếp theo
- Tích hợp webhook: payload schema, response contract đầy đủ
- Xác thực: HMAC flow, code Python/Node
- Bảo mật: checklist endpoint
- Node.js + Express: cùng tutorial bằng Node