Refunds API overview

Issue a refund and follow its state with SePay API, including the dedup rule and the refundable budget of the original payment.

||

Send one refund, SePay forwards it to the bank and returns a refund_id you follow until there is a final answer.

VietinBank enterprise accounts only for now

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.

X-Idempotency-Key is required

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 sendSePay refunds
No refund_amountThe whole remaining amount of the original payment
refund_amount below the remainderExactly that, and the rest stays refundable
refund_amount above the remainderRejected, 422 with an error on refund_amount
Original payment already fully refundedRejected, 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 hasNo transaction_idWith transaction_id
No collection payment422 no_original_transaction422 no_original_transaction
Exactly one paymentSePay picks it, the budget is that payment's amountUses the payment you named
Two or more payments422 transaction_requiredUses 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.

There is no single call that refunds a whole order

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 orderRefund by transaction
Addressed byOrder UUIDCollection transaction id
Picking the paymentAutomatic when the order has exactly one, otherwise pass transaction_idAlready in the path
Works for a payment with no orderNoYes
order_type in the responseva_orderva_order when the payment belongs to a VA order, null when it belongs to no order
Updates the order's refund_statusYesYes, when the payment belongs to an order
BudgetThe chosen payment's amountThat 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.

A refund does not change the order

The order keeps its status and paid_amount. A refund is its own record, read it through List refunds.

Refund statuses

Refund status
Rendering diagram...
StatusMeaning
pendingIn flight, the bank has not answered yet
succeededThe bank confirmed the money moved
failedThe bank confirmed the money did not move
rejectedThe 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.

No need to poll the bank yourself

SePay refreshes refunds that are still pending. You only read Refund detail, or filter the list by status.

Endpoints

POST
https://userapi.sepay.vn/v2/bank-accounts/{ba_xid}/orders/{order_xid}/refund
POST
https://userapi.sepay.vn/v2/transactions/{transaction_id}/refund
GET
https://userapi.sepay.vn/v2/bank-accounts/{ba_xid}/refunds
GET
https://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
1
2
3
4
5
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}'
Response 200
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
{
"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
1
2
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.


See Also