Send one refund, SePay forwards it to the bank and returns a refund_id you follow until there is a final answer.
Refunds currently apply to VietinBank enterprise accounts. An account at another bank returns 422 unsupported_bank; an account that has not been enabled returns 422 not_available. The Sandbox does not apply this gate, so a refund that works there can still return 422 not_available in production.
Every refund must carry an X-Idempotency-Key header that you generate. Without it SePay returns 422 idempotency_key_required instead of running the refund.
Why: if a request times out and you send it again, the key lets SePay return the existing record. Without a key, the retry is a second payout, and the refundable budget only catches that for a full refund, never for a partial one.
Reusing an old key with a different payload returns 409 idempotency_key_reused. Every distinct refund needs its own key.
Amount rules
| You send | SePay refunds |
|---|---|
No refund_amount | The whole remaining amount of the original payment |
refund_amount below the remainder | Exactly that, and the rest stays refundable |
refund_amount above the remainder | Rejected, 422 with an error on refund_amount |
| Original payment already fully refunded | Rejected, 422 already_refunded |
The budget belongs to the original payment
The refundable budget is not the order total; it belongs to each collection payment that arrived. Refunds on one payment never total more than what that payment collected. Refunds still in pending hold budget too, so an in-flight refund cannot leave room for a second one to exceed what was collected.
That is what shapes the order route:
| The order has | No transaction_id | With transaction_id |
|---|---|---|
| No collection payment | 422 no_original_transaction | 422 no_original_transaction |
| Exactly one payment | SePay picks it, the budget is that payment's amount | Uses the payment you named |
| Two or more payments | 422 transaction_required | Uses the payment you named, with that payment's own budget |
Take an order of 500,000 VND paid in two instalments of 300,000 and 200,000: the order has two collection payments, so refunding by order without a transaction_id returns 422 transaction_required. Naming the 300,000 instalment lets you refund up to 300,000 against it; returning the rest takes a second refund naming the 200,000 instalment.
On an order paid in instalments, each payment carries its own budget and needs its own refund with its own idempotency key. Do not send refund_amount equal to the order's total paid_amount: it will be refused for exceeding the chosen payment's budget.
Refund by order or by transaction
Both endpoints run through the same layer and the same budget. They differ only in how you point at the payment:
| Refund by order | Refund by transaction | |
|---|---|---|
| Addressed by | Order UUID | Collection transaction id |
| Picking the payment | Automatic when the order has exactly one, otherwise pass transaction_id | Already in the path |
| Works for a payment with no order | No | Yes |
order_type in the response | va_order | va_order when the payment belongs to a VA order, null when it belongs to no order |
Updates the order's refund_status | Yes | Yes, when the payment belongs to an order |
| Budget | The chosen payment's amount | That payment's amount |
Because the budget follows the original payment rather than the route, refunding a payment through the transaction endpoint and then calling the order endpoint for the same payment shows the remainder already deducted. Switching endpoints cannot pay out twice.
The order keeps its status and paid_amount. A refund is its own record, read it through List refunds.
Refund statuses
| Status | Meaning |
|---|---|
pending | In flight, the bank has not answered yet |
succeeded | The bank confirmed the money moved |
failed | The bank confirmed the money did not move |
rejected | The bank refused the request outright |
The last three are terminal and never change. A refund that reads succeeded never goes back to pending, so you can act on the answer as soon as you see it.
SePay refreshes refunds that are still pending. You only read Refund detail, or filter the list by status.
Endpoints
https://userapi.sepay.vn/v2/bank-accounts/{ba_xid}/orders/{order_xid}/refundhttps://userapi.sepay.vn/v2/transactions/{transaction_id}/refundhttps://userapi.sepay.vn/v2/bank-accounts/{ba_xid}/refundshttps://userapi.sepay.vn/v2/bank-accounts/{ba_xid}/refunds/{refund_id}Integration Steps
Prerequisites
- Create an API Token
- A linked bank account at a bank that supports refunds, with SePay having enabled refunds on that specific account. Without that, creating a refund returns
422 not_available - Get the account UUID from Bank Accounts API
Generate a dedup key
The key is your own string, unique per refund. Combining the order code with the refund attempt number is enough, as long as a retry of the same refund reuses the exact same string.
Send the refund
curl -X POST "https://userapi.sepay.vn/v2/bank-accounts/{ba_uuid}/orders/{order_uuid}/refund" \-H "Content-Type: application/json" \-H "Authorization: Bearer YOUR_API_TOKEN" \-H "X-Idempotency-Key: refund-DH20250001-01" \-d '{"refund_amount": 50000}'
{"status": "success","data": {"refund_id": "RF-7QK3-M8PZ-4VNW","order_type": "va_order","order_id": "b2c3d4e5-f6a7-8901-bcde-f12345678902","order_code": "DH20250001","xid": "e5f6a7b8-c9d0-1234-ef56-789012345abc","transaction_id": "6398452","original_reference": "164T24200GKAJ7BY","amount": 50000,"currency": "VND","scope": "partial","status": "pending","created_at": "2026-08-28 14:05:00"}}
Store the refund_id. It is how you look the refund up, and what you quote to SePay support.
Follow the result
curl -X GET "https://userapi.sepay.vn/v2/bank-accounts/{ba_uuid}/refunds/RF-7QK3-M8PZ-4VNW" \-H "Authorization: Bearer YOUR_API_TOKEN"
Read status. Still pending means check back later; succeeded, failed or rejected is the final answer.