Refunds
A refund returns money from your balance back to the customer who paid you. Every refund is attached to a single previously-captured payment — you can't issue a "free-floating" refund, and you can't refund more than the payment captured.
Two flavors:
- Full refund — omit
amount; Plugipay refunds whatever's left (original minus prior partial refunds). - Partial refund — pass an explicit
amount. You can issue multiple partials against the same payment as long as the sum stays at or below the captured total.
If you're new to refunds, Your first payment → Refund the payment walks through the flow end-to-end.
Lifecycle
A refund moves through a small state machine:
| Status | Meaning |
|---|---|
pending |
The refund has been accepted and queued with the underlying provider. Funds have not yet landed back with the customer. |
succeeded |
The provider confirmed the money is on its way (cards) or has settled (bank transfer). Reconciliation done. |
failed |
The provider rejected the refund. failureCode / failureMessage explain why. The original payment is left untouched. |
canceled |
A pending refund was canceled before the provider executed it (only possible for adapters that support pre-execution cancellation; rare). |
How long pending lasts depends on the provider and method: seconds to minutes for cards, up to an hour for e-wallets (OVO, GoPay, DANA), and 1–3 business days for bank transfers and virtual accounts. Card refunds against the managed adapter in test mode resolve to succeeded synchronously on the create call — useful for local development but not a guarantee for live mode.
Refunds don't reopen the payment. A payment whose refunds sum to the captured amount stays
succeeded; we mark itrefunded(orpartially_refunded) but never roll it back topending. The original payment ID stays valid forever.
Full vs partial refunds
The remaining refundable amount on a payment is:
remaining = capturedAmount - sum(refunds where status in [pending, succeeded]).amount
A pending refund counts toward the cap immediately — we won't let you over-refund just because the first refund hasn't settled yet. If a pending refund later transitions to failed, that amount becomes refundable again.
Examples for a payment of IDR 250,000:
| Calls | Result |
|---|---|
create({ chargeId }) |
Refunds 250,000 (full). |
create({ chargeId, amount: 100_000 }) |
Refunds 100,000. Remaining: 150,000. |
Then create({ chargeId, amount: 200_000 }) |
422 VALIDATION_ERROR — exceeds remaining. |
Then create({ chargeId, amount: 150_000 }) |
Refunds the rest. Payment is now fully refunded. |
Then create({ chargeId }) |
422 VALIDATION_ERROR — nothing left to refund. |
Endpoints
| Method | Path | Purpose |
|---|---|---|
POST |
/v1/refunds |
Create a refund. |
GET |
/v1/refunds/{id} |
Retrieve a refund. |
GET |
/v1/refunds |
List refunds. |
There is currently no cancel endpoint. Refunds against the default adapters move to succeeded (or failed) too quickly for cancellation to be meaningful. If you're integrating a custom adapter where pre-execution cancellation matters, contact us.
Create a refund
POST /v1/refunds
Issues a refund against an existing payment. Idempotency is required — the server rejects unsigned-with-Idempotency-Key requests on this route. See Idempotency for the recipe; the SDKs add one for you.
Request body
| Field | Type | Required | Notes |
|---|---|---|---|
chargeId |
string | yes | The pay_… ID of the payment to refund. Returned on payment.succeeded webhooks and on checkoutSession.paymentId once a session completes. |
amount |
integer | no | Smallest-currency-unit amount to refund. Omit for a full refund of the remaining balance. Must be positive and <= remaining. |
reason |
enum | no | One of requested_by_customer (default), duplicate, fraudulent, other. Used for reconciliation, analytics, and fraud monitoring. |
metadata |
object | no | Up to 50 string keys; see Conventions → Metadata. Passing null clears an existing value on update; omit to leave unchanged. |
The currency of the refund is inherited from the underlying payment — you can't refund a USD payment in IDR.
Samples
// Node — @forjio/plugipay-node
const refund = await plugipay.refunds.create({
chargeId: 'pay_01HXXXXXXXXXXXXXXXXXXXXXXX',
amount: 100000, // IDR 100,000 of a larger payment
reason: 'duplicate',
metadata: { ticket: 'sup-4821' },
});
console.log(refund.id, refund.status); // rf_01H… succeeded
# Python — plugipay
refund = plugipay.refunds.create(
charge_id="pay_01HXXXXXXXXXXXXXXXXXXXXXXX",
amount=100_000,
reason="duplicate",
metadata={"ticket": "sup-4821"},
)
// Go — github.com/hachimi-cat/plugipay-go
amount := int64(100_000)
refund, err := client.Refunds.Create(ctx, &plugipay.RefundCreateParams{
ChargeID: "pay_01HXXXXXXXXXXXXXXXXXXXXXXX",
Amount: &amount,
Reason: plugipay.RefundReasonDuplicate,
Metadata: map[string]string{"ticket": "sup-4821"},
})
# curl
plugipay_curl POST '/api/v1/refunds' '{
"chargeId": "pay_01HXXXXXXXXXXXXXXXXXXXXXXX",
"amount": 100000,
"reason": "duplicate",
"metadata": { "ticket": "sup-4821" }
}'
Response
201 Created with the refund object:
{
"data": {
"id": "rf_01HZxxxxxxxxxxxxxxxxxxxxxx",
"arn": "arn:plugipay:acc_01HX…:refund:rf_01HZ…",
"accountId": "acc_01HXxxxxxxxxxxxxxxxxxxxxxx",
"chargeId": "pay_01HXxxxxxxxxxxxxxxxxxxxxxx",
"invoiceId": null,
"amount": 100000,
"currency": "IDR",
"reason": "duplicate",
"status": "succeeded",
"failureCode": null,
"failureMessage": null,
"metadata": { "ticket": "sup-4821" },
"createdAt": "2026-05-12T10:42:00.123Z",
"updatedAt": "2026-05-12T10:42:00.123Z"
},
"error": null,
"meta": { "requestId": "req_…", "timestamp": "..." }
}
invoiceId is non-null only when the refund was issued against an invoice-backed payment.
Errors
| Status | error.code |
When |
|---|---|---|
400 |
VALIDATION_ERROR |
chargeId missing or malformed; amount not a positive integer; unknown reason. |
404 |
NOT_FOUND |
chargeId doesn't exist or isn't visible to this key (wrong workspace, wrong mode). |
422 |
VALIDATION_ERROR |
amount exceeds the remaining refundable balance, or the payment has nothing left to refund. |
409 |
IDEMPOTENCY_CONFLICT |
An Idempotency-Key was reused with a different body. See Idempotency. |
429 |
RATE_LIMITED |
This endpoint is in the mutating_heavy bucket. Back off and retry. |
502 |
PROVIDER_ERROR |
The upstream provider (Xendit, Midtrans, PayPal) rejected the refund call. Safe to retry with the same idempotency key. |
Always pass an
Idempotency-Key. Refunds are mutating, money-moving, and frequently triggered from human-in-the-loop tooling (support agents clicking "refund"). Without an idempotency key, a network retry can issue the refund twice. Server-side, this endpoint requires the header — the request will be rejected with400if missing.
Retrieve a refund
GET /v1/refunds/{id}
Returns a refund by ID. The response shape matches the create response.
const refund = await plugipay.refunds.get('rf_01HZxxxxxxxxxxxxxxxxxxxxxx');
refund = plugipay.refunds.get("rf_01HZxxxxxxxxxxxxxxxxxxxxxx")
refund, err := client.Refunds.Get(ctx, "rf_01HZxxxxxxxxxxxxxxxxxxxxxx")
plugipay_curl GET '/api/v1/refunds/rf_01HZxxxxxxxxxxxxxxxxxxxxxx'
Errors
| Status | error.code |
When |
|---|---|---|
404 |
NOT_FOUND |
Refund doesn't exist or belongs to a different workspace. |
List refunds
GET /v1/refunds
Returns refunds in reverse-chronological order by default. See Pagination for the cursor scheme.
Query parameters
| Param | Type | Notes |
|---|---|---|
limit |
integer | 1–100, default 20. |
cursor |
string | Cursor returned in meta.nextCursor of a previous page. |
order |
asc | desc |
Default desc (newest first). |
chargeId |
string | Return only refunds against this payment. Useful for showing a payment's full refund history. |
status |
pending | succeeded | failed | canceled |
Filter by current status. |
Samples
// All refunds on a single payment
const { data, hasMore, cursor } = await plugipay.refunds.list({
chargeId: 'pay_01HXxxxxxxxxxxxxxxxxxxxxxx',
});
// Only failed refunds in the last page
const failed = await plugipay.refunds.list({ status: 'failed', limit: 50 });
refunds = plugipay.refunds.list(charge_id="pay_01HXxxxxxxxxxxxxxxxxxxxxxx")
page, err := client.Refunds.List(ctx, &plugipay.RefundListParams{
ChargeID: plugipay.String("pay_01HXxxxxxxxxxxxxxxxxxxxxxx"),
Limit: plugipay.Int(50),
})
plugipay_curl GET '/api/v1/refunds?chargeId=pay_01HX…&limit=50'
The refund object
| Field | Type | Notes |
|---|---|---|
id |
string | rf_… ULID. |
arn |
string | Global resource name, e.g. arn:plugipay:acc_01HX…:refund:rf_…. Useful in cross-service event handling. |
accountId |
string | The workspace this refund belongs to. |
chargeId |
string | The pay_… payment that was refunded. |
invoiceId |
string | null | The inv_… invoice this payment satisfied, if any. null for direct checkout-session payments. |
amount |
integer | Refunded amount in the smallest currency unit (cents, rupiah, yen). Always positive. |
currency |
string | ISO 4217, uppercase. Always matches the payment. |
reason |
string | null | requested_by_customer, duplicate, fraudulent, other, or null if not set. |
status |
string | pending, succeeded, failed, or canceled. |
failureCode |
string | null | Provider-specific failure code, set when status = "failed". |
failureMessage |
string | null | Human-readable failure detail, set when status = "failed". |
metadata |
object | null | Your arbitrary key/value strings. |
createdAt |
string | ISO 8601 UTC. |
updatedAt |
string | ISO 8601 UTC. Bumps on every status transition. |
Events
Refunds emit the following webhook events. Subscribe in Settings → Webhooks or via the webhook endpoints API.
| Event | Fires when |
|---|---|
refund.created |
A refund is created — covers both same-call success and pending provider hand-off. |
refund.succeeded |
A pending refund settles successfully. Fires once per refund. |
refund.failed |
A pending refund is rejected by the provider. data.failureCode / data.failureMessage populated. |
refund.updated |
Catch-all for state transitions you can also catch via the typed events above; useful if you'd rather subscribe to one channel and switch on status. |
Each event's data payload is the refund object at the time of the event. The original payment's webhook fires payment.refunded (full) or payment.partially_refunded once the refund succeeds — see Webhooks for the full catalog.
Idempotency
Refunds move money and are routinely triggered from contexts (support tooling, retried jobs, customer-clicked "refund" buttons) where a duplicate request is plausible. Generate a stable idempotency key tied to the refund intent — e.g. refund:order-2026-001:partial-100k — and reuse it on retries until you've seen a final response. Full recipe and replay semantics: Idempotency.
Next
- Your first payment — the worked end-to-end example.
- Webhooks — signature scheme and event catalog.
- Errors — full error code reference.
- Idempotency — safe-retry recipe.