Refunds (app API)
Issue full or partial refunds against a settled payment, understand the over-refund guard, and track the async outcome through webhooks.
Refund a SUCCEEDED payment in full or in part. Like the original payment, a
refund settles asynchronously through the gateway — the create call records
the status the gateway returns synchronously (usually PENDING), and you react to
the confirmed state via webhooks.
The mode (TEST or LIVE) is derived from the API credential that authenticated
the request — never from the request body. A refund inherits the app_id and
mode of the payment it targets, so one app can never refund another app's payment
or cross the test/live boundary. See app isolation
and modes.
Money is integer minor units (paisa): 150000 means 1,500.00 BDT. Never
floating point. Currency is BDT-only in v1. See money.
All responses use the standard envelope: { "data": <payload|null>, "meta": { "success", "message", "errorCode", "timestamp" }, "pagination": null }. The
examples below show just the data payload after the first one. See
responses and errors.
Create a refund
POST /payments/{id}/refunds against a payment whose status is SUCCEEDED. The
payment is identified by the path; the body carries only the amount and an optional
reason. This is an API-key endpoint.
curl -X POST http://localhost:8080/api/v1/payments/4815/refunds \
-H "X-Api-Key: oi_test_8f2c1a90d4e7" \
-H "X-Api-Secret: sk_test_a1b2c3d4e5f6g7h8" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: 9f1c2e44-7b3a-4d21-9c6e-2a1f0b8e5d77" \
-d '{
"amountMinor": 50000,
"reason": "Damaged item"
}'Request body — CreateRefundRequest
| Field | Type | Required | Notes |
|---|---|---|---|
amountMinor | Long | Yes | Integer minor units (paisa). Must be > 0 (@NotNull @Positive) and ≤ the server-computed refundable balance. Full or partial. |
reason | String | No | Free text, up to 500 characters (@Size(max = 500)). Echoed back on the refund and forwarded to the gateway. |
The shape rules (amountMinor present and positive, reason ≤ 500) are validated
on the request. The business rules — the payment must be settled and the amount
must fit the refundable balance — are enforced in the use case and surface as
422, not 400.
Idempotency
Create is a mutation, so it accepts an Idempotency-Key header. A repeat with the
same key and same body replays the first RefundDto verbatim without asking
the gateway to refund twice. If the original create failed (for example the gateway
returned an error and the transaction rolled back), the key reservation rolls back
too — so a retry with the same key is clean rather than burnt. See
idempotency.
The refund returned — RefundDto
On success the endpoint returns 201 Created with the refund snapshot. The
refund is created in the status the gateway returns synchronously — typically
PENDING, since refunds settle asynchronously.
{
"data": {
"id": 9007,
"paymentId": 4815,
"mode": "TEST",
"amountMinor": 50000,
"currency": "BDT",
"reason": "Damaged item",
"status": "PENDING",
"gatewayRefundId": "RFND-SSL-7Z3X90",
"createdBy": "app:42",
"createdAt": "2026-06-30T12:00:00",
"updatedAt": "2026-06-30T12:00:00"
},
"meta": {
"success": true,
"message": null,
"errorCode": null,
"timestamp": "2026-06-30T12:00:00Z"
},
"pagination": null
}| Field | Type | Notes |
|---|---|---|
id | Long | Refund id. Read it back at GET /refunds/{id}. |
paymentId | Long | The settled payment this refund targets. |
mode | String | TEST or LIVE — inherited from the payment, fixed by the credential. |
amountMinor | Long | Refunded amount in minor units (paisa). |
currency | String | BDT. |
reason | String | Echoed from the request; omitted when null. |
status | String | One of PENDING, SUCCEEDED, FAILED (and AWAITING_APPROVAL for the operator path only). |
gatewayRefundId | String | The gateway's refund reference, set once the gateway accepts the request. |
createdBy | String | The initiator. For app refunds this is always app:{appId} (e.g. app:42). |
approvedBy | String | The refund:approve holder who released an above-threshold operator refund. Omitted (null) for every app refund. |
createdAt | LocalDateTime | Set by JPA auditing. |
updatedAt | LocalDateTime | Set by JPA auditing. |
RefundDto serializes with NON_NULL, so null fields are omitted entirely.
For an app refund, approvedBy is always absent; reason is absent when you
did not send one. Internal fields (raw gateway status, idempotency key) are never
exposed.
The over-refund guard
You may issue several partial refunds, but their total can never exceed the captured amount. The use case loads the payment under a row lock so concurrent refunds serialise, then checks the requested amount against the refundable balance:
refundable = capturedAmount − sum(reserving refunds)A refund reserves balance while it is in any of these states — the reserving set:
AWAITING_APPROVAL— parked for operator sign-off (operator path), reserves up front.PENDING— accepted by the gateway, in flight.SUCCEEDED— settled.
A FAILED refund returned no value, so it drops out of the sum and frees its
reservation — the amount becomes refundable again.
This is what makes retrying a failed refund safe: the failed attempt consumed
nothing, so re-issuing the same amount can never compound. The server is the
final authority on the balance — amountMinor is rejected with 422
INVALID_OPERATION_STATE the moment it would push the reserved total past the
captured amount.
Status & error contract
| HTTP | errorCode | When |
|---|---|---|
201 Created | — | Refund accepted; RefundDto returned. |
400 Bad Request | VALIDATION_ERROR | Body shape invalid: amountMinor missing or ≤ 0, or reason longer than 500 characters. |
404 Not Found | RESOURCE_NOT_FOUND | The payment (or, on lookup, the refund) is not in your app + mode. A cross-app or cross-mode record is hidden as a plain miss — its existence never leaks. |
422 Unprocessable Entity | INVALID_OPERATION_STATE | The payment is not SUCCEEDED, or amountMinor exceeds the refundable balance (over-refund guard). |
502 Bad Gateway | PAYMENT_GATEWAY_ERROR | The gateway rejected the refund request. The create transaction rolls back — refund row and idempotency-key reservation included — so the failure surfaces and a retry is clean. |
422 — over-refund
{
"data": null,
"meta": {
"success": false,
"message": "Refund of 60000 exceeds the refundable balance of 50000 for payment 4815 (captured 150000, already refunded 100000)",
"errorCode": "INVALID_OPERATION_STATE",
"timestamp": "2026-06-30T12:00:00Z"
},
"pagination": null
}502 — gateway error
{
"data": null,
"meta": {
"success": false,
"message": "Could not request a refund for payment 4815",
"errorCode": "PAYMENT_GATEWAY_ERROR",
"timestamp": "2026-06-30T12:00:00Z"
},
"pagination": null
}Lifecycle
App refunds start at PENDING — an app refund is never subject to the per-app
approval threshold; that gate is a human/dashboard control, so the
AWAITING_APPROVAL state below applies only to operator refunds (see
refund approval).
The create call and the async resolution span two transactions: the synchronous
create that returns PENDING, and a later IPN/reconciliation that resolves the
terminal outcome exactly once and fans out the webhook.
Status to webhook
| Status | Meaning | Webhook |
|---|---|---|
AWAITING_APPROVAL | Parked for operator sign-off (operator path). | — (not pushed) |
PENDING | Accepted by the gateway, settling. | refund.pending |
SUCCEEDED | Settled to the customer; ledger + invoice credit posted. | refund.succeeded |
FAILED | Rejected by the gateway; balance freed. | refund.failed |
When all captured value is confirmed-refunded the parent payment becomes
REFUNDED; a partial leaves it PARTIALLY_REFUNDED. A refund that is still
AWAITING_APPROVAL emits no webhook until it reaches the gateway.
Track the outcome
Read a single refund with GET /refunds/{id}. The lookup is scoped to your app_id
and mode, so another app's refund (or a live refund read with a test key) returns
404 rather than leaking its existence.
curl http://localhost:8080/api/v1/refunds/9007 \
-H "X-Api-Key: oi_test_8f2c1a90d4e7" \
-H "X-Api-Secret: sk_test_a1b2c3d4e5f6g7h8"{
"data": {
"id": 9007,
"paymentId": 4815,
"mode": "TEST",
"amountMinor": 50000,
"currency": "BDT",
"reason": "Damaged item",
"status": "SUCCEEDED",
"gatewayRefundId": "RFND-SSL-7Z3X90",
"createdBy": "app:42",
"createdAt": "2026-06-30T12:00:00",
"updatedAt": "2026-06-30T12:04:30"
},
"meta": {
"success": true,
"message": null,
"errorCode": null,
"timestamp": "2026-06-30T12:05:00Z"
},
"pagination": null
}Prefer reacting to the refund.pending / refund.succeeded /
refund.failed webhooks over polling — the succeeded
event fires only after the ledger and any invoice credit have committed.
Related
Payable invoice page
The public, unauthenticated JSON behind a customer-facing branded invoice page — number, line items, totals, your branding, and the checkout URL to pay at — keyed by the opaque payment reference.
Webhooks
The complete 21-event catalogue, the signed canonical envelope, how to verify signatures and dedupe, and the exact delivery, retry and re-send contract.