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:
- You create it with
POST /v1/refunds, targeting anorder_id, apayment_intent_id, or both. - Flint validates it synchronously against what is actually refundable: settled payments minus prior refunds, in the payment's currency. A bad amount is a
400at create time, never a refund that limps off and dies later for a reason you could have known up front. - It settles asynchronously. The create response is a snapshot, normally
"status": "pending". The outcome arrives as arefund.updatedwebhook with"status": "succeeded", or asrefund.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_methodhas 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.
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 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.
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"
}'
{
"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.
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:
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. For adjustments layered on top, such as restocking fees, withheld amounts, or explicit tax overrides, see the Refunds API reference; the quantity and amount_money forms above cover the common cases.
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:
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, updates per-line-item, per-charge, and per-tip refunded amounts, appends an activity entry, and recomputes the order status.
Before
Paid order
{
"order_id": "ord_1kmn0aExample",
"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
{
"order_id": "ord_1kmn0aExample",
"status": "partially_refunded",
"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 transitions the order to refunded; anything less lands on partially_refunded. Both transitions also emit 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:
| Status | Meaning |
|---|---|
pending | Accepted and queued for processing. The normal create-time status. |
in_transit | The refund is on its way back to the buyer's payment method. |
requires_action | Additional action is needed before the refund can complete. Watch for the next refund.updated. |
succeeded | Funds are on their way to the buyer; Flint's processing is complete. Terminal. |
failed | The refund could not be completed. failure_reason says why. Terminal. |
canceled | The refund leg was not attempted, for example because a sibling payment refund failed first. Terminal. |
partially_succeeded | On 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".
| Event | When it fires |
|---|---|
refund.created | A refund was created. Includes the reason. |
refund.updated | The refund changed state, including reaching succeeded. |
refund.failed | The 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):
{
"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:
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_reason | What it indicates |
|---|---|
expired_or_canceled_card | The card can no longer receive credits. |
lost_or_stolen_card | The card was reported lost or stolen. |
insufficient_funds | The refund could not be funded from the available balance. Retry once the balance is replenished. |
declined | The issuer declined the refund. |
payment_disputed | The underlying payment is under dispute; the disputed amount is handled through the dispute process instead. |
merchant_request | The refund was stopped at the merchant's request. |
payment_refund_failed, refund_failed | The 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".
Refund or Void?#
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. Void the authorization instead to release the hold on the buyer's card:
curl -X POST https://api.withflintpay.com/v1/orders/ord_1kmn0aExample/payment-intents/pi_1kmn0aExample/void \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Idempotency-Key: void-pi-001"
The distinction matters to the buyer: a void makes the pending hold disappear without the charge ever posting, while a refund posts a separate credit after the charge. When you have the choice, voiding 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 own return/RMA identifier, filterable on the list endpoint.metadata: key-value pairs that flow through to webhooks and reads, updatable later viaPATCH /v1/refunds/{refund_id}.
Errors to Handle#
Refund creation validates aggressively so that mistakes are synchronous 400s rather than failed refunds. The codes worth explicit handling:
| Code | When you get it | What to do |
|---|---|---|
AMOUNT_EXCEEDS_REFUNDABLE | The 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_REFUND | The order or payment is already fully refunded. | Treat as success for "make sure this is refunded" flows. |
NO_PAYMENTS_FOR_ORDER | The 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_REFUNDABLE | The targeted payment isn't in a succeeded state. | If it's an uncaptured authorization, void it instead. |
PAYMENT_INTENT_NOT_PART_OF_ORDER | The 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_MISMATCH | amount_money.currency doesn't match the payment's currency. | Send the refund in the currency the payment settled in. |
INVALID_AMOUNT | The amount is zero or negative. | Refund amounts must be greater than zero. |
INVALID_REFUND_REASON | The reason is missing or not a supported value. | Send one of the values listed above. |
All of these arrive in the standard error envelope:
{
"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 nervous humans and crashing jobs, 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, and created/updated time windows, plus cursor pagination. A daily job that reviews failures is one line:
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:
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 and the Money Movement API 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 the void half of refund-or-void.
- Webhooks: signature verification and retry-safe handlers for
refund.*events. - Money & Currency: minor units and the
amount_moneyconventions used here. - Disputes: when the buyer goes to their bank instead of asking you.
- Reconciliation: tying refunds to payouts and bank deposits.
- Refunds API reference: every field, including line-item adjustments and tax overrides.
