Refund idempotency and reconciliation

How to shape your idempotency key, a safe send flow for timeouts, and the fields to store so refunds reconcile with SePay later.

||

A refund moves money out, so sending one twice loses real money. This page covers how to shape the idempotency key, what to do with each response code, and what to store for later reconciliation.

How the idempotency key works

Every refund carries an X-Idempotency-Key header that you generate. SePay compares that key against the request body to decide:

You sendWhat SePay does
A new keyCreates a new refund
An old key, identical contentReturns the existing record, no second payout
An old key, changed contentRefuses with 409 idempotency_key_reused

"Content" here means whatever decides where the money goes and how much: the refund subject, transaction_id and refund_amount. Change any of those while keeping the key and you get a 409.

Replaying the key of a failed refund is not a retry

A refund that is failed or rejected is final. Sending the same key again returns that same error; SePay does not issue a new refund to the bank. To try again, generate a new key.

Shaping the key

The key is your own string. SePay only uses it to detect duplicates and never sends it anywhere. Build it from values that already exist and are unique in your system:

Code
1
2
3
refund-{order_code}-{refund_attempt}
refund-DH20250001-01
refund-DH20250001-02

Rules for the key:

  • One refund, one key. Two instalments refunded on the same order are two different keys.
  • Generate the key before calling and persist it immediately. Generate it in memory and the request times out, and you have lost the key you needed to resend that exact refund.
  • Do not use plain random values. A resend has to reproduce the same key, so derive it from business data.
  • At most 100 characters. A longer key answers 422 validation_error with the error on idempotency_key.
`refund_id` is SePay's handle, not your key

SePay issues refund_id in the form RF-7QK3-M8PZ-4VNW. It is random and cannot be derived from the key you sent. It is the handle you look refunds up by and quote to support, so store the mapping between your key and refund_id as soon as the response arrives.

A safe send flow

Safe Refund Send Flow
Rendering diagram...

Three situations are worth an automatic resend: 503 refund_busy, 503 vietinbank_map_connection_error, and a connection lost on your side. All three resend the exact same key, because the key is what keeps a resend from becoming a second payout.

The two 503 codes differ in whether a record exists. refund_busy created nothing; another refund is running against the same original payment. vietinbank_map_connection_error did create the record and left it pending, because whether the bank received it is unknown, so SePay keeps it to reconcile later. Resending the same key is safe for both: in the second case you get that pending record back.

500 unexpected_error behaves the same way: the refund may already have reached the bank, so resend the same key or look it up via List refunds. Never generate a new key.

Every other 422 is a business error and resending it verbatim produces the same result. Read error_code and handle each case; the tables are on Refund an order.

What to store for reconciliation

Persist at least these fields on your own refund record:

FieldWhy you need it
Your idempotency keyTo resend the exact same refund after a timeout
refund_idThe lookup handle, and what SePay asks for on a support request
transaction_idThe original payment being refunded, for per-payment totals
amountWhat this refund returns
statusThe final answer, for your books
created_atTimestamp to line up against a statement

original_reference and xid are optional, but keeping them makes it easier to reconcile a specific refund with the bank through SePay.

Only book a refund once status is succeeded

A pending refund holds budget on the original payment but the money may not have moved. Totalling refunded amounts from pending rows leaves your books wrong if one later turns failed.

Periodic reconciliation

Call List refunds and filter by status:

cURL
1
2
curl -X GET "https://userapi.sepay.vn/v2/bank-accounts/{ba_uuid}/refunds?status=succeeded&per_page=100" \
-H "Authorization: Bearer YOUR_API_TOKEN"
GoalFilter
Reconcile what was actually refundedstatus=succeeded
Find refunds still awaiting an answerstatus=pending
See how often one payment was refundedtransaction={transaction_id}
See every refund on one orderorder={order_xid}

Compare the status you hold against the one returned. Anything still pending on your side that SePay has settled needs updating; anything SePay has that you have no record of is a refund whose response you lost, and transaction_id reconnects it.

See Also