Every Stripe integration answers this question eventually, usually the first time the database and the Dashboard disagree. The answer has less to do with Stripe than with which fact you are storing: some facts originate inside Stripe and can only be mirrored out, and some never enter Stripe at all and have to live with you. Sort the data first and every downstream decision becomes mechanical.
Where to start
| Where you are | Your path |
|---|---|
| You are deciding whether to mirror Stripe into your database or call the API when you need data | Split it by data |
| You want to avoid building billing or order tables at all | What the API can carry |
| Your database and Stripe disagree and you are deciding which to believe | The five drift modes |
| You sell products and are deciding where the order lives | The half Stripe cannot hold |
| You are choosing a payments API and want one record instead of two | Order-first on Flint |
Split the question by data
| The record you want Stripe to hold | Can it? | Why |
|---|---|---|
| Charge, refund, and dispute state | Yes, and it is mandatory | These facts originate inside Stripe. Your database holds a projection, written only from webhooks and API read-backs, never from your own request having returned. |
| Subscription and entitlement state | Yes, with a local read model | The truth lives in Stripe, but checking it on every request does not survive the rate limits. Mirror the few fields you gate on and keep the mirror honest. |
| Customer identity | Split it | Your user row owns identity. The Stripe Customer owns the payment profile. Store the cus_ ID on your row as a pointer; copying fields invites two owners for one email address. |
| Product catalog and prices | Either, but pick one writer | Catalogs fork when both the Dashboard and your admin can edit. Choose the writing side and treat the other as read-only. |
| The order: items sold, fulfillment, stock | No. The objects do not exist | A Checkout Session is a payment record with line items attached, not an order. Stripe's fulfillment guide tells you to keep this half in your own database. |
| Application data in metadata | No | 50 keys of 500 characters that nothing validates, transacts, or reads. Stripe's docs say to store the data externally and keep a pointer in metadata. |
The rest of the page walks the hard rows: keeping the mirror honest, what reading at request time costs, and the row where the answer is no.
The half Stripe owns
Payment state is Stripe's to declare. Treating it that way is a discipline with three rules, and every drift bug below is a violation of one of them.
Outcomes are read, never written. Your code records that it asked for a charge; whether the charge succeeded enters your database only from a webhook or a fresh read of the object. The success redirect is a courtesy, not a fact: Stripe's fulfillment guide states that customers are not guaranteed to reach the success page, and Checkout holds the redirect for up to 10 seconds waiting for your webhook handler first.
Events are signals, not state. Delivery is at least once and unordered, so the payload in hand may be stale even when it is new to you. The handler that survives both properties treats the event as a doorbell:
// For payment_intent.* events. The mirror has one writer: this
// handler. Checkout code records that it asked; only read-backs
// from Stripe record what happened.
const event = stripe.webhooks.constructEvent(rawBody, signature, secret);
await db.tx(async (t) => {
// Delivery is at least once: dedupe inside the same transaction
// as the write, or a retry applies the update twice.
const first = await t.result(
`INSERT INTO processed_events (event_id) VALUES ($1)
ON CONFLICT DO NOTHING`,
[event.id],
);
if (first.rowCount === 0) return;
// Delivery is unordered: the payload is a doorbell, the fetch is
// the fact. A read after the signal cannot apply stale state.
const pi = await stripe.paymentIntents.retrieve(event.data.object.id);
await t.none(
`UPDATE payments SET status = $1, updated_at = now()
WHERE payment_intent_id = $2`,
[pi.status, pi.id],
);
});Writes happen behind your back. A refund issued in the Dashboard, a subscription canceled by support, a card or email changed in the billing portal, a dispute opened by the bank: none of these pass through your code, so a mirror updated from your own API responses misses all of them. The event feed is the only complete write log, and a scheduled diff against the list endpoints catches the deliveries that failed.
Reading Stripe at request time
The alternative to a mirror is calling the API whenever data is needed, and it works until traffic does. Stripe's limits are sized for payment processing, not for serving your read paths:
- The global live-mode limit is 100 requests per second, shared with the payment traffic that must not fail. Sandboxes get 25.
- Most individual endpoints allow 25 requests per second; the search API allows 20.
- Read requests are also allocated: an average of 500 reads per transaction over a rolling 30 days, with a floor of 10,000 per month. Excess traffic gets 429s.
The allocation is the row that decides architectures. Reads are budgeted in proportion to the payments you process, so a page that makes three Stripe calls per view spends payment-sized quota on browser traffic, and the math worsens exactly as your visits outgrow your sales. Search does not change the picture: it is capped lower, the docs forbid it for read-after-write flows, and new data becomes searchable in under a minute under normal conditions, which is a long time to show a buyer the wrong subscription state.
Stripe's own recommendation for read-heavy work is to move the data out: Sigma queries it inside Stripe's warehouse, and Data Pipeline exports it to yours. Both are analytics surfaces with warehouse latency, not request-path reads, which returns you to the mirror, and the mirror is a pipeline you operate: backfill for history, the event feed for changes, reconciliation for repair. Sequin, the company best known for selling that pipeline as a product, shut down on October 23, 2025. The sync is yours to run.
The half Stripe cannot hold
When you sell products, the record that matters answers five questions: what was sold, what was paid, what was refunded against which items, what shipped, and what is left in stock. Stripe holds the second answer completely and fragments of the first; the object that once held the rest was retired twice, in 2019 and again in 2022. A Checkout Session carries line items, but they come back only when you ask with expand, and the session has no field for fulfillment. A refund is an amount against a charge, not a decision about items. Stock is not tracked at all.
Stripe's fulfillment guide fills the gap by assigning your fulfill_checkout function its duties: handle being called multiple times, possibly concurrently, for the same session; retrieve the session with line items expanded; check payment_status before acting; record fulfillment status yourself; and, among its suggestions, save a copy of the payment details and line items in your own database.
Read that list as a data architecture, because it is one. At the moment a payment succeeds, you are instructed to create a second system of record: your database becomes the truth for the sale while Stripe stays the truth for the money, joined by a cart ID you placed in metadata. Every question anyone asks afterward spans the seam. Has this order been refunded: your rows for the items, Stripe for the money. Why does finance's export disagree with the database: walk both sides. The reconciliation job that compares the halves stops being a chore and becomes load-bearing infrastructure.
So for commerce, the search phrase has the shape of a category error. Stripe can be the source of truth for payment state and nothing else, the order must live somewhere, and on Stripe that somewhere is you. The five ways teams get hurt on the seam are below.
