Stripe Payment Reconciliation: Compare Stripe State to Your App

SendPromptly Team
4 min read

Stripe is the source of truth for payment state. Your application is the source of truth for customer experience. Reconciliation compares the two so you can find customers whose payment state and app state no longer match.

This matters because webhook delivery does not prove fulfillment. Stripe may show a paid invoice or active subscription while your app still shows missing credits, a free plan, or stale access.

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.

SendPromptly is designed to catch many of these gaps closer to real time by tracking the expected outcome after a payment event. Reconciliation remains useful as a backstop and audit practice.