How to integrate SePay webhooks

Integrate SePay webhooks into your server: payload structure, the valid response contract, deduplication, plus PHP and Node.js code samples.

||
Want to test first?

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:

JSON
{
  "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"
}
FieldTypeNull / empty?Note
idintegernoSePay transaction ID. Same value across retries and replays, use as the dedup key.
gatewaystringnoBank name for the transaction (e.g. Vietcombank, BIDV, TPBank).
transactionDatestringnoFormat YYYY-MM-DD HH:mm:ss, Vietnam time.
accountNumberstringnoBank account number.
subAccountstringcan be emptyThe 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.
codestringcan be nullPayment 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.
contentstringnoOriginal transfer memo from the bank, no SePay processing.
transferTypestringnoin (incoming) or out (outgoing).
descriptionstringcan be emptyFull description from the bank. Some banks don't support this, then it's empty.
transferAmountintegernoTransaction amount in VND, always positive.
accumulatedintegercan be 0Balance after the transaction. Some banks don't support this, then it's 0.
referenceCodestringcan be emptyReference code from the bank.
Distinguishing null from empty string

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:

  1. HTTP status 200 or 201.
  2. Body is JSON with success: true, exactly {"success": true}.
  3. Returned within 30 seconds.

Anything else counts as failure even if your server received the request:

ResponseSePay 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 30sTimeout
200 only means received

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

ValueHow to read
application/json (default)json_decode($body) or req.body
multipart/form-data$_POST['field'] or req.body.field
application/x-www-form-urlencodedparse_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:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<?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 retry
echo 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):

Laravel users can use the sepayvn/laravel-sepay package.

Next