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
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 IGNOREskips the second record if the webhook retries.BIGINTfor amounts: VND has no decimal part,INTmax 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 → Webhooks → Add:
| Field | Value |
|---|---|
| Name | Node webhook server |
| URL | https://your-server.com/webhook/sepay |
| Event type | Money in (or Both) |
| Account | Pick the accounts to monitor |
| Auth | HMAC-SHA256 |
Copy the Secret Key (shown in full only once), store it in .env:
SEPAY_WEBHOOK_SECRET=<your_secret_key>DB_HOST=localhostDB_USER=db_userDB_PASS=db_passwordDB_NAME=sepay_webhook
3. Install dependencies
npm init -ynpm install express mysql2 dotenv
Add "type": "module" to package.json to enable ESM.
4. Node.js endpoint
Create server.js:
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-SHA256const 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 minutesif (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 JSONconst 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 levelconst [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 transactionif (data.transferType === 'in' && data.code) {// Example: update ordersawait 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 monitoringapp.get('/health', (_, res) => res.json({ ok: true }));app.listen(3000, () => console.log('Listening on :3000'));
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
node server.js
Local test with ngrok (expose localhost 3000 to the Internet):
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:
SELECT * FROM transactions ORDER BY id DESC LIMIT 5;
6. Production checklist
- HTTPS URL (Let's Encrypt is free)
- Secret Key in
.env,.envin 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, notcreateConnection)
Next
- Integrate webhook: full payload schema, response contract
- Authentication: HMAC flow, Python code
- Security: endpoint checklist
- PHP + MySQL: same tutorial in PHP