Reference

Stripe as your source of truth

Two different questions hide in that phrase. For money, Stripe is the source of truth and there is no alternative: charge, refund, and dispute states are facts Stripe creates, and any copy you keep drifts unless it is written from Stripe's own events. For the sale, Stripe cannot be the source of truth: it never learns what an order was, and its fulfillment guide tells you to save the line items in your own database and track fulfillment there yourself. Most integrations therefore run two systems of record, each holding half the story, stitched together by a metadata key. Below: which data belongs where, what reading or mirroring Stripe really costs, the five ways the halves drift apart, and what changes when the order itself is the record.

Verified against official documentation · last reviewed 2026-08-08 · corrections: support@withflintpay.com

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 areYour path
You are deciding whether to mirror Stripe into your database or call the API when you need dataSplit it by data
You want to avoid building billing or order tables at allWhat the API can carry
Your database and Stripe disagree and you are deciding which to believeThe five drift modes
You sell products and are deciding where the order livesThe half Stripe cannot hold
You are choosing a payments API and want one record instead of twoOrder-first on Flint

Split the question by data

The record you want Stripe to holdCan it?Why
Charge, refund, and dispute stateYes, and it is mandatoryThese 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 stateYes, with a local read modelThe 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 identitySplit itYour 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 pricesEither, but pick one writerCatalogs 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, stockNo. The objects do not existA 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 metadataNo50 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:

JavaScript
// 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.

The five drift modes

01

Paid state written from the redirect

Fulfillment ran for a session that was never paid, and a buyer who did pay saw nothing until support stepped in.

The success page is a browser navigation, and Stripe's fulfillment guide is blunt about it: customers are not guaranteed to reach it. A connection can drop between the charge and the landing page, and a landing page that treats its own URL as proof of payment fulfills on arrival instead of on money. Checkout even holds the redirect for up to 10 seconds waiting for your webhook handler to respond, which tells you which channel Stripe considers authoritative.

Confirm it

Count checkout.session.completed events against landing-page hits for a week. The difference is paid buyers your redirect-driven code never saw.

Fix

Fulfill from the webhook, always. If the landing page also triggers fulfillment for immediacy, both paths must call one idempotent function that re-reads the session and checks payment_status before acting.

02

The mirror only knows what your code did

A refund issued in the Dashboard three weeks ago is still missing from your database, and finance found it before you did.

Syncing on your own API responses covers exactly the writes your code makes. Refunds issued in the Dashboard, subscriptions canceled by support, cards and emails changed in the billing portal, and disputes opened by the bank all mutate Stripe without touching your code path. A mirror updated at write time is complete only on the day nobody else can write.

Confirm it

List last month's refunds and subscription cancellations in Stripe and diff them against your database. Anything present in Stripe and missing locally arrived through a surface you do not sync.

Fix

Make the event feed the only writer of mirrored state, and back it with a scheduled diff against the list endpoints, because a webhook can be missed and only a periodic comparison notices.

03

Events applied in arrival order

A canceled subscription flipped back to active after a burst of events landed out of order.

Stripe delivers at least once and does not guarantee order, so the payload that arrives last is not necessarily the state that is true now. A handler that writes whatever each payload says will apply a stale update over a fresh one, and the bug only appears under the burst traffic that produces reordering.

Confirm it

Log event created timestamps against your processing times. Any pair processed in reverse created order is a write that could have gone backward.

Fix

Treat the event as a signal, not a fact: deduplicate by event ID, then fetch the object and write what the fetch returns. A read after the signal cannot rewind, because the API always returns current state.

04

Metadata used as the application database

Credits, plan flags, and feature toggles live in customer metadata, and two support tabs just overwrote each other.

Metadata is 50 keys of 500 characters that nothing validates, nothing transacts, and, outside Radar rules, nothing in Stripe reads. Concurrent editors are last-write-wins per key, search over it lags writes by up to a minute, and Stripe's own advice when data outgrows it is to store the data in your external database and keep the object's ID in metadata.

Confirm it

Grep your code for logic that branches on a metadata value. Each hit is application state with no schema, no history, and no locks.

Fix

Demote metadata to join keys: your order ID, your user ID, a campaign tag. State that changes and matters lives in a database with transactions.

05

A join key held together by convention

One afternoon's deploy bug left forty completed sessions with no order row, and nothing noticed until the packing bench had nothing to pack.

The seam between your half of the record and Stripe's half is a string in metadata. Stripe accepts any value there, your database cannot foreign-key into Stripe, and no constraint anywhere asserts that both sides agree. The link holds because everything works every time, which is another way of saying nothing holds it.

Confirm it

Walk yesterday's completed sessions and confirm each has exactly one order row, then walk your orders back the other way. Run it before you need it.

Fix

Promote that query to a scheduled reconciliation in both directions that alerts on any mismatch. Treat a hit as an incident to root-cause, not noise to patch.

This machinery is the running cost of splitting one record across two systems. None of it ships product, and all of it has to work during your busiest hour.

Questions, answered

Should I use Stripe as my source of truth?

Split the question by data. For charge, refund, dispute, and subscription state, yes: those facts originate in Stripe, and your database can only hold a projection written from webhooks and API read-backs. For orders, fulfillment, and inventory, no: the objects do not exist in Stripe, and its fulfillment guide directs you to keep line items and fulfillment status in your own database.

Should I store Stripe data in my own database?

At any real read volume, yes. Stripe's rate limits and read allocation are sized for payment traffic, not for powering your application's pages. Mirror the identifiers plus the few fields you actually query, write them only from Stripe's events and read-backs, and reconcile on a schedule. Mirroring every field of every object turns Stripe's schema into your migration burden.

Can I use Stripe as my database?

No. The only general-purpose storage on Stripe objects is metadata: 50 key-value pairs per object, 500 characters per value, no validation, no transactions, and nothing in Stripe reads it outside Radar rules. Stripe's docs say it directly: if your system needs more space, store the data in your own database and keep the object's ID in metadata.

Can I call the Stripe API instead of keeping a local copy?

Only at low traffic. The global live-mode limit is 100 requests per second shared with your payment processing, most individual endpoints allow 25, and Stripe also allocates read requests at an average of 500 per transaction over a rolling 30 days. A page that makes three Stripe calls per view spends that allocation on browsers, and the search API is capped at 20 reads per second and can lag writes by up to a minute.

How do I keep my database in sync with Stripe?

Three mechanisms together: a backfill that pages through the list endpoints for history, a webhook feed applied idempotently, deduplicating by event ID and re-fetching the object rather than trusting payload order, and a scheduled reconciliation that diffs Stripe's lists against your rows to catch what the feed missed. Any two without the third leaves a known drift mode open.

What is the source of truth for orders paid through Stripe?

Your database, by design. Stripe's fulfillment guide tells you to retrieve the session with line items expanded, save a copy of the payment details and line items in your own database, and record fulfillment status yourself. Stripe remains the record for the money; the record of the sale is yours, and the two are joined by whatever key you put in metadata.

Does Stripe have an Orders API?

Not anymore, twice over. The original Orders API and its SKU objects were deprecated in October 2019 because they could not support Strong Customer Authentication, and the beta replacement announced in 2022 was removed that November, before general availability. A Checkout Session is the closest current object: a payment record with line items attached, no fulfillment state, and no stock.

Is there a service that syncs Stripe to my database?

The best-known one, Sequin, shut down on October 23, 2025. Stripe's first-party options are Sigma, which queries your Stripe data in Stripe's warehouse, and Data Pipeline, which exports it to yours on a schedule; both are built for analytics, not for serving requests. A real-time operational mirror is still the webhook pipeline you run yourself.

Order-first on Flint

Flint is a payments API where the order is the root object and the money is state on it, so the question this page splits in half has one answer: the record is the order. It exists before any payment does:

Bash
curl -X POST https://api.withflintpay.com/v1/orders \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: order-1842" \
  -d '{
    "line_items": [{
      "name": "Design workshop seat",
      "quantity": 2,
      "unit_price_money": {"amount": 45000, "currency": "USD"}
    }],
    "metadata": {"campaign": "spring-workshops"}
  }'

Collection is a surface attached to the order, not the record itself. Hosted checkout is one call, and Stripe Elements in your own UI or an emailed invoice collect against the same order the same way:

Bash
curl -X POST https://api.withflintpay.com/v1/checkout-sessions \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: checkout-1842" \
  -d '{
    "order_id": "ord_1kmn0aExample",
    "redirects": {
      "success_redirect_url": "https://example.com/thanks",
      "cancel_redirect_url": "https://example.com/checkout"
    }
  }'

Whatever surface collected the money, the outcome lands in one place:

Bash
curl https://api.withflintpay.com/v1/orders/ord_1kmn0aExample \
  -H "Authorization: Bearer YOUR_API_KEY"
JSON
{
  "data": {
    "order_id": "ord_1kmn0aExample",
    "status": "closed",
    "payment_status": "paid",
    "refund_status": "none",
    "pricing_amounts": {
      "total_money": {"amount": 92000, "currency": "USD"}
    },
    "settlement_amounts": {
      "paid_money": {"amount": 92000, "currency": "USD"},
      "refunded_money": {"amount": 0, "currency": "USD"},
      "outstanding_money": {"amount": 0, "currency": "USD"}
    },
    "checkout_session_ids": ["cs_1kmn0aExample"],
    "payment_intent_ids": ["pi_3Qx7Yt9rW2"],
    "refund_ids": [],
    "metadata": {"campaign": "spring-workshops"}
  }
}

One object answers what two systems answered before. payment_status says whether money arrived, refund_status whether any went back, and settlement_amounts carries collected, refunded, and outstanding totals next to the pricing_amounts the buyer agreed to. The checkout_session_ids, payment_intent_ids, and refund_ids arrays are join points inside one API rather than a metadata string maintained by convention. Full settlement closes the order automatically, and a later refund moves refund_status and refunded_money without reopening it or rewinding payment_status. Goods ride the same record: fulfillment_status runs from not_fulfilled through partially_fulfilled to fulfilled as fulfillment is recorded, and refunds take line items, not just amounts, with the allocation landing back on the order.

The webhook feed narrates the order rather than the plumbing: order.paid fires when the order becomes paid on any surface, which makes it the fulfillment trigger, and order.refunded, refund.updated, and the order.fulfillment events carry the rest. Events are signed with Standard Webhooks headers, so off-the-shelf verification libraries work. If you mirror into your warehouse, you mirror one upstream record instead of stitching two halves.

Stripe aloneStripe plus your databaseFlint
What was soldSession line items, behind an expandYour orders tableline_items on the order
Whether money arrivedThe session's payment statusA column written from webhookspayment_status on the same order
Refunds against itemsAmounts onlyAllocation math you wroteLine-item refunds; refund_status on the order
Whether goods shippedNo field existsA column only you updatefulfillment_status on the order
StockNot trackedYour counter and holds tableReservations in the same flow
The join between themA metadata key, by conventionA reconcile job you keep honestID arrays on the order itself

The orders-first guide builds this flow end to end, statuses and lifecycles defines every state named above, and the Orders reference has every field on the requests. Your database is still welcome to a mirror; the difference is that it mirrors one record instead of owning half of it.

Sources