Refunds

A refund returns funds for a previously-successful charge. The merchant returns money to the cardholder; the network routes it back to the original payment method; the platform records it as a Refund linked to the original transaction. It's the canonical "undo" for an inbound transfer.

Refunds are their own records on the platform, not state transitions on the parent. When you create a refund, you don't toggle the original transfer's status — you create a separate refund object that references it. The parent transfer's status continues to reflect how the original charge processed; the refund's status reflects how the return processed. Track them as separate entities; reconcile both for a full picture.

When to use refunds

The standard cases:

  • Customer requested a return (the most common case — reason: requested_by_customer).
  • Duplicate charge correction.
  • Fraud or error — a charge that shouldn't have happened.
  • Dispute resolution — the merchant chose to refund proactively rather than fight a chargeback.

For chargebacks the cardholder initiates (going through their issuing bank), see disputes — that's a different flow with different evidence requirements and a network-driven resolution path.

Creating a refund

A refund attaches to either a Transfer or a ChargeIntent — pass one (mutually exclusive). For new V1 integrations, prefer the transfer parameter:

curl --request POST \
  --url https://api.framepayments.com/v1/refunds \
  --header "Authorization: Bearer $FRAME_SECRET_KEY" \
  --header 'Content-Type: application/json' \
  --data '{
    "transfer": "<transfer_id>",
    "amount": 2000,
    "reason": "requested_by_customer"
  }'

amount is optional. Omit it for a full refund (the entire remaining balance on the charge); supply it for a partial refund (any amount up to the remaining balance).

reason is a categorical string. Frame surfaces a handful of merchant-supplied reasons (requested_by_customer, duplicate, fraudulent, etc.) — these are bookkeeping signal for your dashboard + downstream reporting, not policy levers. The reason doesn't change what Frame does mechanically; it changes what merchants see in their analytics.

The response is the Refund record. The interesting fields:

  • id — the refund's identifier.
  • amount — the refunded amount.
  • status — see lifecycle below.
  • transfer or charge_intent — reference back to the parent transaction.
  • created / updated — Unix timestamps.

Refund lifecycle

Refunds don't have their own standalone state machine — the status field on a refund's serialized response reflects the underlying Charge's state. When a refund is initiated, the parent Charge transitions through its state machine; the refund's status mirrors whichever state the Charge currently sits in.

The states you'll see for refund-related transitions:

  • pending — refund accepted; the network hasn't confirmed the return yet.
  • refunded — funds have returned. The cardholder typically sees the credit within minutes for cards, 1-2 business days for ACH.
  • failed — the network rejected the refund (rare; usually means the original payment method became invalid between the charge and the refund attempt).

The full Charge state machine has additional states (succeeded, disputed, reversed, etc.) that don't apply to most refund flows — they describe the original charge's lifecycle, not the refund's. For refund-tracking purposes, pendingrefunded (or failed) is the practical window.

Refund settlement is asynchronous for both rails — even for cards, where the original charge was synchronous, the return is queued through the network. Don't treat the immediate response as a success signal; listen for the refund webhook or poll getRefund to confirm.

Partial refunds

Multiple partial refunds can be issued against a single charge, up to the original amount:

  • Each partial refund is a separate Refund record.
  • The sum of refunded amounts can't exceed the charge's original amount.
  • Once the charge is fully refunded (sum of refunds = charge amount), further refund attempts return an error.

This is useful for return-some-items scenarios (the customer kept part of the order), prorated cancellations, or correcting accidentally-overcharged amounts.

Relationship to the parent transfer

The cleanest mental model: refunds are siblings of the parent transfer, not status transitions on it. The parent transfer's status continues to describe how the original charge processed — succeeded means the original charge succeeded, regardless of how many refunds you subsequently issued against it.

To get the full picture of a transaction's lifecycle:

  • Read the original transfer for the charge details (status: succeeded, amount, payment method, etc.).
  • List refunds for that transfer to see what's been returned (listRefunds?transfer=<id>).
  • Sum the refund amounts to know how much has been returned vs how much the merchant still keeps.

The Transfer model has internal refunded and reversed states for edge cases (full-refund collapse, processor-side reversals during settlement), but in practice the merchant-facing answer to "what happened with this transaction" comes from reading both the transfer and its refund records together.

Webhooks

Refund events are emitted on the underlying Charge model (refunds are state transitions on the parent charge, not standalone webhook subjects). The key event to listen for:

  • charge.refunded — fires when a refund completes successfully against the parent charge.

The webhook payload includes the parent charge record with its updated refund state. Inspect the related refund records via listRefunds?transfer=<id> to get details on the specific refund that completed. Other charge-scoped events also fire when the parent charge's state changes (charge.reversed on a reversal, for example).

Gotchas

Symptom: the refund response shows status: refunded but the customer says the money hasn't appeared. Why: "refunded" on the Frame record means Frame and the processor accepted the refund — the funds posting to the customer's account is a downstream operation handled by the issuing bank (cards typically post within a few business days; ACH 1-2 business days). Fix: this is expected lag; don't treat the API response as the customer-visible posting time.

Symptom: you tried to refund a transfer that's still in pending and got an error. Why: you can't refund a charge that hasn't completed — there's nothing to return yet. Fix: wait for the transfer to reach succeeded, then issue the refund. For canceling a pending charge before it captures, look at the cancellation flow (not exposed in V1; contact Frame support if you need it).

Symptom: you issued multiple partial refunds and the next one fails with "amount exceeds remaining balance." Why: the sum of prior refunds already covered the charge amount. Fix: call listRefunds?transfer=<id> and sum existing refunds to know what's still refundable. The math: charge_amount - sum(refund.amount) = remaining refundable.

Symptom: a refund succeeded but the parent transfer's status didn't change to reversed or refunded. Why: the parent transfer's status describes how the original charge processed; refunds are separate records that don't necessarily mutate it. Fix: don't rely on the transfer's status for refund tracking. Read the refund records via listRefunds?transfer=<id> to know the net state.

Reference

For the full API surface, see POST/v1/refunds and the rest of the Refunds resource.