Skip to content

Payment API Problems: 7 Stripe Pain Points Developers Report

Webhook races, double charges, no order object, and payouts nobody can match: the payment API problems developers report, why they happen, and the fix.
Payments10 min readBy Flint Pay, Product & API Team

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#

Webhook duplicates and orderingStripeRebuild the sale from several event types, out of 258+Flintorder.paid carries the whole order
Timeouts and double chargesStripeOptional keys; v1 keys prunable after 24 hoursFlintRequired keys, one open attempt per order
No order objectStripeYour tables; refunds are amountsFlintOrder holds lines; refunds target lines
Payout reconciliation and taxStripeMatch payouts through your own metadataFlintBalance transactions carry order_id
PCI, fraud, disputes, and taxStripeSeparate products and queuesFlintRisk rules, disputes, and tax on the order
Sandbox differencesStripeShared settings, shorter retries, no emailsFlintIsolated sandboxes, same retries, Flint-Mode header
Too many productsStripeCheckout, Billing, Tax, Radar, each with own objectsFlintOne 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.

Theo Browne, GitHub, stripe-recommendations
TypeScript
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.

What your webhook handler does
Which eventStripePick which of 258+ event types to handleFlintorder.paid, from checkout, payment links, invoices, or the API
Paid in fullStripeCheck payment_status before fulfillingFlintorder.paid fires once the order is paid in full
Line itemsStripeRetrieve the session againFlintdata.object is the order with its line items
Duplicates and orderStripeDeduplicate events that Stripe and Adyen both say can arrive twice, in no guaranteed orderFlintStill 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.

Dochia, Dochia blog, Idempotency is easy until the second request is different

A retry loop that mints a new key on each attempt turns that timeout into a second charge.

TypeScript
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.

Retrying a charge after a timeout
Idempotency keysStripeOptionalFlintMoney routes reject requests without an Idempotency-Key
Key lifetimeStripev1 keys can be pruned after 24 hours, and a reused key then makes a new requestFlintKeys last as long as the command's effects, so late retries replay
RetriesStripeStore one key per charge and reuse it on every retryFlintOne 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 SecureStripeRead declines from several outcome fieldsFlint22 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.

Med Knight, GitHub, stripe-node #1991
TypeScript
// 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
});
Recording a sale and refunding one line
Record of the saleStripeYour order, line, refund, and per-line tax tablesFlintThe order holds lines, discounts, fees, tip, and tax
Staying in stepStripeKeep your tables in step with StripeFlintpricing_amounts and settlement_amounts show owed, paid, refunded, and outstanding; disputes link to the payment and its order
RefundsStripeRefund an amount against a charge or PaymentIntentFlintRefund a line; over-refunds fail with LIMIT_EXCEEDED
Discount and tax shareStripeWork out each line's share yourselfFlintA 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.

TypeScript
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);
}
Matching a payout to its orders
Payout reportStripeThe payout reconciliation report needs automatic payouts; instant payouts aren't itemized, and report data can lag the payoutFlintManual 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 salesStripeThrough your own payment_metadata keysFlintEvery balance transaction on an order carries order_id, and expand=order inlines each order
Tax on refundsStripeLine-level tax reversals need manual tax transactionsFlintTax 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.

Fraud, disputes, tax, and PCI scope
PCI scopeStripeDepends on where card data flowsFlintWith hosted checkout or Stripe Elements through Flint, card details never reach your servers or Flint's
FraudStripeRadar for fraud rulesFlintRisk 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
DisputesStripeA dispute workflow tied to a chargeFlintDisputes 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 taxStripeTax registrations and calculationsFlintSales 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
curl -s -o /dev/null -D - https://api.withflintpay.com/v1/merchant \
  -H "Authorization: Bearer $FLINT_API_KEY" | grep -qi '^flint-mode: live'
How the sandbox differs from live
SettingsStripeThe test mode sandbox shares settings with live modeFlintOne hostname, and the key decides the mode; each sandbox isolates orders, catalog, webhooks, idempotency keys, and settings
Separate sandboxesStripeUp to five isolated general sandboxesFlintReset 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 liveStripeSandbox webhooks retry three times over a few hours, against up to three days live; tax test calculations aren't guaranteed current, 1,000 per dayFlintThe same webhook retry schedule in test and live
Customer emailsStripeNone by defaultFlintTest 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.

What you wire together
Hosted pagesStripeCheckout SessionsFlintA checkout session against the order
Embedded card formStripeElements with a PaymentIntentFlintStripe Elements with a payment intent under the order
SubscriptionsStripeBilling: Subscription, Invoice, PriceFlintSubscriptions whose renewals are orders
Invoices, tax, and fraudStripeInvoicing, Tax, and RadarFlintInvoices that bill the order, tax on the order, and risk rules in the same API
Record of the saleStripeYour databaseFlintThe 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:

Shell
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 paymentCreate a payment link, pay it with a test card, and find the payment in your dashboard.WebhooksRegister an endpoint, verify deliveries, deduplicate, and choose the event to fulfill from.ReconciliationMatch a deposit to the balance transactions that funded it, and each one to its order.

Flint 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.

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.