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 it refunded (or partially_refunded) but never roll it back to pending. 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 with 400 if 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

Plugipay — Payments that don't tax your success