Metrivo
Back to blog

stripe revenue doesn't match

Stripe Revenue Doesn't Match Your Database or Analytics? A SaaS Revenue Reconciliation Guide

Stripe shows one revenue number, your database another, analytics a third. Reconcile SaaS revenue step by step: missing webhooks, duplicates, gross vs net, and MRR gaps.

15 min read
Stripe Revenue Doesn't Match Your Database or Analytics? A SaaS Revenue Reconciliation Guide - Metrivo guide cover illustration

Which number is right? Establish the comparison before the fix

Concise answer

A revenue mismatch is only diagnosable once you know which two ledgers you are comparing, over which window, in which currency, and on which amount basis.

Most founders describe the problem as "Stripe says X, my dashboard says Y." That sentence hides three separate ledgers. The provider ledger is what Stripe holds: charges, payment intents, invoices, refunds, disputes, and balance transactions. The receipt ledger is what your own database wrote when webhooks arrived. The analytics ledger is what your attribution or revenue tool derived from those receipts, often after joining them to sessions and visitors. Any two of these can disagree for different reasons, so the first job is to name the pair you are comparing.

The second job is to freeze the comparison. Pick one window by the provider's payment time rather than by your own insert time, one currency, and one live-or-test mode. Then decide on an amount basis. Stripe's dashboard totals and your database may both be correct while reporting different things: one counts gross charges, the other counts net of processing fees; one includes refunded and disputed amounts, the other excludes them; one counts one-off payments alongside subscriptions, the other only recurring invoices.

Only after the pair, window, currency, and basis are fixed does a difference become evidence. Without that discipline, teams chase a gap that shrinks or grows every time someone changes the date picker, and the real defect stays hidden underneath a definition mismatch.

Common symptoms and where they usually come from
SymptomLikely causeFirst thing to check
Stripe total is higher than your databaseUndelivered or rejected webhooksStripe event deliveries tab: failed, pending, or redirected attempts
Database total is higher than StripeDuplicate processing or test-mode events mixed inUniqueness on the provider object identifier; livemode flag on stored rows
Totals match but MRR differsOne-off charges, taxes, or refunds counted differentlyWhich rows are classified recurring, and how amounts are normalised to monthly
Database matches Stripe, analytics is lowerPayments never reached the analytics layer, or arrived without identifiersWhether checkout metadata carried visitor and session identifiers
Numbers drift by a few percent every monthFees, currency conversion, or period boundariesBalance transactions for net amounts; timezone used for period edges
A refund appears in one system onlyRefund and dispute events not subscribed or not mappedWhich event types the endpoint is subscribed to, and how they change payment state

The four causes, in the order to check them

Concise answer

Check delivery first, then duplication and ordering, then definitions, and only then the handoff into analytics. Each cause leaves different evidence.

The order matters because the causes hide each other. A missing webhook makes a definition gap look bigger than it is. A duplicate can mask a missing event by coincidence. Working from the provider outward, one cause at a time, keeps the evidence clean.

1. The webhook never arrived, or your endpoint rejected it

Stripe's own documentation is precise about what happens when delivery fails. In live mode, Stripe attempts to deliver events for up to three days with an exponential back off. Events created in a sandbox are retried three times over a few hours. If your endpoint responds with a redirect, Stripe treats that as a failure, which is why an apex-to-www redirect on a webhook URL silently loses payments. A timeout counts as a failure too, so a handler that does heavy work before returning a 2xx response is a delivery risk.

The recovery window is finite. Stripe lets you resend an event from the dashboard for up to 15 days after creation and through the CLI for up to 30 days. The List Events API only returns events created in the last 30 days, but it accepts a delivery_success=false filter, which returns events that were unsuccessfully delivered to at least one endpoint. That filter is the fastest way to answer "what did we miss" for the last month.

The evidence to look for: failed or pending attempts in the endpoint's event deliveries view, non-2xx status codes in your own logs, and signature verification errors, which usually come from a framework parsing the request body before the raw bytes reach the verification step.

2. The same event was processed twice, or events arrived out of order

Stripe states that webhook endpoints might occasionally receive the same event more than once, and that it does not guarantee delivery in the order events were generated. Its guidance is to log processed event identifiers and skip repeats, and not to use the created timestamp for ordering, because distinct events can share a second. Paddle describes its delivery as at-least-once and tells integrators to use the occurred_at field rather than arrival time. Razorpay says receiving the same event multiple times is expected behaviour and provides an x-razorpay-event-id header for deduplication.

If your database total is higher than the provider's, look for the same provider object identifier stored on more than one row. If a subscription shows as active after it was cancelled, look for an older event that overwrote a newer terminal state. Both problems are fixable with a uniqueness constraint on the provider's stable identity and a valid-transition rule keyed on the provider's occurrence time.

3. The systems count different things

This is the most common cause and the least dramatic. Gross versus net is the usual culprit: a charge of 100 with a fee of 3.2 is 100 in the charges list and 96.8 in balance transactions. Refunds and disputes reduce revenue in one system and stay as original charges in another. One-off payments, setup fees, and tax lines inflate a dashboard total but do not belong in recurring revenue. Multi-currency accounts add conversion at different rates on different dates.

Metrivo's subscription metrics documentation gives a concrete example of a definition gap that looks like a bug: provider dashboards often report gross charges including one-off payments, taxes, and amounts later refunded or disputed, while Metrivo derives MRR only from payments it classifies as recurring, normalised to a monthly amount. Two correct numbers, two definitions. Write the definitions down before you compare.

4. The analytics layer never received the payment

When the provider and your database agree but your attribution tool shows less revenue, the payment probably never reached the tool, or it arrived without the identifiers needed to join it to a visitor. That is a handoff failure rather than a payment failure. The usual break is checkout: a session starts with UTM parameters and click identifiers on the landing page, but the checkout is created without passing visitor and session identifiers into the provider's metadata, so the webhook arrives anonymous.

The fix lives in checkout creation, not in the webhook handler. The UTM parameters lost at checkout guide covers the handoff, and the attribution confidence documentation explains why an anonymous payment should stay visible as unattributed revenue rather than being forced into a source.

A reconciliation procedure you can run this afternoon

Concise answer

Export both sides for one fixed window, join on the provider's object identifier, classify every difference, then repair delivery before touching definitions.

This procedure works with a spreadsheet and takes about an hour for a few hundred payments. It also produces the evidence you need if you later decide to automate it.

  • Freeze a window: the last 30 days by the provider's paid time, one currency, live mode only.
  • Export the provider side. For Stripe, the charges list or the balance transactions report both work; balance transactions give you fees and net amounts. Use a restricted key with read permissions if you export through the API rather than the dashboard.
  • Export your side: every stored payment in the window with its provider object identifier, amount, currency, status, refunded amount, and the webhook event identifier that created it.
  • Join on the provider object identifier, never on amount and date. Two payments of the same amount on the same day are common in SaaS.
  • Classify each difference: present in provider but missing locally, present locally but missing in provider, amount or currency mismatch, status mismatch, refund or dispute mismatch, fee or net mismatch.
  • For every provider-only row, open the event in Stripe and check its delivery attempts. Resend it, or list undelivered events with delivery_success=false and replay them through your handler with idempotency guards in place.
  • For every local-only row, check the livemode flag and look for duplicates. A local-only live payment that Stripe does not know about should not exist.
  • Fix the root cause before re-running: raw-body signature verification, a webhook URL that does not redirect, a handler that stores the event before doing slow work, and a uniqueness constraint on the provider identifier.
  • Re-run the same window after the fix and record the result with the date, the window, the definitions used, and the remaining unexplained amount.
Difference categories and what each usually means
DifferenceUsual meaningRepair
Provider has it, you do notDelivery failed, endpoint rejected it, or event type not subscribedReplay the event; subscribe to the missing type; fix the endpoint
You have it, provider does notDuplicate row, test-mode row, or forged request acceptedUniqueness on provider identifier; livemode check; signature verification on raw body
Amount or currency differsGross versus net, conversion rate, or partial captureStore gross, fee, and net separately; record the currency and rate used
Status differsOlder event overwrote a newer one, or an unsupported state was ignoredOrder by provider occurrence time; map every status explicitly
Refund or dispute differsRefund and dispute events not mapped to the original paymentAttach refunds and disputes to the original payment row instead of creating new revenue rows

What independent reconciliation adds that webhook checks cannot

Concise answer

A webhook receipt proves that one event arrived. It cannot prove that another event did not. Showing absence requires reading the provider's records separately and comparing them to what you stored.

The spreadsheet procedure above is a one-off version of independent reconciliation: you took the provider's list of money movements and compared it with your own. The payment webhook audit checklist explains why a healthy webhook pipeline still needs this second path. Every check inside the pipeline starts from an event that arrived. The events that never arrived leave no trace inside the pipeline at all.

Metrivo's Revenue Assurance is a continuous version of the same comparison, and its code is specific about what it does. For Stripe it accepts only a restricted key beginning with rk_live_ or rk_test_; an unrestricted sk_ secret key is rejected with an explicit message that secret keys cannot be used for read-only reconciliation. It validates the key by probing list endpoints for charges, payment intents, invoices, invoice payments, refunds, disputes, balance transactions, and subscriptions, and reports any permission that is missing. It then reads those streams in bounded windows and compares three views: the provider's canonical money units, the signed webhook receipts Metrivo stored, and the revenue projection Metrivo materialised from them.

Every difference is stored as a reason code rather than a narrative. The current code defines twelve of them, and they map closely to the manual categories above. A mismatch that is specific to one provider object produces a decision of measurement broke, with the affected amount summed across the objects involved. Missing or stale evidence produces insufficient evidence. Only when the evidence is complete, fresh, and large enough does Metrivo report that the business changed, with a direction of up, down, or stable.

Metrivo reconciliation reason codes and what each means
Reason codeWhat was observed
SOURCE_MISSING_WEBHOOKThe provider holds a finalised payment with no matching signed webhook receipt
WEBHOOK_MISSING_PROJECTIONA receipt exists but the downstream revenue projection never recorded it
DUPLICATE_MONEY_UNITThe same provider object appears more than once
AMOUNT_MISMATCH / CURRENCY_MISMATCHProvider and local amount or currency disagree
STATUS_MISMATCHProvider and local payment state disagree
REFUND_MISMATCH / DISPUTE_MISMATCHRefunded or disputed amounts disagree
FEE_NET_MISMATCHFee or net amounts disagree with balance transactions
RELATIONSHIP_UNRESOLVEDA provider component could not be linked to its anchor payment
SOURCE_STALE / PROJECTION_DELAYEDProvider read or projection is older than the freshness threshold

The thresholds behind the decision

The decision policy in the current code is deliberately conservative. Provider evidence older than 30 minutes or a projection older than 15 minutes blocks a business verdict. Fewer than five finalised charges in the window also blocks it, and confidence only reaches high at twenty or more. A movement counts as up or down only when it is at least ten percent of the previous amount and at least fifty major currency units; anything smaller is reported as stable. These numbers are policy version one and can change, but the principle is fixed: no confident verdict without fresh, complete, sufficient evidence.

New assurance configurations start in shadow mode, where they observe and compare without becoming the source used for a decision. A configuration becomes authoritative only after its evidence sources and permissions pass setup checks, and authority applies to diagnosis only. Metrivo never moves money, changes payment settings, or edits your site.

Which providers can be reconciled this way

In the current provider matrix, Stripe, Razorpay, Dodo Payments, and Paddle support independent reconciliation. Razorpay connects through a read-only OAuth authorisation; Paddle requires a current permission-capable API key with transaction, adjustment, and subscription read permissions; Dodo requires an API key created with write access disabled, and Metrivo states plainly that Dodo does not expose grant introspection, so it cannot independently prove write access is off. Lemon Squeezy and Polar support signed webhook attribution and lifecycle events but are not marked as independently reconciled, so for those providers completeness stays unknown rather than being claimed.

Why MRR disagrees even when payments match

Concise answer

MRR is a derived metric. Two systems with identical payment rows will still report different MRR if they classify, normalise, or time recurring payments differently.

Once payment totals reconcile, the remaining gap is almost always in how recurring revenue is derived. Metrivo's documented approach is to classify each recurring payment against the customer's previous monthly amount as new, renewal, expansion, contraction, churn, or reactivation, and to sum the active monthly amounts. Each recurring payment is normalised to a monthly amount using its billing interval, so an annual payment does not appear as twelve months of MRR in one month. Churn is detected by absence: a subscription that stops producing payments within its expected billing window is classified as churned, whatever the subscription record says, which also means very recent churn is provisional until the billing window has elapsed.

A provider dashboard that reports MRR from plan configuration will disagree with a payment-derived figure whenever a customer is on a plan but not paying, or paying but not on a plan. Neither is wrong. They answer different questions: what customers are subscribed to, and what customers actually paid. The SaaS metrics guide covers the definitions, and the failed payments and subscription metrics documentation describes the classification in detail, including that derived metrics can be recomputed after missing payments are backfilled.

If you connected a provider late or imported history, recompute after the backfill and compare again. A gap that persists after payments reconcile and definitions match is rare and worth a support conversation with whichever tool is producing it.

Preventing the next mismatch

Concise answer

Most revenue mismatches are prevented by four handler rules and one scheduled comparison.

Verify the signature on the raw request body before anything else, and reject on failure. Store the event durably before returning a 2xx response, and do the slow work afterwards from that stored record. Enforce uniqueness on the provider's stable object identifier, not on the event identifier alone, because Stripe notes that two separate event objects can be generated for the same change. Apply state transitions in provider occurrence order, never in arrival order.

Then schedule the comparison. A daily job that lists provider records for the trailing window and diffs them against stored receipts catches the events that never arrived while they can still be replayed. If you use Stripe, the undelivered-events filter makes that job short. If you would rather not build and maintain it, a tool that performs the comparison from read-only credentials and reports absence explicitly is the alternative; the Revenue Assurance page describes the incident categories Metrivo reports, and the payment verification documentation describes how signed evidence is accepted in the first place.

If you want Metrivo to run this comparison for one website and one payment path, use the Add my website flow on the homepage. It starts real onboarding and connects a provider through signed webhooks first, with reconciliation added afterwards from a read-only credential. The separate demo is a no-signup seeded product sample, not a live comparison against your Stripe account.

Frequently asked questions

Why does Stripe show more revenue than my database?

The most common cause is undelivered or rejected webhooks. Stripe retries live-mode deliveries for up to three days, but a redirecting URL, a timeout, or a signature failure caused by body parsing can make every retry fail. The second cause is subscription coverage: an event type you never subscribed to. Compare the provider's charges list against your rows on the provider object identifier to find the missing ones, then replay them.

How long can I replay a missed Stripe webhook event?

Stripe's documentation states that you can resend an event from the dashboard for up to 15 days after creation and through the CLI for up to 30 days. The List Events API returns only events created in the last 30 days and accepts a delivery_success=false filter to isolate events that failed delivery to at least one endpoint.

Does a 200 response prove the payment was recorded?

No. A 2xx response tells the provider that delivery was acknowledged. It says nothing about whether the event was stored, processed once, mapped to the right money state, or reflected in your analytics. Independent reconciliation against the provider's records is the only way to show that nothing was missed.

Why doesn't my MRR match the Stripe dashboard?

Usually because the two are counting different things. Provider dashboards often report gross charges including one-off payments, taxes, and amounts later refunded or disputed. A payment-derived MRR counts only payments classified as recurring, normalised to a monthly amount, and detects churn by the absence of an expected payment. Reconcile payment totals first, then compare the definitions.

Can I reconcile Stripe without giving a tool my secret key?

Yes. Stripe restricted keys can be limited to read permissions on specific resources such as charges, invoices, disputes, balance transactions, and subscriptions. Metrivo's reconciliation rejects unrestricted secret keys outright and accepts only restricted keys, which it validates by probing the read endpoints it needs.

Which providers does Metrivo reconcile independently?

In the current capability matrix, Stripe, Razorpay, Dodo Payments, and Paddle support independent reconciliation. Lemon Squeezy and Polar are supported for signed webhook attribution and lifecycle events but are not marked as independently reconciled, so completeness for those providers is reported as unknown rather than claimed.