Build SePay webhook with Node.js

Build a production-ready SePay webhook endpoint with Node.js, Express, and mysql2: HMAC-SHA256 auth and race-safe deduplication via INSERT IGNORE.

||

End-to-end tutorial for building a Node.js + Express endpoint that receives SePay webhooks: HMAC-SHA256 authentication, race-safe deduplication, MySQL storage. Node 18+, ESM, mysql2.

Requirements

  • Node.js 18.0+ (native fetch, node:crypto)
  • MySQL 5.7+ or MariaDB 10.3+
  • express, mysql2
  • 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.

2. Create the webhook on the Dashboard

Dashboard → WebhooksAdd:

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

Copy the Secret Key (shown in full only once), store it in .env:

Bash
1
2
3
4
5
SEPAY_WEBHOOK_SECRET=<your_secret_key>
DB_HOST=localhost
DB_USER=db_user
DB_PASS=db_password
DB_NAME=sepay_webhook

3. Install dependencies

Bash
1
2
npm init -y
npm install express mysql2 dotenv

Add "type": "module" to package.json to enable ESM.

4. Node.js endpoint

Create server.js:

JSJavaScript
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
import 'dotenv/config';
import express from 'express';
import crypto from 'node:crypto';
import mysql from 'mysql2/promise';
 
const app = express();
 
const db = mysql.createPool({
host: process.env.DB_HOST,
user: process.env.DB_USER,
password: process.env.DB_PASS,
database: process.env.DB_NAME,
});
 
// IMPORTANT: keep the raw body for HMAC reconstruction
// express.raw() for /webhook/sepay only, NOT global express.json()
app.post(
'/webhook/sepay',
express.raw({ type: '*/*' }),
async (req, res) => {
try {
const body = req.body.toString('utf8');
if (!body) {
return res.status(400).json({ success: false, message: 'Empty body' });
}
 
// 1. Verify HMAC-SHA256
const signature = req.headers['x-sepay-signature'] ?? '';
const timestamp = Number(req.headers['x-sepay-timestamp'] ?? 0);
const secret = process.env.SEPAY_WEBHOOK_SECRET;
 
// Anti-replay: reject timestamps off by more than 5 minutes
if (Math.abs(Date.now() / 1000 - timestamp) > 300) {
return res.status(401).json({ success: false, message: 'Request expired' });
}
 
const expected = 'sha256=' + crypto.createHmac('sha256', secret)
.update(`${timestamp}.${body}`)
.digest('hex');
 
const sig = Buffer.from(signature);
const exp = Buffer.from(expected);
if (sig.length !== exp.length || !crypto.timingSafeEqual(sig, exp)) {
return res.status(401).json({ success: false, message: 'Invalid signature' });
}
 
// 2. Parse JSON
const data = JSON.parse(body);
if (!data?.id) {
return res.status(400).json({ success: false, message: 'Invalid payload' });
}
 
// 3. INSERT IGNORE: race-safe dedup at DB level
const [result] = await db.execute(
`INSERT IGNORE INTO transactions
(sepay_id, gateway, transaction_date, account_number, sub_account,
code, amount_in, amount_out, accumulated, content, reference_code, body)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[
data.id,
data.gateway,
data.transactionDate,
data.accountNumber,
data.subAccount ?? '',
data.code,
data.transferType === 'in' ? data.transferAmount : 0,
data.transferType === 'out' ? data.transferAmount : 0,
data.accumulated ?? 0,
data.content,
data.referenceCode ?? '',
body,
],
);
 
if (result.affectedRows === 0) {
// Already processed. Return OK so SePay doesn't retry.
return res.json({ success: true });
}
 
// 4. Business logic: runs only once per transaction
if (data.transferType === 'in' && data.code) {
// Example: update orders
await db.execute(
`UPDATE orders SET status = 'paid', paid_at = NOW()
WHERE code = ? AND status = 'pending' AND amount <= ?`,
[data.code, data.transferAmount],
);
 
// TODO: enqueue jobs to send email, update stock, etc.
}
 
res.json({ success: true });
 
} catch (err) {
console.error('SePay webhook error:', err);
res.status(500).json({ success: false, message: 'Internal error' });
}
}
);
 
// Health check for monitoring
app.get('/health', (_, res) => res.json({ ok: true }));
 
app.listen(3000, () => console.log('Listening on :3000'));
Raw body, no JSON parse

HMAC-SHA256 signs the original bytes. Use express.raw({ type: '*/*' }) for the /webhook/sepay route. Do NOT use app.use(express.json()) globally because it parses the body to an object and re-serializing with JSON.stringify won't match the original bytes (PHP escapes Unicode \uXXXX, JS doesn't).

For Fastify, Hono, Koa: read framework docs to access the raw body.

5. Run + test

Bash
1
node server.js

Local test with ngrok (expose localhost 3000 to the Internet):

Bash
1
2
ngrok http 3000
# Copy URL https://xxx.ngrok-free.app/webhook/sepay into webhook config

Test send from Dashboard

Webhook → Test send. Sample payload + HTTP result instantly.

Real transaction

Transfer 10,000₫ into a linked account. Open Delivery logs, check the newest log Status Success.

Query the DB:

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

6. Production checklist

  • HTTPS URL (Let's Encrypt is free)
  • Secret Key in .env, .env in gitignore, NOT committed
  • Process manager: PM2, systemd, or Docker for auto-restart
  • Log errors to file or service (Sentry, Datadog)
  • Whitelist SePay IPs at the firewall/WAF
  • Cron reconciliation every 15-30 minutes
  • Monitor via Monitoring + Telegram/Slack/Discord alerts
  • MySQL connection pool (mysql.createPool, not createConnection)

Next