Lập trình webhook SePay với PHP

Dựng endpoint webhook SePay bằng PHP 8 + PDO + MySQL đạt chuẩn production: xác thực HMAC-SHA256, chống trùng race-safe với INSERT IGNORE.

||

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

SQL
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
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 IGNORE sẽ lặng lẽ bỏ qua bản ghi trùng thay vì báo lỗi.
  • BIGINT cho tiền: VND không có phần thập phân, INT max 2.1 tỷ sẽ tràn với đơn B2B.
  • body JSON: lưu payload gốc để debug hoặc query sau bằng JSON_EXTRACT.

2. Tạo webhook trên Dashboard

Dashboard → WebhooksThêm:

TrườngGiá trị
TênWebhook server PHP
URLhttps://your-server.com/webhook.php
Loại sự kiệnTiền vào (hoặc Cả hai)
Tài khoảnChọn tài khoản cần theo dõi
Xác thựcHMAC-SHA256

Copy Secret Key (chỉ hiện đầy đủ 1 lần), lưu vào biến môi trường server:

Bash
1
2
# /etc/nginx/fastcgi_params hoặc systemd env
SEPAY_WEBHOOK_SECRET=<secret_key_của_bạn>

3. Endpoint PHP

Tạo file webhook.php:

PHPPHP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
<?php
// Bật error log, tắt hiển thị lỗi ra client
ini_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ối
if (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']);
}
Raw body, không parse

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:

SQL
1
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