OI Payments Docs
Guides

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

FieldTypeRequiredNotes
amountMinorLongYesInteger minor units (paisa). Must be > 0 (@NotNull @Positive) and the server-computed refundable balance. Full or partial.
reasonStringNoFree 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
}
FieldTypeNotes
idLongRefund id. Read it back at GET /refunds/{id}.
paymentIdLongThe settled payment this refund targets.
modeStringTEST or LIVE — inherited from the payment, fixed by the credential.
amountMinorLongRefunded amount in minor units (paisa).
currencyStringBDT.
reasonStringEchoed from the request; omitted when null.
statusStringOne of PENDING, SUCCEEDED, FAILED (and AWAITING_APPROVAL for the operator path only).
gatewayRefundIdStringThe gateway's refund reference, set once the gateway accepts the request.
createdByStringThe initiator. For app refunds this is always app:{appId} (e.g. app:42).
approvedByStringThe refund:approve holder who released an above-threshold operator refund. Omitted (null) for every app refund.
createdAtLocalDateTimeSet by JPA auditing.
updatedAtLocalDateTimeSet 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

HTTPerrorCodeWhen
201 CreatedRefund accepted; RefundDto returned.
400 Bad RequestVALIDATION_ERRORBody shape invalid: amountMinor missing or ≤ 0, or reason longer than 500 characters.
404 Not FoundRESOURCE_NOT_FOUNDThe 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 EntityINVALID_OPERATION_STATEThe payment is not SUCCEEDED, or amountMinor exceeds the refundable balance (over-refund guard).
502 Bad GatewayPAYMENT_GATEWAY_ERRORThe 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

StatusMeaningWebhook
AWAITING_APPROVALParked for operator sign-off (operator path).— (not pushed)
PENDINGAccepted by the gateway, settling.refund.pending
SUCCEEDEDSettled to the customer; ledger + invoice credit posted.refund.succeeded
FAILEDRejected 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.

On this page