# Refunds (/en/integrate/refunds)

Reverse a successful payment in full or in part. One POST against the Payment Intent or Charge, plus an optional GET to read it back.

A Refund reverses money from a successful payment back to the customer. You can refund the full amount or any partial amount, multiple times, until the payment has nothing left to refund. The API surface is small: one `POST /refunds` to issue, one `GET /refunds/:id` to read, one `GET /refunds` to list.

This page covers the developer path: when to use the API, what to send, what comes back, what events to listen for. For the dashboard-issue flow that merchants use day to day, see [Refunds](/features/transactions/refunds) under Features.

<Callout type="info">
  `POST /refunds&#x60; takes &#x2A;*`paymentIntentId`*&#x2A; (the transaction handle you stored on the order) or
  &#x2A;*`chargeId`** (a specific charge). Provide exactly one. The `paymentIntentId` path is the primary
  one and what most integrations should use; it works directly with the `pi_*` you already capture
  from `checkout.session.completed`.
</Callout>

## How it works [#how-it-works]

1. **Your server** has the `pi_*` for the payment, captured from the `checkout.session.completed` webhook (or read on demand from `GET /checkout/sessions/:id`).
2. **Your server** calls `POST /refunds` with the `paymentIntentId`. Optionally include `amount` (for partial refunds), `reason`, `description`, or `metadata`.
3. **XPay** processes the refund through the original payment method.
4. **XPay** posts a `refund.created` and a `charge.refunded` webhook so your server can mark the order refunded and notify the customer.

The Refund's own Balance Transaction is what hits your XPay balance. The signed value (negative for refunds) is what reconciles against your payouts.

## Build it [#build-it]

### 1. Create the refund [#1-create-the-refund]

The only required field on `POST /refunds` is one of `paymentIntentId` or `chargeId`. Omit `amount` to refund the full remaining refundable amount.

<Tabs items="[&#x22;cURL&#x22;, &#x22;Node.js&#x22;, &#x22;Python&#x22;]">
  <Tab value="cURL">
    ```bash
    curl -X POST https://api.xpay.app/refunds \
      -H "Authorization: Bearer sk_test_..." \
      -H "Content-Type: application/json" \
      -d '{
        "paymentIntentId": "pi_test_xyz789",
        "amount": 50000,
        "reason": "requested_by_customer",
        "metadata": { "order_id": "ord_42" }
      }'
    ```
  </Tab>

  <Tab value="Node.js">
    ```typescript
    const res = await fetch("https://api.xpay.app/refunds", {
      method: "POST",
      headers: {
        Authorization: `Bearer ${process.env.XPAY_SECRET_KEY}`,
        "Content-Type": "application/json",
      },
      body: JSON.stringify({
        paymentIntentId: "pi_test_xyz789",
        amount: 50000, // 500.00 EGP, in minor units
        reason: "requested_by_customer",
        metadata: { order_id: "ord_42" },
      }),
    });

    const refund = await res.json();
    ```
  </Tab>

  <Tab value="Python">
    ```python
    import os, requests

    res = requests.post(
        "https://api.xpay.app/refunds",
        headers={
            "Authorization": f"Bearer {os.environ['XPAY_SECRET_KEY']}",
            "Content-Type": "application/json",
        },
        json={
            "paymentIntentId": "pi_test_xyz789",
            "amount": 50000,
            "reason": "requested_by_customer",
            "metadata": {"order_id": "ord_42"},
        },
        timeout=10,
    )

    refund = res.json()
    ```
  </Tab>
</Tabs>

### 2. Refund a specific charge (alternative) [#2-refund-a-specific-charge-alternative]

If you already work at the Charge level (for example, you key fulfillment off `charge.succeeded` webhooks), pass `chargeId` instead. Same body otherwise; `chargeId` and `paymentIntentId` are mutually exclusive.

```bash
curl -X POST https://api.xpay.app/refunds \
  -H "Authorization: Bearer sk_test_..." \
  -H "Content-Type: application/json" \
  -d '{
    "chargeId": "ch_test_AbC123",
    "amount": 50000
  }'
```

When `paymentIntentId` is rejected with a "zero or multiple succeeded charges" error, fall back to `chargeId` to target the specific charge you want to refund.

### 3. Read the response [#3-read-the-response]

```json
{
  "id": "re_test_AbC123",
  "object": "refund",
  "amount": 50000,
  "currency": "EGP",
  "chargeId": "ch_test_AbC123",
  "paymentIntentId": "pi_test_xyz789",
  "status": "succeeded",
  "reason": "requested_by_customer",
  "balanceTransactionId": "txn_test_def456",
  "createdAt": "2026-05-01T12:00:00.000Z"
}
```

Every refund you create through the merchant API today resolves to `status: "succeeded"` synchronously. The <ApiLink href="/api-reference/objects/refund">Refund</ApiLink> object schema reserves additional values (`pending`, `requires_action`, `failed`) for future use; you won't see them in responses today.

The response carries both `chargeId` and `paymentIntentId` so you can wire the refund back into either side of your data model. The `balanceTransactionId` points to the ledger row that debited your available balance. For partial refunds, multiple Refund objects accumulate against one Charge; each has its own `txn_*`.

## Choosing between `paymentIntentId` and `chargeId` [#choosing-between-paymentintentid-and-chargeid]

The two fields are mutually exclusive: provide exactly one.

* **Use `paymentIntentId` by default.** The `pi_*` is the transaction handle for one successful payment; the server resolves it to the unique succeeded-and-captured charge on that intent. This is the right path for almost every integration that simply wants to refund "the payment."
* **Use `chargeId` when there's ambiguity.** A `paymentIntentId` is rejected when the Payment Intent has zero or more than one succeeded charges. That can happen on edge flows you may build later (multi-capture, partial captures across separate charges). `chargeId` always targets a specific charge.

Reach for `chargeId` only when you need that level of specificity. In the typical "customer paid once, you want to refund them" case, `paymentIntentId` is shorter, clearer, and uses the ID you already store.

## Partial vs full refunds [#partial-vs-full-refunds]

`amount` is in minor units, same as everywhere else (`50000` is 500.00 EGP).

* **Omit `amount`** to refund whatever is still refundable on the Charge. On the first refund this is the full charge total. On subsequent refunds it's whatever's left after previous partial refunds.
* **Send `amount`** to refund a specific portion. Must be at least 1 minor unit and at most the remaining refundable amount.
* You can issue **multiple partial refunds** against the same Charge as long as the total doesn't exceed the original amount. Each call creates its own `re_*` and its own Balance Transaction.
* Once the cumulative refunded amount equals the Charge total, further refund attempts return an error.

## Reason and metadata [#reason-and-metadata]

`reason` is an enumerated short code that describes why you refunded. Useful for analytics and support. Common values:

| Value                       | When to use                                                            |
| --------------------------- | ---------------------------------------------------------------------- |
| `requested_by_customer`     | The customer asked for it. Your default for self-service refunds.      |
| `duplicate`                 | The customer was charged twice for the same order.                     |
| `fraudulent`                | The charge was disputed as fraud and you're refunding proactively.     |
| `expired_uncaptured_charge` | An auth-only charge expired without capture. Rarely surfaced manually. |

`description` accepts up to 500 characters as a free-form note on the Refund. It's stored on the object and surfaces in the dashboard's refund timeline, useful for your own records and for support agents reviewing the refund later.

`metadata` is your own key-value bag, opaque to XPay, useful for cross-referencing with your order system.

## Listen for refund events [#listen-for-refund-events]

For dashboard-issued refunds, or to confirm async refunds completed, listen for refund webhooks. Two events fire on every successful refund:

| Event             | When                                                                              |
| ----------------- | --------------------------------------------------------------------------------- |
| `refund.created`  | A new Refund object was created. Fires for both API and dashboard refunds.        |
| `charge.refunded` | A Charge had a refund applied to it. Useful when you key fulfillment off Charges. |

The `data.object` on `refund.created` is a full <ApiLink href="/api-reference/objects/refund">Refund</ApiLink>, identical to `GET /refunds/:id`. On `charge.refunded` it's a full <ApiLink href="/api-reference/objects/charge">Charge</ApiLink> with the new Refund nested inside `refunds[]`.

For the signature-verification recipe and replay protection, see [Webhooks → Verifying signatures](/integrate/webhooks/verifying-signatures).

## Production checklist [#production-checklist]

* **Send an `Idempotency-Key` on `POST /refunds`.** A retry after a network timeout then returns the original refund instead of issuing a second one. See [Idempotency](/integrate/idempotency).
* **Idempotent fulfillment.** Track which Refund IDs you've already processed in your own database. `refund.created` can be redelivered.
* **Don't issue from the customer-facing app.** Refunds use a secret key (`sk_test_*` / `sk_live_*`). Issue them from your server, not from frontend code.
* **Decide who can issue refunds.** Limit refund permissions on the API key you use for this code path. For dashboard-issued refunds, restrict the **Refunds** permission on team roles.
* **Set expectations on the bank window.** A successful refund hits XPay's ledger immediately, but card refunds take 7 to 14 days to appear on the customer's statement. Tell the customer in your refund email so they don't assume it failed.
* **Reconcile against Balance Transactions.** The `balanceTransactionId` on each refund is what netted out your balance. Use it for accounting, not the Refund's `amount` alone.

## Where to next [#where-to-next]

<Cards>
  <Card icon="<Network />" title="Object model" href="/integrate/object-model">
    How Payment Intents, Charges, and Refunds fit together, and which IDs to store.
  </Card>

  <Card icon="<Webhook />" title="Set up a webhook endpoint" href="/integrate/webhooks/setting-up-an-endpoint">
    Receive `refund.created` and `charge.refunded` and verify their signatures.
  </Card>

  <Card icon="<Receipt />" title="Refunds in the dashboard" href="/features/transactions/refunds">
    The merchant-facing flow for one-off refunds without writing code.
  </Card>
</Cards>