A Stripe deposit is a batch of balance transactions, and the orders it paid for live in your database, joined by a PaymentIntent ID or metadata. Someone runs that join every month.
Tip: The short answer
- The problem: Stripe lists the balance transactions in a payout, not the orders, so every row needs a join to your own tables.
- What Flint does: every balance transaction that belongs to an order carries its
order_id, and one request lists a payout's rows with their orders inlined. - What you stop owning: the PaymentIntent-to-order mapping, the month-end join, and the tax reversal call in every refund handler.
At a glance#
| Month-end question | Stripe PaymentIntents | Flint |
|---|---|---|
| Which orders are in this deposit? | Stripe PaymentIntentsFollow source to a PaymentIntent, then join your table | Flintpayout_id filter with expand=order |
| A refund issued outside your code | Stripe PaymentIntentsReaches your tables only through your webhook handler | FlintSettles against the order it refunds |
| A dispute | Stripe PaymentIntentsAn adjustment row; amount can differ from the charge | FlintA dispute row with the order's order_id |
| A failed payout | Stripe PaymentIntentsTransactions retried under a retried_payout_id | FlintRows keep payout_id; returned money posts as a new row |
| Tax on a partial refund | Stripe PaymentIntentsSpread across lines, or your code computes each reversal | FlintAllocated per line by the refund itself |
| Tax report by jurisdiction | Stripe PaymentIntentsCustom integration: as complete as your reversal calls | Flinttax_transactions_itemized_v1; fails with data_unavailable rather than leave gaps |
Why a Stripe payout doesn't list orders#
An automatic Stripe payout "can include funds from multiple transactions," each a balance transaction with a type, fee, net, and a source object. Of the 52 types, these show up in an ordinary month:
| Stripe balance transaction | What it records | source |
|---|---|---|
charge or payment | A payment from a customer, with Stripe's fee | The charge (ch_) or payment |
refund | Money returned to a customer | The refund (re_) |
adjustment | Disputes, dispute reversals, and some corrections | A dispute, for dispute rows |
refund_failure | A card refund that failed, with the money back in your balance | The refund, then its payment |
stripe_fee | Fees for Stripe software and services, such as Radar and Billing | Not an order |
payout | The payout itself | The payout (po_) |
None of these is an order. Stripe deprecated its original Orders API in October 2019, stripe-node v11 removed the Orders resource, 2022 version included, in November 2022, and Stripe's fulfillment guide now says: "Save a copy of the payment details and line items in your own database."
"When volume grows, reconciliation stops being 1:1," one member wrote on the Shopify Community forum in December 2025. "At some point Excel becomes very fragile and hard to trust." A reply named the worst part:
Most merchants I work with find matching refunds to specific payouts to be the biggest bottleneck.
| Stripe | Flint | |
|---|---|---|
| What was sold | StripeLine items, discounts, tax, and refunds by line sit in your tables | FlintThe order is an API object with line items, discounts, tax per line, and charges |
| Link to the money | StripeStripe holds amounts, linked by PaymentIntent ID or metadata | FlintEvery payment, refund, and dispute settles against the order |
| Payout rows | StripeThe itemized payout report adds payment_intent_id and payment_metadata[key] columns; the hop to what was sold is your join | FlintBalance transactions carry order_id |
| Mapping | StripeWhen the records drift apart, a payout row goes unexplained | FlintStripe processes the cards underneath; you need no Stripe account or mapping table |
The month-end job#
Stripe's guide starts with balance transactions filtered by payout, source expanded; the rest is your code. On Flint it's one filtered list and a tie-out check.
import Stripe from "stripe";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
export async function reconcilePayout(payoutId: string) {
const lines = [];
for await (const txn of stripe.balanceTransactions.list({
payout: payoutId,
expand: ["data.source"],
limit: 100,
})) {
const source = txn.source as any;
switch (txn.type) {
case "charge":
case "payment": {
// Your join: PaymentIntent ID to your orders table.
const order = await db.orders.findByPaymentIntent(source.payment_intent);
lines.push({ order, gross: txn.amount, fee: txn.fee, net: txn.net });
break;
}
case "payment_refund":
case "refund": {
// Which line items, and how much tax? Only your refunds table knows,
// and only for refunds your code wrote down.
const refund = await db.refunds.findByStripeId(source.id);
const order = await db.orders.findByPaymentIntent(source.payment_intent);
lines.push({ order, refund, net: txn.net });
break;
}
case "refund_failure": {
// The refund failed and the money came back. Your refunds table
// still says refunded unless a webhook handler caught the failure.
const refund = await db.refunds.findByStripeId(source.id);
lines.push({ refund, net: txn.net });
break;
}
case "adjustment": {
// Disputes, dispute reversals, and corrections all arrive here.
if (source?.object === "dispute") {
const order = await db.orders.findByPaymentIntent(source.payment_intent);
lines.push({ order, dispute: source.id, net: txn.net });
} else {
lines.push({ unmatched: txn.description, net: txn.net });
}
break;
}
default:
// stripe_fee, tax_fee, and the rest belong to no order.
lines.push({ unmatched: txn.type, net: txn.net });
}
}
return lines;
}
const FLINT = "https://api.withflintpay.com/v1";
const headers = {
Authorization: `Bearer ${process.env.FLINT_API_KEY}`,
"Flint-Version": "2026-09-07",
};
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
export async function reconcilePayout(payoutId) {
const payout = (await (await fetch(`${FLINT}/payouts/${payoutId}`, { headers })).json()).data;
const rows = [];
let pageToken;
for (;;) {
const url = new URL(`${FLINT}/balance-transactions`);
url.searchParams.set("payout_id", payoutId);
url.searchParams.set("expand", "order");
url.searchParams.set("page_size", "100");
if (pageToken) url.searchParams.set("page_token", pageToken);
const res = await fetch(url, { headers });
if (res.status === 503) {
// Contents aren't prepared yet. This is not an empty payout.
await sleep(Number(res.headers.get("Retry-After") ?? 60) * 1000);
continue;
}
if (!res.ok) throw new Error(`${res.status}: ${await res.text()}`);
const page = await res.json();
rows.push(...page.data);
if (!page.next_page_token) break;
pageToken = page.next_page_token;
}
const byType = {};
for (const row of rows) {
byType[row.type] = (byType[row.type] ?? 0) + row.net_money.amount;
}
const net = rows.reduce((sum, row) => sum + row.net_money.amount, 0);
if (net !== payout.amount_money.amount) {
throw new Error(`Payout ${payoutId} doesn't tie out: ${net} vs ${payout.amount_money.amount}`);
}
return { byType, rows };
}
One page of the list, trimmed to two rows and the fields used here:
{
"data": [
{
"balance_transaction_id": "btxn_1kmn0aExample",
"type": "payment",
"amount_money": {"amount": 5000, "currency": "USD"},
"fee_money": {"amount": 225, "currency": "USD"},
"net_money": {"amount": 4775, "currency": "USD"},
"order_id": "ord_1kmn0aExample",
"payout_id": "po_1kmn0aExample",
"related_resource": {"resource_type": "payment_intent", "resource_id": "pi_1kmn0aExample"},
"order": {
"order_id": "ord_1kmn0aExample",
"payment_status": "paid",
"refund_status": "none",
"settlement_amounts": {
"paid_money": {"amount": 5000, "currency": "USD"},
"refunded_money": {"amount": 0, "currency": "USD"},
"outstanding_money": {"amount": 0, "currency": "USD"}
}
}
},
{
"balance_transaction_id": "btxn_1kmn0bExample",
"type": "refund",
"amount_money": {"amount": -1299, "currency": "USD"},
"fee_money": {"amount": 0, "currency": "USD"},
"net_money": {"amount": -1299, "currency": "USD"},
"order_id": "ord_1kmn0bExample",
"payout_id": "po_1kmn0aExample",
"related_resource": {"resource_type": "refund", "resource_id": "ref_1kmn0aExample"},
"order": {
"order_id": "ord_1kmn0bExample",
"payment_status": "paid",
"refund_status": "partially_refunded",
"settlement_amounts": {
"paid_money": {"amount": 3098, "currency": "USD"},
"refunded_money": {"amount": 1299, "currency": "USD"},
"outstanding_money": {"amount": 0, "currency": "USD"}
}
}
}
],
"next_page_token": "pt_1kmn0aExample"
}
On Flint, net_money = amount_money - fee_money at version 2026-09-07, and the rows' net_money adds up to the payout's amount_money: the deposit ties out to the cent or a page is missing.
| Stripe | Flint | |
|---|---|---|
| Payout contents | StripeListed with the payout filter and source expanded; each branch of the walk is your code | FlintPublish whole: every row exists, none belongs to another payout, and they sum to amount_money; until then, 503 with Retry-After, usually for minutes |
| Refunds | StripeA Dashboard refund reaches your refunds table only through a webhook handler; without it, the job finds the order but not the line or tax | FlintA refund row settles against the order it refunds |
| Keeping records current | StripeA refund_failure row returns the money while your table still says refunded | FlintHandle balance_transaction.created and deduplicate by balance_transaction_id, since delivery is at least once and unordered (handling repeats) |
| Row types | StripeDisputes arrive as adjustment rows beside other corrections, and stripe_fee rows belong to no order; spreading them is your call | FlintEach type means one thing: payment, refund, dispute, dispute_reversal, return, adjustment |
| Failed payouts | StripeThe transactions get a retried_payout_id, and your saved payout mapping needs updating | Flintpayout_id never changes; a failed or reversed payout posts a new payout_failure or payout_reversal row for a later payout to carry |
| Payouts you create | StripeThe payout filter lists automatic payouts only | FlintA payout you create with POST /v1/payouts doesn't list contents; match it to its own payout row |
Fees and disputes on one order#
On Stripe, "processing fees from the original transaction aren't returned" on a refund, and a dispute "Debits the disputed amount, plus a dispute fee." A per-order margin report starts from your join.
| Stripe | Flint | |
|---|---|---|
| One order's money | StripeYour margin report starts from your join | FlintGET /v1/balance-transactions?order_id=... returns its payment with fee, refunds, and disputes |
| Order totals | StripeYour tables | Flintsettlement_amounts: paid_money, refunded_money, written_off_money, and outstanding_money; refund_status is separate from payment_status, so a refunded order still reads as paid |
| Processing fee | StripeEach charge row carries Stripe's fee | FlintA $50.00 card payment at 3.79% + 35¢ reports processing_fee_money of 225 and nets $47.75 |
| Fees on a refund | StripeA refunded order keeps its processing fee | FlintA refund doesn't return the processing fee or any add-on fee, such as the automatic tax fee, and Flint charges nothing to refund |
| Disputed amount | StripeCan differ from the charge: a partial dispute, one covering several recurring charges, or a full dispute after a partial refund | FlintA dispute row posts the principal with fee_money of 0 |
| Dispute fee | StripeOutside Mexico, the dispute received fee isn't returned if you win, unless your contract says otherwise | FlintA merchant billing event fee (GET /v1/merchant-billing-entries?dispute_id=...), the card network's cost with no Flint markup |
Sales tax on refunds and in reports#
A filing needs tax collected and refunded per jurisdiction, with each refund reversing tax on the lines that came back. In the basic Stripe Tax integration for PaymentIntents, "Refund reversals use flat-amount distribution" across every line. In the custom one, "When you issue a refund, you must create a reversal tax transaction with a unique reference."
// After refunding part of one line, in your refund handler.
await stripe.tax.transactions.createReversal({
mode: "partial",
original_transaction: order.stripeTaxTransactionId,
reference: `${order.id}-refund-${refund.id}`,
line_items: [
{
original_line_item: line.stripeTaxLineItemId,
reference: line.id,
amount: -refundedAmount,
amount_tax: -refundedTax, // your code computes this share
},
],
});
curl -X POST https://api.withflintpay.com/v1/refunds \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Flint-Version: 2026-09-07" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: refund-ord-1042-mug" \
-d '{
"refund_type": "targets",
"order_id": "ord_1kmn0bExample",
"reason": "defective_product",
"line_items": [
{"order_line_item_id": "li_1kmn0aExample", "quantity": 1}
]
}'
Flint refunds the unit at the price the buyer paid, including its share of any discount and tax:
{
"line_item_allocations": [
{
"order_line_item_id": "li_1kmn0aExample",
"quantity": 1,
"refunded_money": {"amount": 1299, "currency": "USD"},
"tax_refund_mode": "automatic",
"automatic_refund": {
"subtotal_money": {"amount": 1200, "currency": "USD"},
"discount_money": {"amount": 0, "currency": "USD"},
"tax_money": {"amount": 99, "currency": "USD"},
"total_money": {"amount": 1299, "currency": "USD"}
}
}
]
}
| Stripe | Flint | |
|---|---|---|
| Line amounts | StripeYour handler sends the tax transaction and line IDs from your tables, and "If you refund parts of line items, you must calculate the amounts refunded," using the discount and tax arithmetic behind refundedTax | FlintThe refund splits each line's refund into subtotal, discount, and tax in line_item_allocations, with no second call |
| Tax reporting | Stripe"If you refund a tax amount such that the total tax is no longer proportional to the subtotal, your tax reporting can be unreliable." | FlintWith external tax, the refund reverses each jurisdiction row separately, so refunded tax adds up to the cent |
| Repeat refunds | StripeUp to 30 partial reversals per sale, and a full reversal "doesn't affect previous partial reversals" | FlintFails with LIMIT_EXCEEDED instead of paying back more than what's left on the order |
| If a step fails | StripeIf the handler fails after the money refund succeeds, the payout carries the refund and the tax report doesn't | FlintThe refund posts as the negative refund row in the payout, with related_resource naming it |
Tax reports by jurisdiction#
For automatic tax, tax_transactions_itemized_v1 exports one CSV row per collection or refund reversal, with order_id, order_line_item_id, refund_id, destination address, state, county, city, tax_money, and refund_reversed_tax_money. With external tax, tax_summarized_v1 gives taxable sales, tax collected, tax refunded, and tax payable per jurisdiction and tax type.
curl -X POST https://api.withflintpay.com/v1/reports \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: tax-transactions-2026-09" \
-d '{
"report_type": "tax_transactions_itemized_v1",
"interval_start_at": "2026-09-01T04:00:00Z",
"interval_end_at": "2026-10-01T04:00:00Z",
"timezone": "America/New_York",
"currency": "USD"
}'
If a refund in the interval lacks its tax lineage, generation fails with data_unavailable rather than produce a file with gaps. Flint applies, records, and reports tax, and calculates it with automatic tax, but isn't a merchant of record: you register where you owe tax and file the returns.
Run the month end on Flint#
| Question at month end | On Flint |
|---|---|
| What's in this deposit? | GET /v1/balance-transactions?payout_id=po_...&expand=order |
| What did each payment cost? | payments_itemized_v1: processing_fee_money, each add-on fee, merchant_net_money, and order_id |
| What did disputes and ACH returns cost? | GET /v1/merchant-billing-entries?type=event_fee |
| What's been paid and refunded on an order? | settlement_amounts on GET /v1/orders/{order_id} |
| How much tax was collected and refunded? | tax_transactions_itemized_v1 for automatic tax, tax_summarized_v1 for external tax |
| What does the accountant get? | payouts_itemized_v1: each paid payout's balance transactions with order_id and arrival_date |
Sign up for a sandbox. Pay a test order with card 4242 4242 4242 4242, refund one line item, and list the order's balance transactions to see the payment, its fee, and the refund on one order. The sandbox is free and needs no credit card.
FAQ#
How do I reconcile Stripe payouts with orders?#
List the payout's balance transactions with the payout filter (automatic payouts only), expand source, and follow each charge, refund, or dispute to its PaymentIntent. Then join that ID to your own orders table, because Stripe doesn't hold the order.
Why doesn't my Stripe payout match my sales?#
A payout is the net of charges minus Stripe's fees, refunds, disputes and their fees, and other adjustments, settled on Stripe's schedule rather than the day of the sale. Refunds don't return the original processing fee, and a dispute can be for a different amount than the charge.
Does Stripe still have an Orders API?#
No. Stripe deprecated the original in October 2019, and stripe-node v11 removed the Orders resource, including the 2022 Orders API, in November 2022.
How do I report sales tax refunded with Stripe Tax?#
The basic PaymentIntents integration spreads each refund's tax reversal across all lines. For line-level reversals, the custom integration has you create a reversal tax transaction per refund, sending each line's refunded amount and amount_tax yourself.
How does Flint connect payouts to orders?#
Every Flint balance transaction that belongs to an order carries its order_id, and GET /v1/balance-transactions?payout_id=...&expand=order returns a payout's rows with their orders inlined. The rows' net_money adds up to the payout's amount_money.
Does Flint file sales tax for me?#
No. Flint calculates US sales tax with automatic tax or records your own engine's rates, reverses tax on refunds, and exports tax reports by jurisdiction, but it isn't a merchant of record, so you register and file the returns.
Sources#
Everything attributed to Stripe above comes from its documentation and public SDK wikis, and the merchant quotes come from a public forum thread, checked on 2026-10-28.
- Payout reconciliation: automatic payouts "can include funds from multiple transactions," and listing balance transactions by
payoutand expandingsource. - Payout reconciliation report: the itemized report's
payment_intent_idandpayment_metadata[key]columns, andretried_payout_idin the failed payouts report. - List all balance transactions: the
payoutfilter for automatic payouts only, and the 52 values of thetypefilter. - Balance transaction types: disputes and dispute reversals as
adjustmentrows whose source is a dispute, failed refunds as adjustments, andstripe_feeas fees for Stripe software and services. - Refund and cancel payments: processing fees from the original transaction aren't returned, and refunds must also be recorded when you use the Stripe Tax APIs.
- How disputes work: the disputed amount plus a dispute fee debited from your balance, disputed amounts that differ from the charge, and the dispute received fee not returned outside Mexico.
- Stripe Tax with PaymentIntents and basic Stripe Tax API: the two integrations, and refund reversals using flat-amount distribution.
- Custom Stripe Tax API: creating tax transactions with a
reference, reversal transactions for every refund, computing partial line amounts yourself, the 30 partial reversal limit, full reversals not affecting earlier partial ones, and unreliable reporting for non-proportional tax refunds. - Fulfill orders: "Save a copy of the payment details and line items in your own database."
- stripe-node wiki: Migration guide for v9, the legacy Orders API deprecated in October 2019 because it doesn't work with Strong Customer Authentication, and Migration guide for v11, the Orders and SKU resources removed, and the v11.0.0 release of 2022-11-16.
- Shopify Community: How do you reconcile Shopify orders with Stripe payouts without losing your mind?, the original post and a reply, both quoted above.
Flint behavior comes from the Reconciliation, Payouts, Reports, Processing fees, Sales tax, Refunds, and Handling disputes guides, and the Money Movement, Reports, and Orders API references.
