Refunds
POST /v1/refunds refunds a succeeded payment intent — in full or in part. Refund IDs use the vpr_test_* / vpr_live_* prefix.
Required key: secret key (vp_sk_*). Available in SDK 0.11.x.
Create a refund
Omit amount to refund the full remaining balance (the server computes captured − previously refunded). Pass an amount below the remaining balance for a partial refund.
Idempotency-Key, or a retry issues a second real refundRefund de-duplication only happens when you send an Idempotency-Key header.
Without one there is no replay lookup at all: every call — including a retry your
HTTP client makes automatically after a timeout, or a proxy replaying a POST —
creates a new refund record and returns real money to the cardholder again.
Generate the key server-side, keep it stable across retries of the same refund, and never derive it from buyer-supplied input. See Idempotency.
curl https://checkout.vonpay.com/v1/refunds \
-H "Authorization: Bearer vp_sk_test_YOUR_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: order_1042_refund_1" \
-d '{ "payment_intent": "vpi_test_abc123", "amount": 500 }'
const refund = await client.refunds.create(
{
paymentIntent: "vpi_test_abc123",
amount: 500, // omit for a full refund
},
{ idempotencyKey: "order_1042_refund_1" },
);
Request
| Field | Type | Required | Description |
|---|---|---|---|
payment_intent | string | — | ID of the payment intent to refund (vpi_test_* / vpi_live_*). Optional, but you must supply exactly one parent reference — either payment_intent or transaction. |
amount | integer | — | Minor units; positive (≥ 1). Omit to refund the full remaining balance; pass a value below the remaining balance for a partial refund. |
currency | string | — | 3-letter alphabetic ISO 4217 currency code. |
reason | string | — | One of duplicate, fraudulent, requested_by_customer, expired_uncaptured_charge. Mirrored back on the refund record. |
metadata | object | — | Object with string keys and arbitrary JSON values. |
Response — Refund
{
"id": "vpr_test_JL3xPcFktvsF10Ib",
"payment_intent": "vpi_test_abc123",
"amount": 500,
"currency": "USD",
"status": "succeeded",
"reason": null
}
| Field | Type | Description |
|---|---|---|
id | string | Refund record ID (vpr_test_* / vpr_live_*). |
payment_intent | string | The payment intent this refund applies to. |
amount | integer | Refunded amount, in minor units. |
currency | string | ISO 4217 (uppercase on response). |
status | string | requested, succeeded, failed, or canceled. |
reason | string | null | The reason supplied on create, or null. |
idempotent | boolean | true when this response is a replay of an earlier request that used the same Idempotency-Key — meaning no new refund was issued. Read it before treating the response as a fresh refund, or a retried call will look like a second refund that never happened. ⚠️ This field is not a safety net. It only ever appears because you sent an Idempotency-Key; without one there is no replay check and a retry issues a second real refund. |
Status
A refund record is created in requested and reaches a terminal succeeded or failed; canceled is a rare terminal state.
requested ──▶ succeeded
└─▶ failed
Full vs. partial
- Full — omit
amount. Refundscaptured − previously refunded. - Partial — pass an
amountbelow the remaining refundable balance. The full-vs-partial outcome is determined by the amount relative to the remaining balance, not by whetheramountis present. - Multiple partial refunds against the same intent are allowed up to the remaining refundable balance. Each issues a separate refund record and fires its own
charge.refundedevent.
Constraints
| Condition | Result |
|---|---|
amount exceeds the remaining refundable balance | 422 refund_amount_exceeds_remaining. The error envelope carries remaining_refundable. |
The source intent is not in a refundable state (status is not succeeded) | 422 refund_intent_not_refundable. The error envelope carries payment_intent and current_status. |
Void-after-capture
To reverse a captured intent, issue a refund. Call refunds.create, not paymentIntents.void — voiding applies to an authorization that has not been captured, and once the intent is succeeded the money has moved.
Every processor reports void_after_capture as not_supported today, so refunds.create is the path on all of them. If you branch on that capability, make the refund your fallback rather than a special case — a branch that only handles supported or rerouted_to_refund returns the buyer nothing and raises no error. See branching on the matrix.
Reconciliation
A refund that completes fires a charge.refunded webhook carrying refund_id, amount (this refund), is_partial, and original_charge_amount.
A refund that fails fires refund.failed instead — the buyer has not been paid back. Subscribe to both, or a refund that never lands is invisible to your reconciliation. That event carries a reason_code, and the values need opposite handling: retrying a refund_unresolved blind can pay the buyer twice.
To get the cumulative refunded total for a charge, sum amount across all charge.refunded events for the same transaction_id and compare against original_charge_amount.
is_partialis_partial describes one refund, and how it is derived is not identical across payment providers — a second refund that completes the total can still report is_partial: true. The sum above is the method that works everywhere. See the note on the events page.
Related
- Payment Intents — the intent a refund applies to
- Capabilities —
partial_refund,void_after_capture - Webhook Events —
charge.refundedandrefund.failed