Skip to main content

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.

Send an Idempotency-Key, or a retry issues a second real refund

Refund 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

FieldTypeRequiredDescription
payment_intentstringID of the payment intent to refund (vpi_test_* / vpi_live_*). Optional, but you must supply exactly one parent reference — either payment_intent or transaction.
amountintegerMinor units; positive (≥ 1). Omit to refund the full remaining balance; pass a value below the remaining balance for a partial refund.
currencystring3-letter alphabetic ISO 4217 currency code.
reasonstringOne of duplicate, fraudulent, requested_by_customer, expired_uncaptured_charge. Mirrored back on the refund record.
metadataobjectObject 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
}
FieldTypeDescription
idstringRefund record ID (vpr_test_* / vpr_live_*).
payment_intentstringThe payment intent this refund applies to.
amountintegerRefunded amount, in minor units.
currencystringISO 4217 (uppercase on response).
statusstringrequested, succeeded, failed, or canceled.
reasonstring | nullThe reason supplied on create, or null.
idempotentbooleantrue 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. Refunds captured − previously refunded.
  • Partial — pass an amount below the remaining refundable balance. The full-vs-partial outcome is determined by the amount relative to the remaining balance, not by whether amount is 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.refunded event.

Constraints

ConditionResult
amount exceeds the remaining refundable balance422 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.

Don't decide "fully refunded" from is_partial

is_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.