Xác thực webhook SePay thế nào?

So sánh 4 cách xác thực webhook SePay (HMAC-SHA256, API Key, OAuth 2.0, không xác thực) kèm code mẫu đầy đủ trong PHP, Node.js và Python.

||

SePay hỗ trợ 4 cách xác thực. Bạn chọn cách lúc tạo webhook và có thể đổi lại bất cứ lúc nào.

CáchBảo mậtĐộ khóKhi nào dùng
Không xác thựcThấpDễChỉ test, không dùng production
API KeyTrung bìnhDễYêu cầu xác thực cơ bản
HMAC-SHA256CaoTrung bìnhKhuyến nghị. Phát hiện ngay nếu payload bị sửa giữa đường truyền
OAuth 2.0CaoCaoHệ thống đã có OAuth server sẵn

Không xác thực

Không có bước xác thực nào. SePay gửi thẳng webhook đến URL của bạn, không kèm header bảo mật, và server của bạn không có cách nào kiểm tra request có thực sự đến từ SePay hay không.

Chỉ nên dùng khi test trong môi trường nội bộ. Không bao giờ dùng cho production, vì bất kỳ ai biết URL đều có thể gửi request giả mạo đến endpoint của bạn.

API Key

SePay gửi kèm header Authorization, server bạn so sánh với giá trị đã cấu hình.

Code
1
Authorization: Apikey YOUR_API_KEY

Code kiểm tra

1
2
3
4
5
6
7
8
9
10
11
12
13
<?php
$expected = getenv('SEPAY_API_KEY');
$auth = $_SERVER['HTTP_AUTHORIZATION'] ?? '';
 
if (!str_starts_with($auth, 'Apikey ') || !hash_equals($expected, substr($auth, 7))) {
http_response_code(401);
echo json_encode(['success' => false, 'message' => 'Unauthorized']);
exit;
}
 
$payload = json_decode(file_get_contents('php://input'), true);
// ... xử lý ...
echo json_encode(['success' => true]);

Cấu hình

Chọn API Key ở bước Bảo mật khi tạo webhook, nhập key rồi lưu vào biến môi trường trên server.

Cấu hình API Key
Nhấn để phóng to
Cấu hình API Key khi tạo webhook
API Key chỉ hiện đầy đủ một lần

Sau khi lưu, mở lại chỉ thấy 4 ký tự cuối (****xxxx). SePay không lưu bản rõ. Copy vào biến môi trường ngay khi tạo. Quên hoặc nghi bị lộ thì tạo API Key mới.

API Key chỉ xác minh request đến từ SePay, nhưng không bảo vệ payload nếu có ai chen ngang sửa đổi giữa đường truyền. Cần bảo mật hơn thì dùng HMAC-SHA256.

HMAC-SHA256

Cách xác thực an toàn nhất. SePay ký từng request bằng chữ ký số rồi gửi kèm trong header. Server của bạn tái tạo chữ ký theo cùng công thức rồi so sánh để xác minh request là thật.

Headers SePay gửi

X-SePay-Signaturestring
Chữ ký, định dạng sha256={hex_hash}
X-SePay-Timestampstring
Unix timestamp (giây) lúc ký

Cách SePay ký

  1. Lấy timestamp hiện tại (Unix seconds)
  2. Ghép chuỗi: {timestamp}.{raw_body}
  3. Tính HMAC-SHA256 với Secret Key
  4. Gửi header X-SePay-Signature: sha256={hex_hash}
  5. Gửi header X-SePay-Timestamp: {timestamp}
Dùng raw body, không dùng body đã parse

SePay ký bytes gốc của body. Nếu middleware (express.json(), Fastify default...) đã parse rồi bạn JSON.stringify(req.body) lại thì chữ ký sẽ lệch vì:

  • PHP escape Unicode thành \uXXXX, JavaScript thì không
  • Thứ tự khóa JSON có thể đổi
  • Khoảng trắng có thể khác

Cách đọc raw body: Node.js express.raw({ type: 'application/json' }), PHP file_get_contents('php://input'), Python request.get_data(as_text=True).

Webhook dùng form-encoded body

Nếu webhook cấu hình application/x-www-form-urlencoded hoặc multipart/form-data, SePay ký chuỗi form-encoded (giống http_build_query của PHP). Vẫn dùng raw body.

Code kiểm tra chữ ký

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
<?php
$signature = $_SERVER['HTTP_X_SEPAY_SIGNATURE'] ?? '';
$timestamp = (int) ($_SERVER['HTTP_X_SEPAY_TIMESTAMP'] ?? 0);
$body = file_get_contents('php://input');
$secret = getenv('SEPAY_WEBHOOK_SECRET');
 
// Kiểm tra timestamp chống replay (±5 phút)
if (abs(time() - $timestamp) > 300) {
http_response_code(401);
echo json_encode(['success' => false, 'message' => 'Request expired']);
exit;
}
 
// Tái tạo chữ ký và so sánh constant-time
$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;
}
 
$payload = json_decode($body, true);
// ... xử lý giao dịch ...
echo json_encode(['success' => true]);

Cấu hình

  1. Tạo/sửa webhook, chọn HMAC-SHA256 ở bước Bảo mật
  2. Nhập Secret Key hoặc bấm nút tạo tự động
  3. Lưu Secret Key vào biến môi trường trên server (SEPAY_WEBHOOK_SECRET)
Cấu hình HMAC-SHA256
Nhấn để phóng to
Cấu hình HMAC-SHA256 khi tạo webhook
Bảo quản Secret Key

Sau khi lưu, mở lại chỉ thấy 4 ký tự cuối (****xxxx) — SePay không lưu bản rõ. Copy vào biến môi trường ngay khi tạo, không commit vào source code, không gửi qua email/chat. Quên hoặc nghi bị lộ thì tạo Secret Key mới.

Lỗi thường gặp

Vấn đềNguyên nhânCách sửa
Chữ ký không khớpMiddleware parse body rồi serialize lạiDùng raw body gốc
Chữ ký không khớpSai Secret KeyKiểm tra biến môi trường
Chữ ký không khớpThiếu timestamp trong chuỗi kýĐúng format: {timestamp}.{body}
Timestamp quá cũĐồng hồ server lệchBật NTP để đồng bộ thời gian tự động

OAuth 2.0

Cách hoạt động: SePay gọi token endpoint của bạn để lấy access token, sau đó gửi webhook kèm header Authorization: Bearer {access_token}. Khi token sắp hết hạn, SePay tự refresh hoặc xin token mới.

Cấu hình OAuth 2.0
Nhấn để phóng to
Cấu hình OAuth 2.0 khi tạo webhook

Hai định dạng hỗ trợ

Dạng chuẩnDạng tùy chỉnh
Requestapplication/x-www-form-urlencodedapplication/json
Bodygrant_type=client_credentials{"clientId": "...", "clientSecret": "..."}
Response{"access_token": "...", "expires_in": 3600}{"data": {"accessToken": "...", "expiredIn": 3600}}

Khi xin token, SePay thử dạng chuẩn trước. Phản hồi không đúng format thì tự chuyển sang dạng tùy chỉnh. Tích hợp mới nên dùng dạng chuẩn; hệ thống đang chạy ổn dạng tùy chỉnh thì cứ để nguyên.

Dạng chuẩn

OAuth 2.0 client credentials. Hầu hết framework đều hỗ trợ sẵn. SePay gửi request tới token endpoint của bạn:

>
>
>
>
curl -X POST https://your-server.com/oauth/token \
-H "Content-Type: application/x-www-form-urlencoded" \
-u "YOUR_CLIENT_ID:YOUR_CLIENT_SECRET" \
-d "grant_type=client_credentials"

Tham số body (application/x-www-form-urlencoded):

grant_typestringrequired
Luôn là client_credentials
client_idstringrequired
Client ID đã cấu hình trên Webhooks
client_secretstringrequired
Client Secret đã cấu hình trên Webhooks

Response:

RESPONSE
{
  "access_token": "eyJhbGci...",
  "token_type": "Bearer",
  "expires_in": 3600,
  "refresh_token": "dGhpcyBpcyBh..."
}
access_tokenstringrequired
Token SePay dùng ở header Authorization: Bearer {access_token}
expires_ininteger
Thời gian hết hạn (giây). Mặc định 3600 nếu không trả
refresh_tokenstring
Refresh token. Không có thì SePay xin token mới từ đầu

Code mẫu server endpoint:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
<?php
$auth = $_SERVER['HTTP_AUTHORIZATION'] ?? '';
$grantType = $_POST['grant_type'] ?? '';
 
if ($grantType !== 'client_credentials') {
http_response_code(400);
echo json_encode(['error' => 'unsupported_grant_type']);
exit;
}
 
$expected = 'Basic ' . base64_encode(getenv('CLIENT_ID') . ':' . getenv('CLIENT_SECRET'));
if (!hash_equals($expected, $auth)) {
http_response_code(401);
echo json_encode(['error' => 'invalid_client']);
exit;
}
 
echo json_encode([
'access_token' => bin2hex(random_bytes(32)),
'token_type' => 'Bearer',
'expires_in' => 3600,
]);

Dạng tùy chỉnh

Tương thích ngược cho hệ thống đã tích hợp trước. Không cần chuyển nếu đang chạy ổn. SePay gửi request tới token endpoint của bạn:

>
>
>
curl -X POST https://your-server.com/oauth/token \
-H "Content-Type: application/json" \
-d '{"clientId":"YOUR_CLIENT_ID","clientSecret":"YOUR_CLIENT_SECRET"}'

Tham số body (application/json):

clientIdstringrequired
Client ID đã cấu hình trên Webhooks
clientSecretstringrequired
Client Secret đã cấu hình trên Webhooks

Response:

RESPONSE
{
  "data": {
    "accessToken": "eyJhbGci...",
    "refreshToken": "eyJhbGci...",
    "expiredIn": 3600
  }
}
data.accessTokenstringrequired
Access token
data.refreshTokenstringrequired
Refresh token
data.expiredInintegerrequired
Thời gian hết hạn (giây)

Refresh Token

Token còn dưới 10 giây thì SePay refresh. Refresh thất bại thì xin token mới từ đầu.

Dạng chuẩn: grant_type=refresh_token + refresh_token dạng form-urlencoded.

Dạng tùy chỉnh: JSON {"clientId": "...", "clientSecret": "...", "refreshToken": "..."}.

SePay thử dạng chuẩn trước, lỗi thì chuyển sang dạng tùy chỉnh.

Gửi webhook

Có token rồi, SePay gửi:

POST https://your-webhook-url
Authorization: Bearer eyJhbGci...
Content-Type: application/json

Endpoint trả 200/201 kèm {"success": true} là thành công. Mọi kết quả khác đều bị tính là thất bại.

Lưu ý

  • Token endpoint phải HTTPS
  • Token endpoint lỗi thì retry theo cùng lịch webhook delivery, xem Xử lý lỗi
  • Bị lỗi xác thực OAuth và webhook không đến? Xem Chẩn đoán OAuth 2.0
Client Secret chỉ hiện đầy đủ một lần

Mở lại chỉ thấy 4 ký tự cuối (****xxxx). SePay không lưu bản rõ. Copy ngay khi tạo. Quên thì tạo Client Secret mới (Client ID giữ nguyên).

Tiếp theo