Use Test mode to exercise webhooks with simulated transactions without touching Live data.
On every transaction, your endpoint (the URL that receives the webhook) gets an HTTP POST from SePay. Return HTTP 200 with body {"success": true} within 30 seconds and you're done. On failure, SePay retries automatically per the retry schedule.
Payload
Steps to Integrate WebHooks
Whenever a transaction matches the configured conditions, SePay sends an HTTP POST to your endpoint:
{
"id": 92704,
"gateway": "Vietcombank",
"transactionDate": "2024-07-02 11:08:33",
"accountNumber": "1017588888",
"subAccount": "",
"code": "SEVN63DC8E5C",
"content": "SEVN63DC8E5C chuyen tien",
"transferType": "in",
"description": "NGUYEN VAN A chuyen tien",
"transferAmount": 5000000,
"accumulated": 105000000,
"referenceCode": "FT24012345678"
}| Field | Type | Null / empty? | Note |
|---|---|---|---|
id | integer | no | SePay transaction ID. Same value across retries and replays, use as the dedup key. |
gateway | string | no | Bank name for the transaction (e.g. Vietcombank, BIDV, TPBank). |
transactionDate | string | no | Format YYYY-MM-DD HH:mm:ss, Vietnam time. |
accountNumber | string | no | Bank account number. |
subAccount | string | can be empty | The VA matched for the transaction. Official VA: the VA number the customer transferred to. TKP (memo-based VA): the identifier in the transfer memo. Empty "" if no match. |
code | string | can be null | Payment code (e.g. DH123456), extracted from content via the prefix configured at Company → General settings → Payment code structure. Unrelated to VA. null when no configuration matches. |
content | string | no | Original transfer memo from the bank, no SePay processing. |
transferType | string | no | in (incoming) or out (outgoing). |
description | string | can be empty | Full description from the bank. Some banks don't support this, then it's empty. |
transferAmount | integer | no | Transaction amount in VND, always positive. |
accumulated | integer | can be 0 | Balance after the transaction. Some banks don't support this, then it's 0. |
referenceCode | string | can be empty | Reference code from the bank. |
code = null means there's no payment code, which is different from an empty code. Both null and "" are falsy in PHP/JS, so to distinguish them use $code === null instead of if ($code).
Valid response
SePay only counts as success when your response has all 3:
- HTTP status 200 or 201.
- Body is JSON with
success: true, exactly{"success": true}. - Returned within 30 seconds.
Anything else counts as failure even if your server received the request:
| Response | SePay reads as |
|---|---|
200 or 201 + {"success": true} | Success |
200 + a different body (e.g. {"status": "ok"} or empty) | Failure (wrong body) |
Status other than 200/201 (including 202, redirects, 4xx, 5xx) | Failure |
| No response in 30s | Timeout |
Return 200 immediately, then push processing into a queue or background job. SePay only needs to know the endpoint received the request. Synchronous processing risks timeouts.
Content-Type
| Value | How to read |
|---|---|
application/json (default) | json_decode($body) or req.body |
multipart/form-data | $_POST['field'] or req.body.field |
application/x-www-form-urlencoded | parse_str() or form parser |
Deduplication
The same transaction can be sent more than once because of:
- Auto retry when your endpoint returns an error (see Error handling)
- Manual replay from the webhook page. Admins can replay even successful logs.
- Multiple webhooks pointing to the same endpoint within one company
Add a UNIQUE constraint on the transaction_id column, then use INSERT IGNORE. This blocks duplicates at the database level and is safe even when two webhooks arrive at the exact same time:
<?php$payload = json_decode(file_get_contents('php://input'), true);$stmt = $pdo->prepare('INSERT IGNORE INTO webhook_logs (transaction_id, payload) VALUES (?, ?)');$stmt->execute([$payload['id'], json_encode($payload)]);if ($stmt->rowCount() === 0) {// Already processed, return OK so SePay does not retryecho json_encode(['success' => true]);exit;}// ... business logic for first time only ...echo json_encode(['success' => true]);
Without deduplication, you can double-count transactions or send duplicate order confirmations. A non-idempotent endpoint (one that isn't safe to call multiple times) can multiply transactions on a single replay click.
Code samples
Production-ready end-to-end tutorials (schema, HMAC, idempotency, production checklist):
- Build SePay webhook with PHP: PHP 8 + PDO + MySQL
- Build SePay webhook with Node.js (Express): Express + mysql2
Laravel users can use the sepayvn/laravel-sepay package.
Next
- Authentication: HMAC-SHA256, API Key, OAuth 2.0
- Security: HTTPS, IP whitelist, replay protection, validation
- Error handling: retry schedule + diagnostics