Refunds

A refund returns money to your buyer, back to the payment method they paid with. On Flint, refunds are order-aware: refund a whole order, an exact amount, specific line items or charges, or a single payment on a multi-payment order, and the order's totals, per-line-item amounts, and status all update to match. You choose the refund against a structured order instead of reverse-engineering what a processor-side amount meant.

This guide covers creating refunds, tracking them to completion, and the failure cases a production integration has to handle. Everything behaves identically with a test key; see Testing to stage a payment you can refund.

How Refunds Work#

Every refund moves through the same small lifecycle:

  1. You create it with POST /v1/refunds, targeting an order_id, a payment_intent_id, or both.
  2. Flint validates it synchronously against what is actually refundable: settled payments minus prior refunds and bank returns, in the payment's currency. A bad amount is a 400 at create time, never a refund that appears to succeed and then fails asynchronously for a reason you could have known up front.
  3. It settles asynchronously. The create response is a snapshot, normally "status": "pending". The outcome arrives as a refund.updated webhook with "status": "succeeded", or as refund.failed.

Three properties to design around:

  • Refunds are asynchronous. Treat the create response as "accepted", not "done". Card networks occasionally reject refunds hours later, so anything you show the buyer should follow the webhook, not the create call.
  • Funds return to the original payment method. refund_method has exactly one value today, original_payment. There is no store-credit or alternate-destination refund; if the original method can no longer receive funds, the refund fails and you resolve it with the buyer directly (see When a Refund Fails).
  • There is no undo. The API has no cancel-refund endpoint. Confirm the amount before you send it; the server blocks over-refunds, but it cannot block a refund you meant to send for less.
  • The processing fee stays charged. Refunding returns the buyer's money, not the fee Flint charged to process the original payment, and Flint adds no separate refund fee. A fully refunded payment therefore leaves you down its original processing_fee_money. See Processing Fees.

succeeded means Flint's side is done. The buyer's bank typically posts the credit to their statement within 5 to 10 business days, and that last leg is controlled by the issuer, not by any API. Setting that expectation in your refund confirmation email saves a support ticket.

Refund, Return, or Cancel#

Three operations put value back with the buyer. Pick by what is physically happening:

SituationUseTypical case
Money goes back, nothing comes backPOST /v1/refundsDuplicate charge, service problem, goodwill, an order canceled before it shipped
Merchandise comes backReturnsA buyer sends goods back, or hands them over at the counter
The payment is an uncaptured authorizationCancel itNothing was captured, so there is nothing to refund

A Return that resolves as a refund creates an ordinary Refund on the same order, drawn from the same refundable balance and returned by the endpoints on this page. It carries return_id and return_resolution_id so you can tell the two apart; refunds you create directly omit both, and both are filters on GET /v1/refunds.

Going straight to POST /v1/refunds when merchandise is coming back skips the other half of the job: no eligibility check against your return policy, no restocking fee, no inspection, and no stock returned to a location.

Refund an Order in Full#

Omit amount_money and Flint refunds everything still refundable on the order. reason is always required (see Choose a Reason), and the Idempotency-Key header makes retries safe: a network timeout plus a retry yields one refund, not two.

Bash
curl -X POST https://api.withflintpay.com/v1/refunds \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Idempotency-Key: refund-ord-001" \
  -d '{
    "order_id": "ord_1kmn0aExample",
    "reason": "requested_by_customer"
  }'
JSON
{
  "data": {
    "refund_id": "ref_1kmn0aExample",
    "order_id": "ord_1kmn0aExample",
    "status": "pending",
    "reason": "requested_by_customer",
    "amount_money": {"amount": 2500, "currency": "USD"},
    "refunded_tip_money": {"amount": 0, "currency": "USD"},
    "refund_method": "original_payment",
    "payment_refunds": [
      {
        "payment_intent_id": "pi_1kmn0aExample",
        "amount_money": {"amount": 2500, "currency": "USD"},
        "refunded_tip_money": {"amount": 0, "currency": "USD"},
        "status": "pending"
      }
    ],
    "created_at": "2026-07-02T17:03:00Z"
  }
}

amount_money on the response is what Flint resolved the full refund to. payment_refunds lists the execution against each underlying payment; for an order paid in one payment it has a single entry, and for a multi-payment order there is one entry per payment, each with its own status (see Refund One Payment on a Multi-Payment Order).

Partial Refunds#

Pass amount_money to refund part of the order. Amounts are integers in the currency's minor unit, so 500 is $5.00, and the currency must match the payment's currency.

Bash
curl -X POST https://api.withflintpay.com/v1/refunds \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Idempotency-Key: refund-ord-002" \
  -d '{
    "order_id": "ord_1kmn0aExample",
    "amount_money": {"amount": 500, "currency": "USD"},
    "reason": "requested_by_customer"
  }'

You can issue as many partial refunds as you like; Flint tracks the running total and rejects any refund that would exceed what remains, with AMOUNT_EXCEEDS_REFUNDABLE. Once the order is fully refunded, further attempts fail with NOTHING_TO_REFUND. The guard is computed from settled payments, not from the order's list price, so discounts, tips, and multiple payments are already accounted for.

If the order includes a tip, refunds draw down the purchase amount first; the tip is returned last. The tip portion of each refund is reported separately as refunded_tip_money on the refund and on each entry in payment_refunds, so your reporting can keep sales and gratuity apart.

Refund Specific Line Items or Charges#

When the buyer returns one item out of a larger order, you can refund by structure instead of doing the arithmetic yourself. Pass line_items referencing the order's line item IDs, with either a quantity (refund that many units at the price actually paid) or an explicit amount_money, but not both:

Bash
curl -X POST https://api.withflintpay.com/v1/refunds \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Idempotency-Key: refund-ord-003" \
  -d '{
    "order_id": "ord_1kmn0aExample",
    "reason": "defective_product",
    "line_items": [
      {"order_line_item_id": "li_1kmn0aExample", "quantity": 1}
    ]
  }'

Flint computes the refund amount from what that line actually settled for, including its share of tax (the default tax_refund_mode is automatic). The response includes line_item_allocations, a per-line breakdown of the refunded quantity, money, and tax, and the order's own line_items pick up refunded_quantity and refunded_money so support and reporting see the same story.

Order-level charges (delivery fees, service fees, surcharges; see Tips & Fees) are refundable the same way: pass charges with the order_charge_id and an optional amount_money for a partial charge refund.

Line-item and charge targeting require order_id. The quantity and amount_money forms above cover the common cases. For adjustments layered on top, such as restocking fees and withheld amounts, see the Refunds API reference. For overriding how much tax reverses, and how flat order tax behaves on a partial refund, see Sales Tax.

Refund One Payment on a Multi-Payment Order#

An order can settle across multiple payments. When you refund by order_id, Flint plans the refund across the order's succeeded payments for you, starting with the largest, and reports each leg in payment_refunds.

To put money back on one specific payment instead, target it directly:

Bash
curl -X POST https://api.withflintpay.com/v1/refunds \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Idempotency-Key: refund-pi-001" \
  -d '{
    "payment_intent_id": "pi_1kmn0aExample",
    "amount_money": {"amount": 500, "currency": "USD"},
    "reason": "duplicate"
  }'

The payment must have succeeded, or the request fails with PAYMENT_INTENT_NOT_REFUNDABLE. If the payment belongs to an order, the refund still attaches to that order and updates it exactly as an order-targeted refund would; order_id is filled in on the response for you. You may pass both order_id and payment_intent_id, and Flint verifies they match (PAYMENT_INTENT_NOT_PART_OF_ORDER if they don't).

What Happens to the Order#

Refunds don't just move money; they keep the order truthful. As refund legs settle, Flint recalculates the order's settlement amounts and refund_status, updates per-line-item, per-charge, and per-tip refunded amounts, and appends an entry to the order's activity log (a refund row, or refund_failed if a leg does not complete). The workflow status remains closed, and payment_status remains paid because money did settle before it was refunded.

Before

Paid order

JSON
{
  "order_id": "ord_1kmn0aExample",
  "status": "closed",
  "payment_status": "paid",
  "refund_status": "none",
  "refund_ids": [],
  "settlement_amounts": {
    "paid_money": {"amount": 2500, "currency": "USD"},
    "refunded_money": {"amount": 0, "currency": "USD"}
  }
}

After a $5.00 refund settles

Partially refunded

JSON
{
  "order_id": "ord_1kmn0aExample",
  "status": "closed",
  "payment_status": "paid",
  "refund_status": "partially_refunded",
  "refund_ids": ["ref_1kmn0aExample"],
  "settlement_amounts": {
    "paid_money": {"amount": 2500, "currency": "USD"},
    "refunded_money": {"amount": 500, "currency": "USD"}
  }
}

A full refund sets refund_status to refunded; anything less sets it to partially_refunded. The order stays closed and paid on its other two axes. Each refund also emits an order.refunded webhook carrying the order_id, the refund_id, the refund amount, and the order's new refunded total, which is often the only event a fulfillment or accounting system needs to subscribe to.

Payment intents mirror the same information from the payment's point of view: a refunded payment carries its own refund_status and refunded_money.

Track the Refund to Completion#

A refund's status moves through:

StatusMeaning
pendingAccepted and queued for processing. The normal create-time status.
in_transitThe refund is on its way back to the buyer's payment method.
requires_actionAdditional action is needed before the refund can complete. Watch for the next refund.updated.
succeededFunds are on their way to the buyer; Flint's processing is complete. Terminal.
failedThe refund could not be completed. failure_reason says why. Terminal.
canceledThe refund leg was not attempted, for example because a sibling payment refund failed first. Terminal.
partially_succeededOn a multi-payment refund, some payment legs succeeded and others did not. Check payment_refunds for the split. Terminal.

Three events cover the whole lifecycle. Note there is no refund.succeeded event; success is refund.updated with "status": "succeeded".

EventWhen it fires
refund.createdA refund was created. Includes the reason.
refund.updatedThe refund changed state, including reaching succeeded.
refund.failedThe refund failed permanently. Fires once per refund, alongside the final refund.updated.

A delivered event looks like this (see Webhooks for signature verification and retry handling):

JSON
{
  "webhook_event_id": "whev_1kmn0aExample",
  "event_type": "refund.updated",
  "payload_version": 1,
  "mode": "test",
  "merchant_id": "mer_1kmn0aExample",
  "created_at": "2026-07-02T17:04:11Z",
  "data": {
    "refund_id": "ref_1kmn0aExample",
    "order_id": "ord_1kmn0aExample",
    "payment_intent_id": "pi_1kmn0aExample",
    "status": "succeeded",
    "amount_money": {"amount": 500, "currency": "USD"}
  }
}

A minimal handler routes on the terminal states:

JavaScript
switch (event.event_type) {
  case "refund.updated": {
    const refund = event.data;
    if (refund.status === "succeeded") {
      await markRefundComplete(refund.refund_id);
      await notifyBuyer(refund.order_id, refund.amount_money);
    }
    break;
  }
  case "refund.failed": {
    await flagForSupport(event.data.refund_id, event.data.failure_reason);
    break;
  }
}

If you can't receive webhooks, poll GET /v1/refunds/{refund_id} until the status is terminal. Prefer webhooks in production; most refunds resolve quickly, but a refund can sit pending or in_transit far longer than any sensible polling window when banks are slow.

When a Refund Fails#

Refunds fail for real-world reasons: the card was canceled or expired, the account behind it is closed, or the issuer declined the credit. A failed refund means the money did not move; it is still in your balance, and the buyer has not been paid back.

The refund's failure_reason (also present on refund.failed events and on each failed entry in payment_refunds) tells you why:

failure_reasonWhat it indicates
expired_or_canceled_cardThe card can no longer receive credits.
lost_or_stolen_cardThe card was reported lost or stolen.
insufficient_fundsThe refund could not be funded from the available balance. Retry once the balance is replenished.
insufficient_available_balanceThe available balance could not cover the refund when Flint dispatched it; the create call reports this synchronously as REFUND_INSUFFICIENT_AVAILABLE_BALANCE. Restore the balance, then retry.
declinedThe issuer declined the refund.
payment_disputedThe underlying payment is under dispute; the disputed amount is handled through the dispute process instead.
merchant_requestThe refund was stopped at the merchant's request.
payment_refund_failed, refund_failedThe refund failed in processing without a more specific reason.

When a refund fails, resolve it with the buyer directly: confirm where they can receive funds and, where the failure was transient (like an insufficient balance), issue a new refund. Amounts that failed or were canceled never count as refunded, so the refundable balance still covers them; a fresh POST /v1/refunds with a new Idempotency-Key is the retry path.

On a multi-payment refund, one failing leg does not roll back the others. Legs that already succeeded stay succeeded, remaining unattempted legs are canceled, and the parent refund lands on partially_succeeded or failed. Always read payment_refunds before deciding what to tell the buyer: "we returned $30 of your $50 and are fixing the rest" is a very different message from "your refund failed".

ACH Returns and Refunds#

A bank return is the buyer's bank pulling an ACH debit payment back days after it succeeded. It has nothing to do with merchandise coming back, which is a Return.

A bank return is not a refund: it arrives as a dispute with case_type bank_return (see Disputes), and it reduces what remains refundable on the payment exactly as a prior refund would. A full bank return leaves nothing to refund, so a later POST /v1/refunds fails with NOTHING_TO_REFUND.

A refund and a bank return that race are serialized so the same funds cannot move twice. If the bank return lands first, refund creation fails with NOTHING_TO_REFUND; a refund already in flight when the bank return applies fails with failure_reason payment_disputed. Treat either outcome as "the buyer already has the money back through their bank".

Refunds are funded from your available balance, and a bank return withdraws from that same balance. When the balance cannot cover a refund, creation fails with REFUND_INSUFFICIENT_AVAILABLE_BALANCE; restore the available balance, then retry the same request with the same Idempotency-Key.

Cancel an Uncaptured Authorization#

Refunds apply to captured money. If you use manual capture and the payment is still an uncaptured authorization, there is nothing to refund yet, and POST /v1/refunds fails with PAYMENT_INTENT_NOT_REFUNDABLE. Cancel the authorization instead to release the hold on the buyer's card:

Bash
curl -X POST https://api.withflintpay.com/v1/orders/ord_1kmn0aExample/payment-intents/pi_1kmn0aExample/cancel \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Idempotency-Key: cancel-pi-001" \
  -d '{"payment_attempt_id": "opat_1kmn0aExample"}'

The distinction matters to the buyer: cancellation makes the pending hold disappear without the charge ever posting, while a refund posts a separate credit after the charge. Canceling an uncaptured authorization is faster and cleaner than capturing and refunding.

Choose a Reason#

reason is required on every refund. It never changes how the money moves; it drives your reporting, support tooling, and the dashboard's refund views, so pick the most specific one that is true:

  • The classic three: requested_by_customer, duplicate, fraudulent.
  • Product or fulfillment problems: defective_product, wrong_item_shipped, never_received, not_as_described, arrived_too_late.
  • Buyer decisions: customer_changed_mind, better_price_found, accidental_order.
  • Anything else: other.

Three companion fields make refunds traceable later:

  • reason_message: free-text context for humans ("buyer sent photos of the cracked mug").
  • external_reference_id: your identifier from a system Flint does not own, filterable on the list endpoint. A refund raised by a Flint Return carries return_id and return_resolution_id instead, so keep this field for RMA numbers that live elsewhere.
  • metadata: key-value pairs that flow through to webhooks and reads, updatable later via PATCH /v1/refunds/{refund_id}.

A refund created by a Return resolution takes its reason from the buyer's return reason, mapped onto this list: defective becomes defective_product, wrong_item becomes wrong_item_shipped, too_small, too_large, and damaged_on_arrival become not_as_described, arrived_late becomes arrived_too_late, changed_mind becomes customer_changed_mind, and no_longer_needed becomes requested_by_customer. Your own return reasons, and a refund covering lines that gave different reasons, both map to other. Read the Return when you need the buyer's exact wording.

Errors to Handle#

Refund creation validates aggressively so that mistakes are synchronous 400s rather than failed refunds. The codes worth explicit handling:

CodeWhen you get itWhat to do
AMOUNT_EXCEEDS_REFUNDABLEThe amount is more than what remains refundable.Re-fetch the order or list its refunds, then retry with the remaining amount, or omit amount_money to refund exactly what's left.
NOTHING_TO_REFUNDThe order or payment is already fully refunded, or a bank return already pulled the settled money back.Treat as success for "make sure this is refunded" flows.
NO_PAYMENTS_FOR_ORDERThe order has no succeeded payments.Nothing was collected, so there is nothing to return. If a payment is mid-flight, wait for it to settle.
PAYMENT_INTENT_NOT_REFUNDABLEThe targeted payment isn't in a succeeded state.If it's an uncaptured authorization, cancel it instead.
PAYMENT_INTENT_NOT_PART_OF_ORDERThe payment_intent_id and order_id you passed don't belong together.Fix the pairing; this usually indicates crossed wires in your own lookup.
CURRENCY_MISMATCHamount_money.currency doesn't match the payment's currency.Send the refund in the currency the payment settled in.
INVALID_AMOUNTThe amount is zero or negative.Refund amounts must be greater than zero.
INVALID_REFUND_REASONThe reason is missing or not a supported value.Send one of the values listed above.
REFUND_INSUFFICIENT_AVAILABLE_BALANCEThe connected account's available balance cannot fund the refund.Restore the available balance, then retry with the same Idempotency-Key.

All of these arrive in the standard error envelope:

JSON
{
  "error": {
    "type": "validation_error",
    "code": "AMOUNT_EXCEEDS_REFUNDABLE",
    "message": "Refund amount exceeds remaining refundable amount",
    "request_id": "9d2b6e4f-3a8c-4d1e-8c47-5b9a2e0d4f36"
  }
}

Two operational notes. First, always send an Idempotency-Key: refunds are exactly the kind of write that gets retried, by a support agent clicking twice or a job restarting mid-run, and the key guarantees one refund per intent (see Idempotency). Second, expect races: if a support agent refunds in the dashboard while your automation refunds via API, one of the two gets AMOUNT_EXCEEDS_REFUNDABLE or NOTHING_TO_REFUND. That is the guard working; handle it as "already resolved", not as an alert.

Find and Reconcile Refunds#

GET /v1/refunds supports filtering by order_id, payment_intent_id, customer_id, status, reason, refund_method, amount range (min_amount/max_amount with currency), external_reference_id, return_id, return_resolution_id, and created/updated time windows, plus cursor pagination. A daily job that reviews failures is one line:

Bash
curl "https://api.withflintpay.com/v1/refunds?status=failed&created_after=2026-07-01T00:00:00Z" \
  -H "Authorization: Bearer YOUR_API_KEY"

Single reads support expand (order, payment_intent, customer) when you want the surrounding context in one call:

Bash
curl "https://api.withflintpay.com/v1/refunds/ref_1kmn0aExample?expand=order" \
  -H "Authorization: Bearer YOUR_API_KEY"

For money-level reconciliation, refunds post as negative entries in the balance transaction ledger and net against payouts; see Reconciliation for matching refunds back to bank deposits.

Test Refunds#

In a sandbox, refunds run the same asynchronous lifecycle as live, with no real money moving. The loop takes two minutes: stage a paid order with the 4242 4242 4242 4242 test card (Testing walks through it), refund it with the calls above, and watch refund.created and refund.updated arrive at your webhook endpoint. Also test the unhappy paths deliberately: refund an unpaid order to see NO_PAYMENTS_FOR_ORDER, and refund one cent more than what's left to see AMOUNT_EXCEEDS_REFUNDABLE.

Next Steps#

  • Manual Capture: holds, captures, and canceling an uncaptured authorization.
  • Webhooks: signature verification and retry-safe handlers for refund.* events.
  • Money & Currency: minor units and the amount_money conventions used here.
  • Processing Fees: why a refunded payment still costs you its original fee.
  • Disputes: when the buyer goes to their bank instead of asking you.
  • Reconciliation: tying refunds to payouts and bank deposits.
  • Sales Tax: how tax reverses, and how to override it.
  • Refunds API reference: every field, including line-item adjustments and tax overrides.
Rate this doc