The entire targeting surface of a Stripe refund is a payment reference and an amount:
curl https://api.stripe.com/v1/refunds \
-u "sk_test_...:" \
-d payment_intent=pi_... \
-d amount=1437
Send it and 14.37 moves back to the buyer. Whether that was one defective mug out of a six-item order or a goodwill gesture is recorded nowhere Stripe can see, which is why everything downstream of the refund, from the Dashboard to your accounting connector, shows an unallocated amount. The ask is not new: when a developer requested item-level credits on Stripe's api-discuss list, the answer was that negative line items are not possible and to fake the credit with a coupon amount_off (December 2020; the thread is in the sources). The refund API has not gained a line item parameter since.
Which path applies to you
If an invoice sits behind the payment: issue a credit note
Credit notes are the one place Stripe does line-level refunds, and they attach to invoices only. Subscriptions and Stripe Invoicing always create an invoice. Payment-mode Checkout and Payment Links create one only when invoice_creation is enabled. A plain PaymentIntent never has one. The PaymentIntent object no longer carries an invoice field on current API versions, so check by listing invoice payments:
# An empty list means no invoice: credit notes are not available
curl -G https://api.stripe.com/v1/invoice_payments \
-u "sk_test_...:" \
-d "payment[type]=payment_intent" \
-d "payment[payment_intent]=pi_..."
An empty data array closes this path; skip to computing the amount. A hit gives you the invoice ID, and from there a credit note refunds the line directly: reference the invoice line item and a quantity, and Stripe works out that line's share of tax and discounts. refund_amount sends the credited amount back to the original payment method:
curl https://api.stripe.com/v1/credit_notes \
-u "sk_test_...:" \
-d invoice=in_... \
-d "lines[0][type]=invoice_line_item" \
-d "lines[0][invoice_line_item]=il_..." \
-d "lines[0][quantity]=1" \
-d refund_amount=1437
You can link refunds you created separately through refunds[] instead. Credit notes stay inside the invoice's arithmetic: combined credit notes and refunds cannot exceed what the invoice charged.
Everywhere else: compute the amount yourself
The recipe: read what the line settled for, split it per unit without losing cents, refund that number, and write down what it was for. For Checkout, the session's line items carry the settled amounts, discounts and tax included. Payment Link payments create Checkout Sessions too, so the same call covers them:
const { data: lineItems } = await stripe.checkout.sessions.listLineItems(
sessionId,
{ limit: 100 },
);
// Each line item reports what the buyer actually paid for it:
// amount_subtotal, amount_discount, amount_tax, amount_total, quantity
const line = lineItems.find((li) => li.price.product === productId);
amount_total is what the buyer paid for the line after discounts, with tax inside or on top depending on the price's tax_behavior. Split it per unit once and keep the array:
// Split a line's settled total into per-unit amounts, in integer minor
// units, so that repeated partial refunds sum exactly to the total.
function perUnitAmounts(amountTotal, quantity) {
const base = Math.floor(amountTotal / quantity);
const extra = amountTotal - base * quantity;
return Array.from({ length: quantity }, (_, i) =>
i < extra ? base + 1 : base,
);
}
// A 3-unit line that settled at 4310 with tax and discounts in it:
perUnitAmounts(4310, 3); // [1437, 1437, 1436]
Then refund the unit's amount, and make the refund carry its own explanation:
const refund = await stripe.refunds.create(
{
payment_intent: paymentIntentId,
amount: 1437,
metadata: {
// Fixed keys your accounting integration reads back from the
// webhook events that carry this refund. Stripe never reads them.
line_item_id: line.id,
product_id: line.price.product,
units_refunded: "1",
},
},
{ idempotencyKey: `refund-${orderId}-${line.id}-unit-1` },
);
Stripe checks this refund against the charge's remaining balance and nothing else. It will process a second refund for the same unit as long as the charge has balance left, so per-line bookkeeping is yours; the traps below are where that bookkeeping goes wrong. For a plain PaymentIntent with no session, the same recipe applies, with the line amounts coming from your own order data, because Stripe never saw the items.
The four places the math goes wrong
Getting line detail into your accounting system
Accounting integrations build credit memos from data on the refund. Stripe's refund object gives them an amount, a payment reference, and nothing else. That is why NetSuite, ERP, and OMS connectors show Stripe refunds as unallocated amounts against an order, and why credit memos come out as one-line adjustments instead of mirroring the items that came back. The connector is not broken; there is nothing for it to read.
Two ways to give it something to read:
- The credit note, when there is an invoice. It is itself the line-level record, and connectors that understand Stripe invoicing can mirror its lines into a credit memo directly.
- Metadata on the refund, everywhere else. Pick a fixed convention once and write it at create time, as in the sample above: the line item ID or SKU, units, and amounts. Metadata holds up to 50 keys with 40-character names and 500-character values, enough for a compact JSON value covering several items, and it rides along in the webhook events that carry the refund, so the connector can allocate without another API call.
Neither fixes history. A refund created without context stays unallocated, so if reconciliation matters, put the convention in place before the next refund rather than after.
Questions, answered
Can you refund a single line item in Stripe?
Not directly. A Stripe refund is an amount against a charge or PaymentIntent and carries no reference to items. If the payment has an invoice behind it, a credit note can credit specific invoice lines and pay the money back. Everywhere else you compute the item's share of the payment yourself and refund that amount.
Can I use a credit note to refund a Stripe Checkout payment?
Only when the session produced an invoice. Subscription-mode sessions always do. Payment-mode Checkout and Payment Links produce one only when invoice creation is enabled, and a plain PaymentIntent never has one, so there is nothing for a credit note to attach to. For those payments, compute the amount yourself.
Does Stripe calculate the tax share when I refund part of a payment?
No. The amount you send is the amount that moves. For a partial refund on Checkout or a PaymentIntent you include the item's tax share in the amount yourself, and any tax transaction you recorded needs its own reversal. Credit notes are the exception: on an invoice, the credited lines carry their tax with them.
How do I record which items a Stripe refund covered?
Write it on the refund's metadata at create time with a fixed set of keys, and read it back from the webhook events that carry the refund. When the payment has an invoice, issue a credit note instead; the credit note itself is the line-level record.
Why doesn't my accounting integration show which items were refunded?
Because the refund object has no line items to read. Connectors that build credit memos can only allocate a refund when something carries the line detail: a credit note on an invoice, or metadata your integration wrote on the refund when it was created.
What is the difference between a refund and a credit note?
A refund returns money the buyer already sent. A credit note reduces what they still owe. Stripe puts both behind one object: a credit note on an open invoice lowers the amount due, and the same call with refund_amount pays money back instead. Flint keeps them apart. Refunds target order line items and move money; credit notes credit invoice lines and move none, so the one you need depends on whether the invoice has been paid.
Line-item refunds on Flint
Flint refunds are order-aware, so the request names the items. Pass the order's line item ID and a quantity, and the server computes the amount from what that line settled for, its discount share and tax included:
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-101" \
-d '{
"order_id": "ord_1kmn0aExample",
"reason": "requested_by_customer",
"line_items": [
{"order_line_item_id": "li_1kmn0aExample", "quantity": 1}
]
}'
The response itemizes the arithmetic per line in line_item_allocations: the subtotal, the discount share, the tax that reversed, and the total that moved:
"line_item_allocations": [
{
"order_line_item_id": "li_1kmn0aExample",
"quantity": 1,
"refunded_money": {"amount": 1437, "currency": "USD"},
"automatic_refund": {
"subtotal_money": {"amount": 1499, "currency": "USD"},
"discount_money": {"amount": 150, "currency": "USD"},
"tax_money": {"amount": 88, "currency": "USD"},
"total_money": {"amount": 1437, "currency": "USD"}
},
"tax_refund_mode": "automatic"
}
]
The proration, the rounding, and the per-line ledger are the server's job here. Repeated partial refunds draw down each line's remaining balance, refunding a line past what it settled for fails with a specific error instead of silently succeeding, and the rounding is deterministic integer math. Tax reverses automatically from the settled amounts, with an explicit override for the cases that need to reverse less. Restocking fees and withheld amounts are adjustments on the same request:
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-102" \
-d '{
"order_id": "ord_1kmn0aExample",
"reason": "customer_changed_mind",
"line_items": [
{
"order_line_item_id": "li_1kmn0aExample",
"quantity": 1,
"refund_adjustments": [
{
"adjustment_type": "restocking_fee",
"applies_to": "none",
"amount_money": {"amount": 500, "currency": "USD"},
"reason": {"code": "restocking_policy"}
}
]
}
]
}'
The one thing that call cannot do is fix a bill nobody has paid. Stripe folds both jobs into the credit note, where refund_amount decides whether money moves. Flint splits them: a refund returns settled money against the order, and a credit note credits invoice lines and moves none. So the question that picks the tool is whether the invoice has been paid.
# The invoice is open, so nothing has to move back. Credit two
# units of the line on a draft created against that invoice.
curl -X PATCH https://api.withflintpay.com/v1/credit-notes/cn_1kmn0aExample \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_API_KEY" \
-d '{
"expected_version": 1,
"credit_note_lines": [
{
"invoice_line_item_id": "invli_1kmn0aExample",
"correction": {"type": "quantity", "quantity": 2}
}
]
}'
# Issue it to freeze the lines and mint the number, then draw the
# credit against the balance. outstanding_money falls by 2874.
curl -X POST https://api.withflintpay.com/v1/credit-notes/cn_1kmn0aExample/issue \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Idempotency-Key: issue-cn-1kmn0a-001"
curl -X POST https://api.withflintpay.com/v1/credit-notes/cn_1kmn0aExample/allocations \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Idempotency-Key: allocate-cn-1kmn0a-001" \
-d '{"amount_money": {"amount": 2874, "currency": "USD"}}'
A credit note names invoice_line_item_id and either a quantity of whole units or an amount, and the credited line carries its discount and tax share the same way a refund allocation does. Issuing assigns a number and renders a PDF the buyer can file against the original invoice. Allocating is what moves the balance, and reversing an allocation puts it back on an append-only record, which is why credited is the one invoice status that can move backward.
Two caps rather than one: a line cannot be credited past its frozen value across every issued credit note, and an allocation cannot exceed what the invoice still owes. Creating a credit note against a paid invoice is allowed and allocating it is not, because there is no balance left to draw down. Money already collected comes back as a refund.
| Stripe, card payments | Stripe, invoice payments | Flint |
|---|
| Refund one unit of an item | You compute the amount | Credit note line with a quantity | line_items with a quantity; the server computes |
| Tax on the refunded unit | Your math, plus a separate tax reversal if you record tax | Carried by the credit note | Reversed automatically from what the line settled for |
| Discounted items | You prorate the cart discount | Prorated on the invoice | Allocated server-side, itemized in the response |
| Refunding past the item's share | Allowed; only the charge total is checked | Capped by the invoice | Fails per line with a specific error |
| Crediting a bill that has not been paid | No invoice, so no path | Credit note on an open invoice; it lowers the amount due | Credit note; outstanding_money falls and no money moves |
| Undoing that credit | Not applicable | Void, but only while the invoice is still open | Reverse the allocation; the original stays on the record |
| Where the line detail lives afterward | Metadata you wrote | The credit note | The refund's per-line allocations, the credit note, and the order itself |
The refunds guide covers the full lifecycle, including the failure cases, the credit notes guide covers the unpaid side, and the Refunds API reference has every field on the requests above. For why order structure is what makes this possible, see Partial refunds start with order structure.
Sources