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 the credit note does what this page's title asks: 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 actually 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 actually 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.
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 actually 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"
}
]
Everything this page has you build by hand is 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"}
}
]
}
]
}'
| 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 |
| Where the line detail lives afterward | Metadata you wrote | The credit note | The refund's per-line allocations and the order itself |
The refunds guide covers the full lifecycle, including the failure cases, 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