> For clean Markdown of any page, append .md to the page URL. > For a complete documentation index, see https://docs.paymentkit.com/guides/billing/issue-a-refund/llms.txt. > For AI client integration (Claude Code, Cursor, etc.), connect to the MCP server at https://docs.paymentkit.com/_mcp/server. # Issue a refund A refund returns captured funds to the customer who paid them. You can refund a charge by its payment intent or by the invoice it paid, refund the full amount or only part of it, and record refunds that were already processed outside PaymentKit. # Create a refund A refund always targets a single succeeded charge. You identify that charge in one of two ways: * `payment_intent_id` — refund the latest succeeded charge on a payment intent. * `invoice_id` — refund the charge that paid an invoice. PaymentKit resolves the payment allocation for you. (If the invoice was paid by more than one payment intent, refund by `payment_intent_id` instead.) Provide at least one of these. If you supply both, `payment_intent_id` takes precedence. #### Dashboard 1. Open the **Payment** or **Invoice** you want to refund 2. Click **Refund** 3. Enter a full or partial amount and choose a reason 4. Click **Refund** to send it to the processor The refund appears immediately with a **Pending** status and updates to **Succeeded** once the processor confirms it. #### API ```bash curl -X POST https://app.paymentkit.com/api/{account_id}/payments/refunds \ -H "Authorization: Bearer sk_live_..." \ -H "Content-Type: application/json" \ -H "Idempotency-Key: refund-order-4821" \ -d '{ "payment_intent_id": "pi_abc123", "amount_atom": 1500, "reason": "requested_by_customer" }' ``` Omit `amount_atom` to refund the full refundable amount. ## Request fields | Field | Type | Description | | ---------------------- | --------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `payment_intent_id` | `string` | The payment intent to refund. Required unless `invoice_id` is provided. | | `invoice_id` | `string` | An invoice to refund. PaymentKit resolves the charge that paid it. Required unless `payment_intent_id` is provided. | | `amount_atom` | `integer` | Amount to refund, in the smallest currency unit (e.g. cents). Must be greater than `0`. Defaults to the full refundable amount. | | `currency` | `string` | Currency code (e.g. `USD`). Defaults to the charge's currency; if provided, it must match. | | `reason` | `string` | One of `manual`, `duplicate`, `fraudulent`, `requested_by_customer`, `expired_uncaptured_charge`, `manual_out_of_band`. Defaults to `manual`. | | `refunded_out_of_band` | `boolean` | Set `true` to record a refund you already processed elsewhere (e.g. in the processor's dashboard). No processor call is made and the refund is marked `succeeded` immediately. Defaults to `false`. | | `metadata` | `object` | Free-form JSON, up to 10 KB. | > **Tip** > > Send an `Idempotency-Key` header on every create request. If the call is retried with the same > key, PaymentKit returns the original refund instead of issuing a duplicate. # The refund object ```json { "id": "re_prod_0a1b2c3d4e5f6g7h", "account_id": "acc_prod_...", "charge_id": "pa_prod_...", "amount_atom": 1500, "currency": "USD", "status": "succeeded", "reason": "requested_by_customer", "processor_refund_id": "re_3Nz...", "failure_code": null, "failure_message": null, "processed_at": "2026-06-17T14:05:00Z", "metadata": null, "created_at": "2026-06-17T14:04:58Z", "updated_at": "2026-06-17T14:05:00Z" } ``` | Field | Description | | ---------------------------------- | ------------------------------------------------------------------------------------- | | `id` | Refund identifier, prefixed `re_`. | | `charge_id` | The charge that was refunded. | | `amount_atom` / `currency` | The refunded amount and its currency. | | `status` | Lifecycle status — see below. | | `reason` | The reason supplied at creation, if any. | | `processor_refund_id` | The processor's own refund ID. `null` for out-of-band refunds or while still pending. | | `failure_code` / `failure_message` | Populated when `status` is `failed`. | | `processed_at` | When the processor finished the refund. `null` while pending. | ## Refund statuses | Status | Meaning | | ----------------- | ------------------------------------------------------------------------------------ | | `pending` | Created and awaiting the processor's response. | | `succeeded` | Funds returned to the customer (or recorded out-of-band). Terminal. | | `failed` | The processor rejected the refund. See `failure_code` / `failure_message`. Terminal. | | `requires_action` | Additional action is required before the refund can complete (rare). Non-terminal. | | `cancelled` | Cancelled before completion — from `pending` or `requires_action`. Terminal. | # Look up refunds | Method | Path | Returns | | ------ | ------------------------------------------------------------------ | -------------------------------------------- | | `GET` | `/api/{account_id}/payments/refunds/{refund_id}` | A single refund | | `GET` | `/api/{account_id}/payments/refunds/by_charge/{charge_id}` | All refunds for a charge (paginated) | | `GET` | `/api/{account_id}/payments/refunds/by_intent/{payment_intent_id}` | All refunds for a payment intent (paginated) | ```bash curl https://app.paymentkit.com/api/{account_id}/payments/refunds/by_intent/pi_abc123 \ -H "Authorization: Bearer sk_live_..." ``` # Rules and limits * **The charge must have succeeded.** You can only refund a charge in the `succeeded` state. * **You cannot refund more than the refundable amount** — the captured amount minus any refunds that have already succeeded. Fully-refunded charges have a refundable amount of `0`. * **Partial refunds** are allowed as long as the running total stays within the refundable amount. Issue several partial refunds against the same charge until it is fully refunded. * **Refunding an invoice payment** reduces the paid amount on the linked invoice(s) accordingly. # Webhook events | Event | Trigger | | ------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `refund.created` | A refund was created. | | `refund.updated` | A refund field changed. Not sent for the transition to `failed`, and not reliably sent for the transition to `succeeded` — reconcile settlements on `refund.succeeded`. | | `refund.succeeded` | The refund settled. | | `refund.failed` | The refund was rejected. Carries `failure_code` and `failure_message`. | Subscribe to `refund.succeeded` to reconcile completed refunds. It is the positive signal for a refund reaching a terminal successful state, and it fires on every path a refund can settle through — including refunds that settle asynchronously, hours after they were created. `refund.succeeded` does not replace `refund.updated` — nothing was removed, so an integration watching `refund.updated` keeps behaving exactly as it does today. If you do receive both for the same refund, treat them as the same fact rather than two refunds. Reconcile on `refund.succeeded`: it is the event that fires on every settlement path. `refund.failed` is different: it replaces `refund.updated` for the failure transition, and you will not receive both. Once `refund.succeeded` fires, the refund is terminal for every ordinary flow and the charge's refundable amount is reduced in the same transaction as the event. An administrative correction can still move a settled refund back to failed afterwards, which emits `refund.failed`. ## What the payload carries The webhook body's `data.object` carries the refund's stored columns, including `amount_atom`, `currency`, `status`, `charge_id`, `processor_refund_id`, `reason`, `failure_code`, `failure_message`, `processed_at`, `created_at`, `updated_at`. Two differences from the REST response shown above are worth knowing before you parse it: * **Your own metadata arrives as `meta`, not `metadata`.** The REST API renames the column; the webhook body does not. `metadata` in a webhook body is never the refund's metadata. * **Keys are never omitted.** A field with no value arrives as `null`, so a refund recorded out of band — which has no processor reference — arrives as `"processor_refund_id": null`. Branch on the value, not on whether the key exists. The same event is also retrievable from `GET /api/{account_id}/events/`, where it carries a separate `metadata` object describing the transition — including `amount_atom`, `currency`, `from_state`, `to_state`, and `processor_refund_id` when there is one. That object is not part of the webhook body, and it is unrelated to the refund's own `meta`. > Return funds to a customer for a charge, a payment intent, or a paid invoice — in full or in part.