Why reconcile?
Webhooks deliver transactions in real time, but sometimes they don't reach your server:
- Server is briefly down (deploy, restart, downtime)
- Network error between SePay and your server
- Webhook timed out because your server was too slow
- Out of retry attempts (max 7 within 33 minutes)
To never miss a transaction, run periodic reconciliation against SePay API.
Steps
1. Fetch transactions from SePay
Call the API to get transactions in the period to reconcile:
https://userapi.sepay.vn/v2/transactionsYYYY-MM-DD HH:mm:ssYYYY-MM-DD HH:mm:ssFull parameter list: SePay API - Transactions list.
Example: fetch transactions for 01/03/2026
curl -X GET "https://userapi.sepay.vn/v2/transactions?transaction_date_from=2026-03-01%2000:00:00&transaction_date_to=2026-03-01%2023:59:59&per_page=100" \-H "Content-Type: application/json" \-H "Authorization: Bearer YOUR_API_TOKEN"
2. Compare with your database
Compare SePay's transactions against your database. Use the id (UUID) or reference_number field to identify missing transactions.
3. Backfill missing transactions
Transactions present at SePay but missing in your database: insert and run business logic (update orders, mark payments, ...).
Code samples
<?php$token = getenv('SEPAY_API_TOKEN');$pdo = new PDO('mysql:host=localhost;dbname=db_name;charset=utf8mb4', 'db_user', 'db_pass',[PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]);$dateFrom = date('Y-m-d H:i:s', strtotime('-24 hours'));$dateTo = date('Y-m-d H:i:s');// 1. Fetch last 24h transactions from SePay$url = 'https://userapi.sepay.vn/v2/transactions?' . http_build_query(['transaction_date_from' => $dateFrom,'transaction_date_to' => $dateTo,'per_page' => 100,]);$ctx = stream_context_create(['http' => ['header' => "Authorization: Bearer $token\r\nContent-Type: application/json",]]);$result = json_decode(file_get_contents($url, false, $ctx), true);$transactions = $result['data'] ?? [];printf("SePay: %d transactions\n", count($transactions));// 2. What's already in DB?$stmt = $pdo->prepare('SELECT reference_number FROM tb_transactions WHERE created_at >= ?');$stmt->execute([$dateFrom]);$existing = array_flip($stmt->fetchAll(PDO::FETCH_COLUMN));// 3. Backfill missing. UNIQUE(sepay_id) prevents duplicates.$insert = $pdo->prepare('INSERT IGNORE INTO tb_transactions(sepay_id, gateway, transaction_date, account_number, sub_account,amount_in, amount_out, accumulated, code, transaction_content, reference_number)VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)');$missing = 0;foreach ($transactions as $tx) {if (isset($existing[$tx['reference_number']])) continue;$insert->execute([$tx['id'], $tx['bank_brand_name'], $tx['transaction_date'],$tx['account_number'], $tx['va'] ?? '',$tx['amount_in'] ?? 0, $tx['amount_out'] ?? 0, $tx['accumulated'] ?? 0,$tx['code'], $tx['transaction_content'], $tx['reference_number'],]);$missing++;}printf("Done. Backfilled %d transactions.\n", $missing);
Set up a cron job to reconcile automatically, e.g. every hour:
0 * * * * node /path/to/reconcile.js >> /var/log/reconcile.log 2>&1
Reconciliation strategies
Pick one depending on how often your reconciler runs:
- Rate limit: API limited to 3 requests/second. Exceeding gets HTTP 429.
- Deduplication: Always check
id(UUID) orreference_numberbefore inserting. - Pagination: Use
pageandper_page(max 100). For large data, narrow the time range or usesince_id. - API docs: SePay API - Transactions list.
Next
- QR code and payment page: full payment page sample
- Incidents: see lost transactions from failing webhooks and replay them
- Integrate webhook: deduplication when webhooks and reconciliation run in parallel