Stripe Payment Reconciliation: Compare Stripe State to Your App

SendPromptly Team
4 min read

You just found one customer whose Stripe subscription is active but whose app account is still on the free plan. You fixed that one. Now the uncomfortable question: how many more are there right now, silently paying for nothing, that nobody has complained about yet?

This is the moment most teams start reconciliation — not proactively, but reactively, after a support ticket or a chargeback surfaces the first mismatch. Stripe is the source of truth for payment state. Your application is the source of truth for what the customer actually experiences. Webhook delivery does not guarantee the two stay in sync: Stripe can show a paid invoice or an active subscription while your app still shows missing credits, a free plan, or stale access, and neither system will flag that gap on its own.

A one-off script catches today’s mismatches. It does not catch tomorrow’s, and it does not run itself. Here is how to build a reconciliation check you can actually trust and keep running.

Decide what should match

Start with one payment path and define the expected app-side effect.

Examples:

  • Active Stripe subscription means the app account has the matching paid plan.
  • Paid invoice.paid event means a credit grant record exists.
  • Cancelled subscription means app access is downgraded or scheduled to downgrade.
  • Refunded payment means the related credit deduction or access revocation happened.

Keep the first reconciliation job narrow. A small check that runs weekly is more useful than a broad script nobody trusts.

Pull Stripe state

For a subscription app, list active subscriptions:

1
stripe subscriptions list --status active --limit 100

For invoice-credit products, list paid invoices for a window:

1
2
3
4
stripe invoices list \
  --status paid \
  --created[gte]=1716000000 \
  --created[lte]=1716086400

Use a bounded time window when you are starting. It keeps the result set small and makes discrepancies easier to audit.

Compare against app state

The comparison should use stable identifiers, not only email addresses. Prefer Stripe Customer ID, subscription ID, invoice ID, or payment intent ID stored in your own database.

Here is a runnable Python skeleton for a subscription check:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import os
import sqlite3
import stripe

stripe.api_key = os.environ["STRIPE_SECRET_KEY"]
db = sqlite3.connect("app.db")

for subscription in stripe.Subscription.list(status="active", limit=100).auto_paging_iter():
    customer_id = subscription["customer"]
    price_id = subscription["items"]["data"][0]["price"]["id"]

    row = db.execute(
        "select id, plan_price_id from users where stripe_customer_id = ?",
        (customer_id,),
    ).fetchone()

    if row is None:
        print(f"missing user for stripe customer {customer_id}")
        continue

    user_id, app_price_id = row
    if app_price_id != price_id:
        print(f"plan mismatch user={user_id} app={app_price_id} stripe={price_id}")

Adapt the SQL to your schema. The important part is that every discrepancy becomes a concrete record your team can inspect.

Classify the mismatch

Not every mismatch has the same cause.

  • Stripe active, app free: access provisioning failed or linked the payment to the wrong user.
  • Stripe paid invoice, no credit grant: invoice.paid handling failed, dedupe blocked the grant, or workers were down.
  • Stripe cancelled, app active: revocation handling failed or your app intentionally waits until period end.
  • App paid, Stripe inactive: manual app update, failed cancellation handling, or stale Stripe customer link.

Add enough context to each finding: customer ID, user ID, subscription or invoice ID, expected app state, observed app state, and timestamp.

Repair safely

Reconciliation tells you what is wrong. It should not blindly mutate production data without guardrails.

For each mismatch:

  1. Confirm the Stripe record and app record refer to the same customer.
  2. Confirm whether the expected effect already happened under another identifier.
  3. Use an idempotent handler, replay, or admin repair action.
  4. Record the repair with the Stripe identifier and operator.
  5. Verify the app state after repair.

For replay-based repair, your handler must dedupe by event ID or business effect. For direct admin repair, use a path that records an audit trail.

Automate after the first run

Once the script produces useful findings, schedule it. Start with a weekly run, then move high-risk paths to daily or hourly checks.

Alert only on actionable findings. A reconciliation job that produces noisy, untriaged output becomes background noise quickly.


Reconciliation is what you run after you already suspect something is wrong. SendPromptly is designed to catch these gaps closer to real time, by tracking the expected outcome after each payment event and opening an incident the moment one goes missing — so reconciliation becomes a backstop and audit practice instead of your only safety net. See how it works →