Build SePay webhook with PHP

Build a production-ready SePay webhook endpoint with PHP 8, PDO, and MySQL: HMAC-SHA256 auth and race-safe deduplication via INSERT IGNORE.

||

End-to-end tutorial for building a PHP endpoint that receives SePay webhooks: HMAC-SHA256 authentication, race-safe deduplication, MySQL storage. No framework, PHP 8+, PDO.

Requirements

  • PHP 8.0+
  • MySQL 5.7+ or MariaDB 10.3+
  • Extensions pdo_mysql, hash (in PHP core)
  • A public URL endpoint (HTTPS for production)

1. Create the 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;

Why:

  • sepay_id UNIQUE: dedup key. INSERT IGNORE skips the second record if the webhook retries.
  • BIGINT for amounts: VND has no decimal part, INT max 2.1 billion which overflows for B2B invoices.
  • body JSON: store the original payload for debugging or future querying via JSON_EXTRACT.

2. Create the webhook on the Dashboard

Dashboard → WebhooksAdd:

FieldValue
NamePHP webhook server
URLhttps://your-server.com/webhook.php
Event typeMoney in (or Both)
AccountPick the accounts to monitor
AuthHMAC-SHA256

Copy the Secret Key (shown in full only once), store it as a server env var:

Bash
1
2
# /etc/nginx/fastcgi_params or systemd env
SEPAY_WEBHOOK_SECRET=<your_secret_key>

3. PHP endpoint

Create 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
// Log errors, don't display to 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. Read raw body, must be the original bytes to match the signature
$body = file_get_contents('php://input');
if ($body === false || $body === '') {
http_response_code(400);
echo json_encode(['success' => false, 'message' => 'Empty body']);
exit;
}
 
// 2. Verify HMAC-SHA256
$signature = $_SERVER['HTTP_X_SEPAY_SIGNATURE'] ?? '';
$timestamp = (int) ($_SERVER['HTTP_X_SEPAY_TIMESTAMP'] ?? 0);
$secret = getenv('SEPAY_WEBHOOK_SECRET');
 
// Anti-replay: reject timestamps off by more than 5 minutes
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. INSERT IGNORE: race-safe dedup at DB level
$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 means already processed. Return OK so SePay doesn't retry.
echo json_encode(['success' => true]);
exit;
}
 
// 5. Business logic: runs only once per transaction
if ($data['transferType'] === 'in' && !empty($data['code'])) {
// Example: update orders
$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 jobs to send email, update stock, 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, no parse

HMAC-SHA256 signs the original bytes. Use file_get_contents('php://input'). If a framework (Laravel, Symfony, ...) parsed the body, the request must read the raw body BEFORE the framework parses. In Laravel: $request->getContent() returns the raw body.

4. Test the endpoint

Test send from Dashboard

Webhook → Test send. SePay sends a sample payload, shows HTTP status + response body immediately.

Real transaction

Transfer 10,000₫ into a linked account. Open Delivery logs → click the latest log row. Status Success = OK.

Query the DB:

SQL
1
SELECT * FROM transactions ORDER BY id DESC LIMIT 5;

5. Production checklist

  • HTTPS URL (Let's Encrypt is free)
  • Secret Key in env var, NOT hardcoded
  • display_errors = 0 (avoid leaking info in responses)
  • Log errors to a separate file (error_log)
  • Whitelist SePay IPs at the firewall/WAF
  • Cron reconciliation every 15-30 minutes
  • Monitor via Monitoring + Telegram/Slack/Discord alerts

Next