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 send | What SePay does |
|---|---|
| A new key | Creates a new refund |
| An old key, identical content | Returns the existing record, no second payout |
| An old key, changed content | Refuses 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.
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:
refund-{order_code}-{refund_attempt}refund-DH20250001-01refund-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_errorwith the error onidempotency_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
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:
| Field | Why you need it |
|---|---|
| Your idempotency key | To resend the exact same refund after a timeout |
refund_id | The lookup handle, and what SePay asks for on a support request |
transaction_id | The original payment being refunded, for per-payment totals |
amount | What this refund returns |
status | The final answer, for your books |
created_at | Timestamp 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.
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 -X GET "https://userapi.sepay.vn/v2/bank-accounts/{ba_uuid}/refunds?status=succeeded&per_page=100" \-H "Authorization: Bearer YOUR_API_TOKEN"
| Goal | Filter |
|---|---|
| Reconcile what was actually refunded | status=succeeded |
| Find refunds still awaiting an answer | status=pending |
| See how often one payment was refunded | transaction={transaction_id} |
| See every refund on one order | order={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.