Skip to content

Guide 09

How to keep your app's billing in step with Stripe.

Stripe decides what was paid. Your app keeps a copy, and the copy drifts unless something keeps it honest. This is how this site's billing does it, and how to check payouts against the payments inside them.

Edwin Barrera · Founder and software architect

· 2 min read

The short answer

  1. 01Store every event once, and answer Stripe fast.
  2. 02Never let an older event overwrite a newer one.
  3. 03Fetch the events your webhook missed from Stripe's API.
  4. 04Let only Stripe mark money as paid.

Payouts come last: they're how you check the money that reached your bank against the payments you recorded.

Why the copy drifts

Stripe sends each change as an event to your webhook. Events can arrive twice, out of order, or not at all while your server is down. Stripe retries failed deliveries for a while (its webhook docs say how), but a copy that trusts every delivery as it arrives will drift.

1. Store every event once

The webhook, simplified from this site's billing
@router.post("/v1/webhooks/stripe")
async def receive(request: Request, stripe_signature: str = Header()) -> dict[str, bool]:
    payload = (await request.body()).decode()
    try:
        # Checks the signature over the raw body, and rejects deliveries older than five minutes.
        stripe.WebhookSignature.verify_header(
            payload, stripe_signature, WEBHOOK_SECRET, tolerance=stripe.Webhook.DEFAULT_TOLERANCE
        )
    except stripe.SignatureVerificationError:
        raise HTTPException(400) from None
    event = json.loads(payload)
    # Stored under its id and queued in one transaction: a second delivery is acknowledged and skipped.
    new = await store_once_and_queue(event)
    return {"received": True, "duplicate": not new}
  • Verify the signature on the raw body before reading anything in it.
  • Store the event under its id, so a second delivery changes nothing.
  • Do the work in a background job and return quickly. If storing fails, return an error, and Stripe delivers it again.

2. Never let an old event win

Each record keeps the creation time of the last event that changed it. An event older than that is ignored, so a late delivery can't undo a newer state.

The projection, simplified
async def project(collection: str, stripe_id: str, fields: dict, event_created: int) -> None:
    # Write the object's state, unless a newer event already has.
    await db[collection].update_one(
        {
            "stripeId": stripe_id,
            "$or": [
                {"lastEventCreated": {"$lte": event_created}},
                {"lastEventCreated": {"$exists": False}},
            ],
        },
        {"$set": {**fields, "lastEventCreated": event_created}},
        upsert=True,
    )

3. Backfill what the webhook missed

Stripe's API lists the events of the last 30 days. Our worker reads that list on a schedule, starting an hour before the newest event it saw last time, and sends the ones it doesn't have through the same path as the webhook.

Because events are stored once and stale ones are ignored, running it twice, or on two workers at once, changes nothing. It replays only the types the webhook subscribes to: customers, invoices, checkouts, refunds and disputes.

4. Let only Stripe mark money as paid

Our code never marks an invoice as paid. It asks Stripe to create and send invoices, with an idempotency key on every write so a retry can't create a second one, and waits for Stripe's events to say what happened. The admin never shows money that Stripe hasn't confirmed.

Matching payouts

Stripe sends your balance to your bank as payouts, each one the sum of many payments, refunds and fees. To check one, list the balance transactions it settled: for automatic payouts, the balance transactions endpoint takes a payout id and returns them, and the payout reconciliation report does the same in the Dashboard. Match each transaction to the invoice or charge in your own records; the fee is on the transaction.

This site's billing stops before that step: it keeps invoices, subscriptions and recurring revenue per product in step with Stripe. Matching payouts is the next job for the same worker when a client needs it, and it's the kind of work our data and automation service does.

Read next

Have something to build? Tell us about it.

A person reads every brief and replies with questions or a first plan.