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
- Your server has the
pi_*for the payment, captured from thecheckout.session.completedwebhook (or read on demand fromGET /checkout/sessions/:id). - Your server calls
POST /refundswith thepaymentIntentId. Optionally includeamount(for partial refunds),reason,description, ormetadata. - XPay processes the refund through the original payment method.
- XPay posts a
refund.createdand acharge.refundedwebhook 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
paymentIntentIdby default. Thepi_*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
chargeIdwhen there's ambiguity. ApaymentIntentIdis 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).chargeIdalways 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
amountto 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
amountto 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:
| 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
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 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.createdcan 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
balanceTransactionIdon each refund is what netted out your balance. Use it for accounting, not the Refund'samountalone.
Where to next
Advanced configuration
Submit button text, locale, branding, payment-method restrictions, promotion codes, fees, expiration, and metadata. Everything else you can set on a Checkout Session.
Payment Links (no-code)
Use the URL your merchant created in the dashboard. Embed it on a site, drop it in an email, print a QR code, or listen for paid events with a webhook.