nicholas90.homelinked.tech / reports / stripe-webhooks

Stripe Webhooks with Checkout & Invoices

A research report on how Stripe uses webhooks to reliably confirm payments across Checkout and Invoices — the events, the lifecycle, the security rules, and how it applies to my SpinDry product page.

Nicholas Brandon Yang Capability Commerce · Stage 3 2026-07-13 Test mode

TL;DR — A redirect to a success page can be closed, refreshed, or faked, so it must never be your source of truth for “paid.” A webhook is Stripe calling your server directly to say what really happened. For one-time Checkout, listen for checkout.session.completed; for Invoices (and subscriptions), listen for invoice.paid. Always verify the signature, process each event once (idempotency), and reply 2xx within 20 seconds.

1 · What a webhook is

Why a redirect isn’t enough

When a customer pays, Stripe sends them back to your success_url. That redirect is fine for showing a “thank you” message, but it is not reliable proof of payment: the customer might close the tab before it loads, lose their connection, or open the URL manually without paying. Payment methods that settle later (bank debits, some wallets) aren’t even “paid” yet at redirect time.

A webhook solves this. It’s an HTTP POST that Stripe sends from its servers directly to a URL on your server whenever something happens (a payment succeeds, an invoice is paid, a charge is refunded). It arrives regardless of what the customer’s browser does, and Stripe retries it until your server confirms receipt. That makes webhooks the trustworthy backbone for fulfilment, receipts, and evidence.

Mental model: the redirect is what the customer sees; the webhook is what your system trusts.

2 · Webhooks + Checkout

Confirming a Checkout payment

Stripe Checkout (both the hosted page and Payment Links) emits webhook events for the Checkout Session. The main one is checkout.session.completed, fired when the customer finishes the session. For card payments this effectively means paid; for delayed methods, payment may still be processing.

Customer pays on Stripe Checkout │ ▼ Stripe → POST https://your-server/webhook (event: checkout.session.completed) │ ▼ Your server: verify signature → check it's not a duplicate → fulfil once → 200 OK

Key Checkout events

EventWhen it fires
checkout.session.completedCustomer completed the session (start fulfilment here).
checkout.session.async_payment_succeededA delayed payment method later succeeded.
checkout.session.async_payment_failedA delayed payment method later failed.
checkout.session.expiredThe session expired without payment.

Fulfilment best practices (from Stripe docs)

Minimal endpoint (Python / Flask sketch)

# SECRET keys live here on the SERVER only — never in a webpage.
import stripe, os
endpoint_secret = os.environ["STRIPE_WEBHOOK_SECRET"]  # whsec_...

@app.post("/webhook")
def webhook():
    payload = request.get_data()                 # RAW body, unmodified
    sig     = request.headers["Stripe-Signature"]
    event   = stripe.Webhook.construct_event(payload, sig, endpoint_secret)  # verifies

    if event["type"] == "checkout.session.completed":
        session = event["data"]["object"]
        if already_processed(event["id"]):     # idempotency
            return "", 200
        fulfil_order(session)                     # mark paid, record evidence
        mark_processed(event["id"])

    return "", 200                             # 2xx tells Stripe "got it"
3 · Webhooks + Invoices

The invoice lifecycle

An invoice is a billing document with its own life. Instead of watching one “paid?” flag, you listen to events as it moves through states:

draft ──finalize──▶ open ──payment succeeds──▶ paid │ │ │ └──payment fails──▶ (retry) ──▶ uncollectible └──(discard)──▶ void

Invoices are created as draft; finalizing them makes them open (ready to pay). With automatic collection, Stripe finalizes and attempts payment for you. On success the status becomes paid.

Key invoice events

EventWhen it fires
invoice.createdA draft invoice was created.
invoice.finalizedDraft became an open invoice, ready to be paid.
invoice.paidRecommended. Fires when paid or marked paid out-of-band.
invoice.payment_succeededA payment attempt succeeded (not sent for out-of-band).
invoice.payment_failedA payment attempt failed (dunning / retry).
invoice.voidedThe invoice was cancelled.

The one nuance to remember: invoice.paid covers both online payments and invoices marked paid manually (out-of-band), while invoice.payment_succeeded only fires for actual payment attempts. Stripe recommends listening to invoice.paid so you never miss a paid invoice.

4 · How they connect

Checkout, Invoices and webhooks together

Checkout and Invoices are two ways money arrives; webhooks are the single reliable channel that tells you it did. How they relate:

So: one-time sale → Checkout events; recurring or billed sale → Invoice events; and in every case the webhook is what flips your own record to “paid” and writes the evidence.

5 · Setup & security

How to wire it up safely

Security boundary (same rule as Day 1): the secret key (sk_…) and the webhook signing secret (whsec_…) live in server environment variables only — never in a webpage, never in a public repo. My current SpinDry page uses a Payment Link, so it holds no keys at all.

6 · Applied to SpinDry

What I built — the loop is closed

SpinDry uses a Payment Link (Path A): my own Stripe account, no server, no keys — Stripe hosts everything and I can see test payments in my Dashboard. That sells, but the page alone never learns that a payment happened.

So I built Path B — and it is now live. My own Cloudflare Worker at spindry-webhook.nbybrandon.workers.dev receives every event, verifies the Stripe signature (HMAC-SHA256 via Web Crypto), and returns 200 — confirmed “Delivered” in the Stripe dashboard for checkout.session.completed. It records the event and can forward it to another URL, so a payment automatically triggers action elsewhere. That closes the loop for real: capability → product → payment → webhook → evidence.

Next from here: auto-write the JSONL evidence straight back to my runtime, and add coupons / subscription invoices.

Sources