Stripe sent a payment.success event. 14 seconds later, the buyer had their blueprint. Nobody touched a keyboard.
We'd been manually emailing download links for six weeks. Every sale was a 4-minute interruption: open Gmail, find the template, paste the link, swap the buyer's name, hit send. It worked. It was also the kind of task that makes you question your life choices at 9pm on a Tuesday. Then we wired the webhook. The manual step disappeared. The first automated sale felt strange, almost wrong, because there was nothing left to do.
Here's what we built, how long it took, and the exact code running in production.
TL;DR (30 seconds)
We replaced 12-15 minutes of manual Gmail fulfillment per sale with a FastAPI webhook handler that fires in 14 seconds. The system uses three components: a Stripe listener, a fulfillment router, and a delivery agent. Total build time was 14 days. The full blueprint is $297 at /blueprints/.
- The trigger: Stripe's
checkout.session.completedevent carries everything you need: customer email, product SKU, and session metadata. It's all in one payload. - The gap most tutorials skip: Verifying the signature is step one. Routing to the right handler based on the SKU is the actual work, and almost nobody covers it.
- The failure mode that will burn you: Stripe retries failed webhooks. Without an idempotency check against your Supabase table, the buyer gets the download link twice, or more.
- The 14-day path: Week 1 wires the listener and router. Week 2 handles edge cases, writes the Jinja2 email templates, and deploys to production.
Manual Fulfillment Is a Choke Point
You're not failing. You've sold 40-plus units. The product works, the checkout works, buyers are happy. But every sale creates a task, and that task owns a random slice of your day.
Here's the math: 15 minutes per sale, 50 sales, is 12.5 hours. That's a full work day you've spent copy-pasting download links. And it's not one block of 12.5 hours you can plan around. It's 50 interruptions scattered across evenings and weekends, each one just annoying enough to break your concentration but not painful enough to force a fix.
The tutorials aren't helping. The Stripe quickstart gets you to a verified endpoint and then stops cold at "add your fulfillment logic here." Stack Overflow threads show stripe.Webhook.construct_event syntax from the 2.x SDK that throws a different error now. The YouTube walkthroughs spend 11 minutes on ngrok before writing a single line of business logic. None of them answer the question you actually have: what happens between "signature verified" and "buyer has their file?"
We'll show you the whole thing.
| Step | Manual (before) | Automated (after) |
|---|---|---|
| Payment received | Check Stripe dashboard | Webhook fires instantly |
| Buyer notified | Email manually from Gmail | Auto-email in 14 seconds |
| File delivered | Copy download link, paste in email | Auth URL generated and sent |
| Record kept | Spreadsheet update | Supabase row inserted |
| Time per sale | 12-15 minutes | 0 minutes |
The Architecture: 3 Components, 150 Lines
We built this in FastAPI. The whole system is three components. Each one has a single job.
Component 1: Stripe webhook listener. This is the FastAPI endpoint that receives checkout.session.completed events. Its only job is to verify the Stripe signature and extract two things: the product SKU from the session metadata and the customer email from customer_details. If signature verification fails, it returns a 400 immediately. If it passes, it hands off to the router. The listener doesn't know anything about products or delivery. It just checks the handshake and passes the baton.
Component 2: Fulfillment router. The router reads the SKU and decides what happens next. We sell three products. Each SKU maps to a different handler: one emails a download link, one writes a row to Supabase and triggers a Claude agent, one does both. The router is a simple dictionary dispatch. Adding a new product means adding one key to that dictionary. This is the piece most tutorials skip entirely, and it's where the actual business logic lives.
Component 3: Delivery agent. The delivery agent generates an authenticated download URL with a 48-hour expiry, renders a Jinja2 onboarding email template with the buyer's name and product details, and sends via your transactional email provider. It also writes a fulfillment record to Supabase before sending, not after. That ordering matters. If the email send fails, you have a record to retry from. If you write the record after, a crash mid-send leaves you with no trail.
Here's the listener and router entry point. This is the real handler, not a skeleton:
@app.post("/webhook/stripe")
async def stripe_webhook(request: Request):
payload = await request.body()
sig = request.headers.get("stripe-signature")
try:
event = stripe.Webhook.construct_event(
payload, sig, STRIPE_WEBHOOK_SECRET
)
except ValueError:
raise HTTPException(status_code=400, detail="Invalid payload")
except stripe.error.SignatureVerificationError:
raise HTTPException(status_code=400, detail="Invalid signature")
if event["type"] == "checkout.session.completed":
session = event["data"]["object"]
product_sku = session["metadata"].get("sku")
customer_email = session["customer_details"]["email"]
await route_fulfillment(product_sku, customer_email)
return {"status": "ok"}
One thing you need to add before this goes to production: an idempotency check inside route_fulfillment. Stripe retries webhook delivery if your endpoint doesn't respond with a 2xx within 30 seconds. Without a check against your Supabase fulfillment table, a slow response or a temporary outage means the buyer receives the download link two or three times. The fix is one query: look up the session ID before inserting. If the row exists, return early. That's it. It's 4 lines and it will save you an awkward support email at some point.
Understanding why structured content matters for AI citation helped us think about how we document these handlers internally too. The same principle applies: clarity and structure make things findable, whether the reader is a human or a model.
The 14-Day Timeline
We didn't build this over a single weekend. We tried. It took 14 days of actual focused work, spread across evenings.
Week 1 (days 1-7): Wiring. Day 1 is setting up the FastAPI project, adding the /webhook/stripe route, and getting signature verification working against a test event from the Stripe CLI. Not ngrok, not a local tunnel. The Stripe CLI's stripe trigger checkout.session.completed command sends a test event directly to your local server. Days 2 and 3 are building the fulfillment router and writing the first handler for your primary SKU. Day 4 is the Supabase integration: inserting the fulfillment row, testing the idempotency check with duplicate events. Days 5 through 7 are the delivery agent: the authenticated URL generation, the Jinja2 template, and the first real end-to-end test where you pay $1 on your own Stripe test checkout and receive the actual email.
Week 2 (days 8-14): Testing and deploying. Day 8 is edge cases: what happens if metadata.sku is missing? What happens if the customer email is null? You need explicit handling for both, or a missing metadata field will throw an unhandled exception and Stripe will retry indefinitely. Days 9 and 10 are email template polish and adding a second SKU handler if you have one. Days 11 and 12 are deploying to a real server (we used Fly.io, $3/month for a single instance) and configuring the production webhook endpoint in the Stripe dashboard. Days 13 and 14 are live testing with real $1 transactions and watching the Supabase table populate in real time. On day 14, we turned off the old Gmail manual process. It felt like deleting a spreadsheet you've kept for sentimental reasons.
What We'd Do Differently
We'd use Supabase Edge Functions from day 1 instead of a separate FastAPI server.
Fly.io is fine. But it's one more thing to deploy, monitor, and pay for. Supabase Edge Functions run Deno, and they sit right next to your database. There's no network hop between the webhook handler and the Supabase insert. Cold start time is under 200ms. You skip the infrastructure layer entirely, and the webhook handler lives in the same repo as your database schema.
The tradeoff is that Deno TypeScript is less familiar if you've been writing Python. We appreciate the Python ecosystem for this kind of work. But if we were starting today with a greenfield Supabase project, we'd write the handler in TypeScript and keep the whole stack in one place. The 150-line Python system works. The Supabase-native version would be 90 lines and one fewer monthly bill.
If you want the full implementation (the router dispatch table, the Jinja2 templates, the Supabase idempotency pattern, and the Fly.io deploy config), we packaged it into a $297 blueprint at /blueprints/. Or if you'd rather not spend the 14 days yourself, let us wire it for you. That's the $1,997 Concierge engagement, and we'll have it running in your stack within a week.
Either way, let me know if you run into the signature verification issue with the current Stripe SDK. It's a common snag and happy to help you through it.
Next post: How we handle failed deliveries and retries without re-triggering duplicate fulfillment. The full Supabase idempotency pattern with schema and query.