The same seven Stripe complaints repeat across developer posts and GitHub issues, from webhooks that arrive twice to deposits nobody can match to sales. Most share one cause: a payment-first API moves an amount of money, and the record of what was sold lives in your database.
Tip: The short answer
- Stripe moves an amount of money, so line items, refunds, payout matching, and state sync become your code.
- Flint is order-first: the order holds lines, discounts, and tax, and checkout, refunds, disputes, payouts, and reports all key on it.
- You stop building order tables, per-line refund math, metadata-based payout matching, and sales rebuilt from many event types.
At a glance#
| Problem | Stripe | Flint |
|---|---|---|
| Webhook duplicates and ordering | StripeRebuild the sale from several event types, out of 258+ | Flintorder.paid carries the whole order |
| Timeouts and double charges | StripeOptional keys; v1 keys prunable after 24 hours | FlintRequired keys, one open attempt per order |
| No order object | StripeYour tables; refunds are amounts | FlintOrder holds lines; refunds target lines |
| Payout reconciliation and tax | StripeMatch payouts through your own metadata | FlintBalance transactions carry order_id |
| PCI, fraud, disputes, and tax | StripeSeparate products and queues | FlintRisk rules, disputes, and tax on the order |
| Sandbox differences | StripeShared settings, shorter retries, no emails | FlintIsolated sandboxes, same retries, Flint-Mode header |
| Too many products | StripeCheckout, Billing, Tax, Radar, each with own objects | FlintOne API built around the order |
1. Webhook duplicates and ordering#
Each Stripe event describes one object, so your handler rebuilds the sale from sessions, payment intents, invoices, and subscriptions. Theo Browne's stripe-recommendations calls this "split brain":
You're then expected to track the purchase in your own database via webhooks.
if (event.type === "checkout.session.completed") {
const session = event.data.object;
await db.orders.insert({
stripeSessionId: session.id,
total: session.amount_total,
status: "paid",
});
}
A redelivery inserts a second row, a bank debit completes the session with payment_status still unpaid, and the row has no line items.
// app/api/flint/webhook/route.ts
import { Client } from "@flintpay/node";
const flint = new Client({
baseUrl: "https://api.withflintpay.com",
apiKey: process.env.FLINT_API_KEY!,
});
export async function POST(request: Request) {
const rawBody = new Uint8Array(await request.arrayBuffer());
const { event, known } = flint.verifyWebhook(rawBody, request.headers, [
process.env.FLINT_WEBHOOK_SECRET!,
]);
if (known && event.event_type === "order.paid") {
// A unique constraint on webhook_event_id makes a redelivery a no-op.
await jobs.enqueueOnce(event.webhook_event_id, {
orderId: event.data.object.order_id,
lines: event.data.object.line_items,
});
}
return new Response(null, { status: 200 });
}
This shared-secret verifier needs an endpoint created with "signing_methods": ["hmac"]; new endpoints default to JWT, covered in the Webhooks guide.
| Stripe | Flint | |
|---|---|---|
| Which event | StripePick which of 258+ event types to handle | Flintorder.paid, from checkout, payment links, invoices, or the API |
| Paid in full | StripeCheck payment_status before fulfilling | Flintorder.paid fires once the order is paid in full |
| Line items | StripeRetrieve the session again | Flintdata.object is the order with its line items |
| Duplicates and order | StripeDeduplicate events that Stripe and Adyen both say can arrive twice, in no guaranteed order | FlintStill at least once and unordered: deduplicate on webhook_event_id, the same across up to 9 attempts over 71 to 86 hours |
Read more: Stripe webhooks: duplicates, out-of-order events, drift and Stripe webhook line items.
2. Timeouts and double charges#
When the provider charges the card and your service fails before saving the result, an engineering post on idempotency spells out what you're left with:
Now your database cannot infer whether money moved.
A retry loop that mints a new key on each attempt turns that timeout into a second charge.
async function chargeSavedCard(cart) {
for (let attempt = 1; attempt <= 3; attempt++) {
try {
return await stripe.paymentIntents.create(
{
amount: cart.total,
currency: "usd",
customer: cart.customerId,
payment_method: cart.paymentMethodId,
confirm: true,
off_session: true,
},
{ idempotencyKey: crypto.randomUUID() }, // new key on every attempt
);
} catch (err) {
if (attempt === 3) throw err;
}
}
}
If the first request charged the card and timed out, the second charges again. The loop also retries hard declines such as a stolen card.
curl -X POST https://api.withflintpay.com/v1/orders/ord_1kmn0aExample/pay \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Idempotency-Key: pay-ord-7781-attempt-1" \
-d '{
"action": "pay",
"payment_source": {"credential_type": "token", "token": "pm_1kmn0aExample"},
"expected_outstanding_money": {"amount": 23500, "currency": "USD"}
}'
The optional expected_outstanding_money is the total the buyer approved; if the order changed, Flint returns VERSION_CONFLICT before charging.
| Stripe | Flint | |
|---|---|---|
| Idempotency keys | StripeOptional | FlintMoney routes reject requests without an Idempotency-Key |
| Key lifetime | Stripev1 keys can be pruned after 24 hours, and a reused key then makes a new request | FlintKeys last as long as the command's effects, so late retries replay |
| Retries | StripeStore one key per charge and reuse it on every retry | FlintOne open payment attempt per order; a second returns 409. A processor timeout leaves the attempt at requires_retry, and resuming reads the processor's result instead of charging again |
| Declines and 3D Secure | StripeRead declines from several outcome fields | Flint22 decline codes, each with a documented next step; 3D Secure pauses at requires_action |
Read more: Retry payments without double charging: keys, declines, 3DS.
3. No order object#
Stripe deprecated its Orders API in 2019 and removed the 2022 beta before general availability (history), so line items, tax, and refunds live in your tables. A stripe-node issue shows the usual workaround, retrieving line items after checkout to build an order:
I retrieve the line_items so that I can create an order document (because I'm working with mongoose) and save it in my database.
// Refund one framed print from a discounted, taxed order
await stripe.refunds.create({
payment_intent: order.stripePaymentIntentId,
amount: line.unitPrice, // list price: ignores the discount share and the tax
});
curl -X POST https://api.withflintpay.com/v1/orders \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Idempotency-Key: order-print-7781" \
-d '{
"external_reference_id": "studio-sale-7781",
"line_items": [
{
"type": "custom",
"name": "Framed print, 24 x 36",
"quantity": 1,
"unit_price_money": {"amount": 24000, "currency": "USD"},
"fulfillment": {"requirement": "none"}
},
{
"type": "custom",
"name": "Wall mounting hardware",
"quantity": 1,
"unit_price_money": {"amount": 1500, "currency": "USD"},
"fulfillment": {"requirement": "none"}
}
],
"discounts": [{
"manual": {
"name": "Newsletter subscriber",
"amount_money": {"amount": 2000, "currency": "USD"}
}
}]
}'
# After payment, refund the print by line
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-7781-print" \
-d '{
"refund_type": "targets",
"order_id": "ord_1kmn0aExample",
"reason": "requested_by_customer",
"line_items": [{"order_line_item_id": "li_1kmn0aExample", "quantity": 1}]
}'
| Stripe | Flint | |
|---|---|---|
| Record of the sale | StripeYour order, line, refund, and per-line tax tables | FlintThe order holds lines, discounts, fees, tip, and tax |
| Staying in step | StripeKeep your tables in step with Stripe | Flintpricing_amounts and settlement_amounts show owed, paid, refunded, and outstanding; disputes link to the payment and its order |
| Refunds | StripeRefund an amount against a charge or PaymentIntent | FlintRefund a line; over-refunds fail with LIMIT_EXCEEDED |
| Discount and tax share | StripeWork out each line's share yourself | FlintA line refund returns its share of discount and tax |
Read more: Stripe vs order-first payment APIs, Stripe partial refunds, and Payment API vs commerce API.
4. Payout reconciliation and tax#
A Stripe payout links to charges, and charges link to your sales only through metadata you set. Tax reversals have the same gap: with Stripe Tax on PaymentIntents, the simplified integration reverses refunds as flat amounts.
const unmatched = [];
for await (const bt of stripe.balanceTransactions.list({
payout: payoutId,
expand: ["data.source"],
})) {
// Works only if every PaymentIntent carried your order ID in metadata.
// A refund's source is the Refund, whose metadata is a separate map.
const orderId = bt.source?.metadata?.order_id;
if (!orderId) unmatched.push(bt.id);
}
curl "https://api.withflintpay.com/v1/balance-transactions?payout_id=po_01JABCDEFGHJKMNPQRSTVWXYZ0&expand=order&page_size=100" \
-H "Authorization: Bearer YOUR_API_KEY"
The rows' net_money adds up to the payout's amount_money, so a difference means a missing page.
| Stripe | Flint | |
|---|---|---|
| Payout report | StripeThe payout reconciliation report needs automatic payouts; instant payouts aren't itemized, and report data can lag the payout | FlintManual payouts reconcile against the payout's own balance transaction; reports that can't prove their interval is complete fail with data_unavailable |
| Matching rows to sales | StripeThrough your own payment_metadata keys | FlintEvery balance transaction on an order carries order_id, and expand=order inlines each order |
| Tax on refunds | StripeLine-level tax reversals need manual tax transactions | FlintTax is recorded per line and reversed in proportion on line refunds |
Flint calculates and reports tax; registering and filing stay with you. Read more: Stripe payout reconciliation: matching deposits to orders.
5. PCI, fraud, disputes, and tax#
On Stripe each obligation is a separate product with its own setup and queue. Dispute evidence and refund tax both depend on the record of the sale, which lives in your tables.
| Stripe | Flint | |
|---|---|---|
| PCI scope | StripeDepends on where card data flows | FlintWith hosted checkout or Stripe Elements through Flint, card details never reach your servers or Flint's |
| Fraud | StripeRadar for fraud rules | FlintRisk rules over the API on every card and ACH debit payment. Two starter rules: block your three default block lists (they start empty; add values yourself, or decline a review with add_to_block_lists), and review payments the processor rates high risk |
| Disputes | StripeA dispute workflow tied to a charge | FlintDisputes record evidence_due_at and link to the order; dispute.needs_response tells your queue, and you respond in the dashboard or your own interface |
| Sales tax | StripeTax registrations and calculations | FlintSales tax per line, reversed on refunds |
Flint isn't a merchant of record, so you still respond to disputes, register and file sales tax, complete your PCI self-assessment, and decide who gets access. Read more: Hidden costs of a Stripe integration: PCI, disputes, tax and What you're responsible for when you sell online.
6. Sandbox differences from production#
Stripe's testing docs list where its sandboxes behave differently from live mode. On Flint, every authenticated response carries a Flint-Mode header, so a deploy smoke test can fail the build when a test key reaches production:
curl -s -o /dev/null -D - https://api.withflintpay.com/v1/merchant \
-H "Authorization: Bearer $FLINT_API_KEY" | grep -qi '^flint-mode: live'
| Stripe | Flint | |
|---|---|---|
| Settings | StripeThe test mode sandbox shares settings with live mode | FlintOne hostname, and the key decides the mode; each sandbox isolates orders, catalog, webhooks, idempotency keys, and settings |
| Separate sandboxes | StripeUp to five isolated general sandboxes | FlintReset a CI sandbox with POST /v1/sandboxes/{sandbox_id}/reset and a live key, then mint a test key and re-register the webhook endpoint |
| Matching live | StripeSandbox webhooks retry three times over a few hours, against up to three days live; tax test calculations aren't guaranteed current, 1,000 per day | FlintThe same webhook retry schedule in test and live |
| Customer emails | StripeNone by default | FlintTest mode sends real buyer emails |
Live buyers can pay only once readiness.payments.status is ready. Sandbox card charges are simulated, so the Going live guide ends with one real $1.00 payment and its refund. Read more: Sandboxes & test mode and the Stripe go-live checklist.
7. Too many products to wire together#
Each job in a typical store or SaaS app is a separate Stripe product with its own objects, events, and docs, and none of them is the record of the sale.
| Stripe | Flint | |
|---|---|---|
| Hosted pages | StripeCheckout Sessions | FlintA checkout session against the order |
| Embedded card form | StripeElements with a PaymentIntent | FlintStripe Elements with a payment intent under the order |
| Subscriptions | StripeBilling: Subscription, Invoice, Price | FlintSubscriptions whose renewals are orders |
| Invoices, tax, and fraud | StripeInvoicing, Tax, and Radar | FlintInvoices that bill the order, tax on the order, and risk rules in the same API |
| Record of the sale | StripeYour database | FlintThe order |
For coding agents, a docs MCP server searches the docs and returns any endpoint's schema, /SKILL.md holds the API reference in one file, and the OpenAPI spec is at /v1/openapi.json:
claude mcp add --transport http flint-docs https://developers.withflintpay.com/mcp
Read more: Overview and Why Flint is orders-first.
Try it in a sandbox#
Accept your first paymentWebhooksReconciliationFlint charges 3.79% + 35¢ per card payment, with Stripe's processing included and no monthly fee, and sandbox keys are free.
Sign up for a free sandbox. Create an order with two lines and a discount, pay it with 4242 4242 4242 4242, and refund one line. Then read the order's settlement_amounts to see what was paid and refunded. Read the refunds guide.
FAQ#
What are the most common problems with the Stripe API?#
Webhook duplicates and ordering, double charges after timeouts, no order object, payout reconciliation, separate products for disputes, fraud, and tax, sandbox differences, and product sprawl. Most come from the record of the sale living in your database.
Does Stripe guarantee webhook order?#
No. Stripe "doesn't guarantee the delivery of events in the order that they're generated," so deduplicate by event ID and fetch current state when it matters.
How do I stop double charges when a payment request times out?#
Generate one idempotency key per charge and reuse it on every retry. On Flint, money routes require the key, and a second payment on an order with an open attempt returns a 409 instead of charging.
Is there a Stripe alternative that keeps orders in the API?#
Flint is an order-first payments API: the order holds line items, discounts, and tax, order.paid carries it, and refunds target lines. Each balance transaction behind a payout carries its order_id.
Does Flint run on Stripe?#
Yes. Stripe processes every card payment on Flint, and you don't need your own Stripe account. Flint is for US businesses selling online in USD, and it isn't a merchant of record.
Sources#
Developer reports are linked to the public post or issue they come from. Everything attributed to Stripe and Adyen comes from their own documentation or repositories, checked on 2026-10-28.
- t3dotgg/stripe-recommendations: the "split brain" description and "over 258 event types."
- Idempotency is easy until the second request is different (May 2026): "Now your database cannot infer whether money moved."
- GitHub, stripe-node #1991 (January 2024): retrieving line items to build an order document.
- Stripe, Receive Stripe events in your webhook endpoint: no guaranteed order, duplicate deliveries, and retries for up to three days in live mode and three times over a few hours in a sandbox.
- Stripe, Fulfill orders: checking
payment_statusand retrieving line items. - Adyen, Handle webhook events: the same webhook event can arrive twice.
- Stripe, Idempotent requests: optional keys, removal after at least 24 hours, and a new request after pruning.
- Stripe, Declines: the
outcomefields on a declined payment. - Stripe, Create a refund: a refund specifies a Charge or PaymentIntent, with an optional
amount. - stripe-node, Migration guide for v11: "Remove
Ordersresource." - Stripe, Payout reconciliation report: automatic payouts only, instant payouts not itemized, data that can lag the payout, and
payment_metadata[key]columns. - Stripe, Stripe Tax with PaymentIntents: flat-amount refund reversals in the simplified integration, manual tax transactions in the custom one, and test calculations that aren't guaranteed current, limited to 1,000 per day.
- Stripe, Testing use cases and Sandboxes: settings shared between the test mode sandbox and live mode, up to five general sandboxes, and no customer email in sandboxes by default.
Flint behavior comes from the Overview, Why Flint is orders-first, Webhooks, Idempotency, Declines and payment attempts, Refunds, Reconciliation, Reports, Sales tax, Risk controls, Disputes, Sandboxes & test mode, Testing, and Going live guides.
