docs
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 under Features.

POST /refunds takes paymentIntentId (the transaction handle you stored on the order) or 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.

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

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.

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" }
  }'
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();
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()

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.

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

{
  "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 RefundAPI 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

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

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 is an enumerated short code that describes why you refunded. Useful for analytics and support. Common values:

ValueWhen to use
requested_by_customerThe customer asked for it. Your default for self-service refunds.
duplicateThe customer was charged twice for the same order.
fraudulentThe charge was disputed as fraud and you're refunding proactively.
expired_uncaptured_chargeAn 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

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

EventWhen
refund.createdA new Refund object was created. Fires for both API and dashboard refunds.
charge.refundedA Charge had a refund applied to it. Useful when you key fulfillment off Charges.

The data.object on refund.created is a full RefundAPI, identical to GET /refunds/:id. On charge.refunded it's a full ChargeAPI with the new Refund nested inside refunds[].

For the signature-verification recipe and replay protection, see Webhooks → Verifying signatures.

Production checklist

  • 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

On this page