Refund a customer's past charges
View as MarkdownSend money back to a customer by refunding charges they already paid, instead of sending a separate payout. For example, a customer who has spent $7,000 with you and is owed $5,000 can be refunded $5,000 across those earlier card charges. The money goes back to the cards they paid with, and you skip a payout rail and its fees.
You need two calls, both available today:
GET https://api.worthypayments.com/v1/customers/:id/transactions (payments:read)
POST https://api.worthypayments.com/v1/refunds (refunds:create, server-only)
Before you build this, agree it with your acquirer and your finance team. Refunds count toward your refund rate, and using refunds in place of payouts changes how the money is booked. This guide covers the API side only.
1. List the customer's charges#
curl "https://api.worthypayments.com/v1/customers/cus_8f1c…/transactions?limit=100" \
-H "x-api-key: sk_live_xxx"
GET /v1/customers/{id}/transactions returns the customer's charges only:
- Every sale and authorization is listed, including those synced from your gateway.
- Refund, void and capture rows never appear here.
- The list is newest first, at most 100 a page.
- To get the next page, pass the previous page's
next_cursorasstarting_after, and keep going whilehas_moreistrue.
Each row carries what you need to know what it can still give back:
| Field | Meaning |
|---|---|
amount | What the charge was, in cents. |
amount_refunded | What it has already given back, in cents. API refunds and refunds made in your gateway's own portal are both counted. |
refunded | true once that is the whole amount. |
disputed | true while a chargeback or a matched pre-dispute alert is open on it. |
status | succeeded for money taken. |
currency | The charge's currency. Refund in the same one. |
created | When the charge was made. |
card | The card the charge was paid with: brand, bin (first six to eight digits, left out when we hold none) and last4. A refund of this charge goes back to this card. |
2. Keep to the person you owe#
Each refund goes back to the card its charge was paid with, and nowhere else. One
POST /v1/refunds refunds exactly one charge. There is no bulk refund and no refund by customer,
so nothing here can send money to every card on an account at once. Which cards receive money is
decided entirely by which charges you refund.
That makes the choice of charges yours to get right. A customer record gathers every charge made under it, and one account can hold charges paid with several cards: a new card after an old one expired, a partner's card, or a card that was not the account holder's at all. Refunding "the customer's charges" without looking at the card can pay someone other than the person you owe.
So before you refund:
- Decide which card(s) belong to the person you are paying, from your own records: the card
they verified with you, the card on their payout request, or the card they confirm. Identify
each by
brand,binandlast4. - Refund only charges paid with those cards. Skip every other charge, however refundable it
looks. A charge whose
card.binis missing cannot be matched on all three, so leave it out unless your own records settle it. - Send anything left over through your usual payout to the account you have verified.
3. Pick what you can refund#
A charge is refundable when all of these hold:
- it was paid with one of the cards you kept in step 2;
statusissucceeded;refundedisfalse;disputedisfalse;- it is less than 180 days old (
created); - what remains,
amount − amount_refunded, is above zero.
Walk the list and take from each refundable charge what remains, until you reach the amount you owe. Any order works. Newest first is a sound default, because those cards are the most likely to still be open. If the refundable total is less than you owe, send the rest through your usual payout.
4. Refund each charge#
One POST /v1/refunds per charge, with an explicit amount:
curl https://api.worthypayments.com/v1/refunds \
-H "x-api-key: sk_live_xxx" \
-H "Idempotency-Key: payout-1042:TXN-8ac4796a-1940-4c6a-ba54-f151606330e3" \
-H "Content-Type: application/json" \
-d '{ "charge": "TXN-8ac4796a-1940-4c6a-ba54-f151606330e3", "amount": 2500, "currency": "usd", "reason": "requested_by_customer" }'
Idempotency-Key(header, required). Derive it from the payout and the charge, for examplepayout-1042:<charge id>. A retried request then refunds once. A fresh key on every attempt can refund twice. See Idempotency.amountandcurrency. Always pass both. Withoutcurrencythe request is read asusd, so a partial refund of a non-USD charge would be refused.reason. One ofrequested_by_customer,duplicateorfraudulent, or leave it out.
// Node 18+. `owed` in cents; `charges` are the rows from step 1 (`created` is Unix seconds).
// `ownCards` are the payee's verified cards from step 2, e.g.
// [{ brand: "visa", bin: "424242", last4: "4242" }].
const DAY_MS = 86_400_000;
function paidWithOwnCard(c, ownCards) {
const card = c.card ?? {};
return ownCards.some(
(own) =>
card.brand === own.brand &&
card.last4 === own.last4 &&
card.bin != null &&
card.bin.startsWith(own.bin),
);
}
function refundable(c, ownCards) {
const ageDays = (Date.now() - c.created * 1000) / DAY_MS;
return (
paidWithOwnCard(c, ownCards) &&
c.status === "succeeded" &&
!c.refunded &&
!c.disputed &&
ageDays < 180
);
}
async function refundOne(c, amount, payoutId, apiKey) {
for (;;) {
const res = await fetch("https://api.worthypayments.com/v1/refunds", {
method: "POST",
headers: {
"x-api-key": apiKey,
// Same key on every retry of this charge in this payout: it refunds once.
"Idempotency-Key": `payout-${payoutId}:${c.id}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ charge: c.id, amount, currency: c.currency }),
});
if (res.status !== 429) return res.ok;
// The per-key request ceiling was hit: wait as told, then retry the same request.
const wait = Number(res.headers.get("Retry-After") ?? 60) * 1000;
await new Promise((r) => setTimeout(r, wait));
}
}
async function refundOwed(charges, ownCards, owed, payoutId, apiKey) {
let left = owed;
for (const c of charges) {
if (left <= 0) break;
const remaining = c.amount - c.amount_refunded;
if (!refundable(c, ownCards) || remaining <= 0) continue;
const amount = Math.min(remaining, left);
// A refused refund (see below) moves no money: skip that charge, try the next.
if (await refundOne(c, amount, payoutId, apiKey)) left -= amount;
}
return left; // what still has to go through your usual payout
}
What the API refuses for you#
The server checks every refund itself, so a list that is a moment out of date cannot make you
pay twice. A refused refund moves no money and answers 400, with the reason in the message:
| Message | Means | Do |
|---|---|---|
TRANSACTION_DISPUTED | A chargeback or matched alert is open on the charge. The cardholder may already be getting this money back. | Skip it. |
REFUND_EXCEEDS_REMAINING | amount is more than what remains, which a refund made meanwhile can cause. | Read the charge again and refund what remains. |
ALREADY_FULLY_REFUNDED | Nothing is left. | Skip it. |
REFUND_WINDOW_EXPIRED | The charge is over 180 days old. | Skip it. |
INVALID_STATE_FOR_REFUND | The charge never captured money. | Skip it. |
REFUND_CURRENCY_MISMATCH | currency is not the charge's. | Send the charge's currency. |
A 500 is the one ambiguous answer: the refund may or may not have gone through. Retry with the
same Idempotency-Key, or check GET /v1/charges/{id}, before you count it either way.
Pace, then confirm#
- Rate limit. There is no per-minute refund budget, only the general per-key request
ceiling. Back off on a
429byRetry-After. See Rate limits. - Confirmation. Each refund raises a
charge.refundedwebhook (Webhooks).GET /v1/charges/{id}shows the newamount_refunded. Book the payout as settled by refunds for the sum that succeeded.
