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_*).
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" },
);
Refunding a payment that has no payment intent
Name exactly one parent: payment_intent, or transaction for a capture that has no payment intent, such as a guest Embedded Fields payment. Sending both or neither is refused.
This is also how you act on refund_target_is_duplicate: that refusal names the transaction to use in refund_instead, and you refund that one.
const refund = await client.refunds.create(
{
transaction: "vp_tx_test_abc123",
amount: 500, // omit for a full refund
},
{ idempotencyKey: "order_1042_refund_1" },
);
In the Node and Python SDKs the transaction target arrived in 2.10.0; naming both targets, or neither, throws before any request leaves your server.
A payment intent id and a transaction id are both returned to the shopper's browser, so neither one authorizes a refund. Never refund an id taken from a client request: look it up on your server from your own order record, and check it belongs to the order and customer asking.
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. |
transaction | string | — | Settlement-ledger id of the capture to refund (vp_tx_test_* / vp_tx_live_*). This is the parent for a guest embedded-fields capture, which has no payment intent. Send exactly one parent — payment_intent or transaction; sending both, or neither, is refused. |
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: 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
A 2xx does not mean the buyer was repaid. Four different success responses are possible and only one of them means the money has moved — branch on status, not on the status code alone.
| HTTP | status | Did the buyer get their money? |
|---|---|---|
201 | succeeded | Yes. The refund completed and the money has moved. |
202 | requested | Not yet. The provider accepted the refund but has not completed it. It is real and in progress. |
200 | canceled | No, and it never will be. The reversal was voided before it left — terminal, and no money moved. |
200 | (any) + idempotent: true | No new refund was issued. This is a replay of an earlier request that used the same Idempotency-Key. The status is whatever the original refund reached — including failed, which means the buyer was never repaid and this key can no longer repay them. |
{
"id": "vpr_test_JL3xPcFktvsF10Ib",
"payment_intent": "vpi_test_abc123",
"amount": 500,
"currency": "USD",
"status": "succeeded",
"reason": null
}
A 202 is not a failure — do not retry it
On a provider that settles refunds asynchronously an accepted refund comes back as 202 with status: "requested" — real and in progress. Treat it as pending, never as failed: reissuing it pays the buyer twice. Resolve it from charge.refunded (completed) or refund.failed (did not; a processor that reports a declined refund rather than a failed one also arrives as refund.failed, so there is no second event to subscribe to) — there is no refund-retrieve endpoint to poll. Not every connection emits the failure event, so a requested refund with no terminal event stays open in your own ledger and is reconciled on your side rather than waited on indefinitely.
200 with status: "canceled" is not a completed refund
The provider voided the reversal before it settled: terminal, and the buyer was not repaid — do not mark the order refunded. A canceled refund consumes no refundable balance, so once you know why it was voided, issue it again with a new idempotency key: the old key stays bound to the canceled record and replaying it returns this same response forever without reaching the processor.
A failed refund's key is spent — but that does not mean reissue
failed is the other terminal state, and the key behaves exactly as it does for
canceled: the record stays bound to that Idempotency-Key, so replaying it
returns the same failed refund with idempotent: true for as long as the record
exists. Retrying under that key can never repay the buyer, however many times you
send it.
What to do next depends entirely on how the failure reached you, and the two answers are opposites. Decide from that before you decide anything else:
| How it reached you | What it means | What to do |
|---|---|---|
A typed decline — you received a refund.failed webhook carrying reason_code: refund_declined | The provider refused the reversal. Nothing moved. | If retry_available is false, the payment was disputed and the buyer already has the money back: do not reissue or refund another way. Otherwise resolve the cause, then reissue under a new key. |
A provider_unavailable or a timeout on your original POST /v1/refunds call | State unknown. The reversal may already have gone through. | Do not reissue. Record it as unresolved in your ledger and settle it with support. |
An ambiguous refund never arrives as refund.failed — it surfaces only as the synchronous error on your original call, so do not wait for an event to tell the two apart (provider_unavailable). Reissuing a refund whose first attempt went through pays the buyer twice out of your balance, and the refundable balance still permits it.
| Field | Type | Description |
|---|---|---|
id | string | Refund record ID (vpr_test_* / vpr_live_*). |
payment_intent | string | null | The payment intent this refund applies to. Both parent keys are always present and exactly one is non-null — this is null on a refund anchored to a transaction. Branch on which one is non-null; do not test for the key's presence, because both are always there. |
transaction | string | null | Settlement-ledger id this refund applies to (vp_tx_test_* / vp_tx_live_*). Both parent keys are always present and exactly one is non-null — this is null on a refund anchored to a payment intent. |
amount | integer | Refunded amount, in minor units. |
currency | string | ISO 4217 (uppercase on response). |
status | string: requested | succeeded | failed | canceled | The refund's own lifecycle state, distinct from the parent payment intent's. See Status. |
reason | string | null: duplicate | fraudulent | requested_by_customer | expired_uncaptured_charge | 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. |
idempotency_key | string | null | On a refund read through GET /v1/refunds: the Idempotency-Key it was created with. For a refund you made with POST /v1/refunds it is your key exactly as sent (only leading and trailing spaces removed), so you can match refunds to your own keys. A refund issued from the dashboard or a connected store carries the key that product used; null for a refund made directly at the payment provider or created without a key. |
created_at | string | On a refund read through GET /v1/refunds: when the refund record was created. |
List a payment's refunds
GET /v1/refunds · secret key. Name the payment with exactly one of payment_intent (vpi_…) or transaction (vp_tx_…, the vp_tx_id on the session). Refunds made through either id are listed whichever one you ask with.
curl "https://checkout.vonpay.com/v1/refunds?payment_intent=vpi_live_EXAMPLE" \
-H "Authorization: Bearer vp_sk_live_YOUR_KEY"
{
"object": "list",
"data": [
{
"id": "vpr_live_EXAMPLE",
"payment_intent": "vpi_live_EXAMPLE",
"transaction": null,
"amount": 1000,
"currency": "USD",
"status": "succeeded",
"reason": "requested_by_customer",
"idempotency_key": "your-refund-id",
"created_at": "2026-10-05T14:02:11Z"
}
],
"has_more": false,
"next_cursor": null,
"amount_refunded": 1000,
"remaining_refundable": 3999,
"refundable": true
}
dataholds every refund of the payment, newest first, in any status. Check each one'sstatus: arequestedrefund is still in progress and can still fail.amount_refundedcounts refunds that succeeded or are still in progress; failed and canceled refunds are not counted. The totals cover all of the payment's refunds, not just this page.remaining_refundableis what a refund with noamountwould return now, and0whenrefundableisfalse.refundablesays whether the payment itself qualifies (settled, not disputed, not already fully refunded). The refund call still checks account conditions such as refunds being switched on for your provider.- Page with
limit(1–100, default 25) andstarting_after: passnext_cursorwhilehas_moreistrue.
From SDK 3.6.0 this is refunds.list() in Node and Python.
To see what has been refunded, read these totals (also on GET /v1/payment_intents/{id} and GET /v1/sessions/{id}) rather than adding up webhooks. An unknown id, another account's payment, or a payment in the other key mode returns 404; a duplicate transaction returns the same 422 refund_target_is_duplicate that POST /v1/refunds does.
Status
A refund record is created in requested — returned to you directly with HTTP 202 on an asynchronous provider — 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. |
| A dispute or chargeback is recorded on the payment's settlement | 422 refund_intent_not_refundable, with current_status reading disputed or chargeback, before anything reaches the provider. The buyer gets the money back through the dispute, so a refund on top would pay them twice. The intent itself still reads succeeded, because a dispute is recorded on the settlement rather than on the intent. A dispute the provider has not yet linked to this payment is not seen by this check, so a refund can still reach the provider and come back as refund_declined. |
No Idempotency-Key header, and the payment was taken on a provider connection that keeps no queryable record of a reversal | 400 idempotency_key_required. Not retryable as sent — add the header. Only these connections REJECT a keyless refund — every other one ACCEPTS one, and is not protected by it. Send the key regardless. |
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, so refunds.create is the path on all of them; if you branch on that capability, make the refund the fallback (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. It reaches every active subscription whether or not you selected it, but not every processor connection emits it, so keep an unresolved refund open in your own records until you see one or the other. 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, read amount_refunded_total off the latest charge.refunded event and compare it against original_charge_amount. ⛔ Do not sum the amount field — its meaning varies by processor connection (this-refund on some, running-total on others), so summing over-counts and marks an order fully refunded early.
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. Use amount_refunded_total as above. 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