Refund a customer's past charges

View as Markdown

Send 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#

bash
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_cursor as starting_after, and keep going while has_more is true.

Each row carries what you need to know what it can still give back:

FieldMeaning
amountWhat the charge was, in cents.
amount_refundedWhat it has already given back, in cents. API refunds and refunds made in your gateway's own portal are both counted.
refundedtrue once that is the whole amount.
disputedtrue while a chargeback or a matched pre-dispute alert is open on it.
statussucceeded for money taken.
currencyThe charge's currency. Refund in the same one.
createdWhen the charge was made.
cardThe 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:

  1. 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, bin and last4.
  2. Refund only charges paid with those cards. Skip every other charge, however refundable it looks. A charge whose card.bin is missing cannot be matched on all three, so leave it out unless your own records settle it.
  3. 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;
  • status is succeeded;
  • refunded is false;
  • disputed is false;
  • 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:

bash
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 example payout-1042:<charge id>. A retried request then refunds once. A fresh key on every attempt can refund twice. See Idempotency.
  • amount and currency. Always pass both. Without currency the request is read as usd, so a partial refund of a non-USD charge would be refused.
  • reason. One of requested_by_customer, duplicate or fraudulent, or leave it out.
js
// 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:

MessageMeansDo
TRANSACTION_DISPUTEDA chargeback or matched alert is open on the charge. The cardholder may already be getting this money back.Skip it.
REFUND_EXCEEDS_REMAININGamount is more than what remains, which a refund made meanwhile can cause.Read the charge again and refund what remains.
ALREADY_FULLY_REFUNDEDNothing is left.Skip it.
REFUND_WINDOW_EXPIREDThe charge is over 180 days old.Skip it.
INVALID_STATE_FOR_REFUNDThe charge never captured money.Skip it.
REFUND_CURRENCY_MISMATCHcurrency 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 429 by Retry-After. See Rate limits.
  • Confirmation. Each refund raises a charge.refunded webhook (Webhooks). GET /v1/charges/{id} shows the new amount_refunded. Book the payout as settled by refunds for the sum that succeeded.

See also Refunds and Customers.