Stripe Playground
Staged Stripe payments learning prototype on Bun + Hono + TanStack — Elements, hosted Checkout, signature-verified webhooks, idempotency, and refunds
Problem
Stripe’s surface area is enormous — Elements, hosted Checkout, PaymentIntents, webhooks, idempotency, refunds, dynamic payment methods, 3DS, async crypto rails — and a single tutorial app collapses all of them into one screen, hiding the order in which the primitives actually depend on each other. To really learn the platform you want each concept introduced as its own staged commit, with a working artifact at every step and an explicit comparison memo when two paths cover the same ground.
Approach
- Staged rollout, one concept per stage. Stage 1 seeds Stripe objects via the CLI. Stage 2 adds Elements + order-keyed PaymentIntent. Stage 3 adds signature-verified webhooks and idempotency. Stage 4 contrasts hosted Checkout against Elements. Stage 5 adds full refunds plus a stablecoin / Tempo deep-dive.
- Webhook-authoritative order state. The success page polls
GET /api/payments/order/:orderIdand reflects whatever the webhook wrote, not whatever the redirect param said. - Idempotency at every layer.
processed_eventskeyed byevent.idfor inbound webhooks; order-derived idempotency key onrefunds.create; pre-check viarefunds.listbefore issuing. - Test-mode-only by construction. The server (
loadEnv) and the web bundle (lib/stripe.ts) both reject keys that don’t start withsk_test_/pk_test_at startup. - One repo, two flows. Elements (
/checkout) and hosted Checkout (/checkout-hosted) both share the sameuseOrderPollhook and webhook dispatcher so the comparison is apples-to-apples.
Implementation
Stage 1 — Stripe CLI Seeding + Hono Skeleton
A bun run seed script provisions Customer / Product / Price objects via the Stripe CLI; --cleanup and --tag flags recover orphans from failed runs. Hono on Bun serves as the API skeleton.
Stage 2 — Elements + Order-keyed PaymentIntent
POST /api/payments/intent is a create-or-reuse endpoint backed by bun:sqlite (apps/api/.data/), so resubmissions return the same intent rather than racing. The web app at /checkout uses TanStack Query to create the intent and renders Stripe’s PaymentElement with dashboard-driven dynamic payment methods (cards + crypto + whatever’s enabled).
Stage 3 — Signature-verified Webhooks + Idempotency
POST /api/webhooks/stripe verifies the Stripe-Signature header against the raw request body, gates on a processed_events table keyed by event.id, and drives orders.status from payment_intent.{succeeded,processing,payment_failed,canceled} and charge.refunded. The success page polls until the status is terminal — webhook truth wins over the redirect.
Stage 4 — Hosted Checkout vs Elements
POST /api/checkout/session creates a Stripe-hosted Checkout Session; /checkout-hosted redirects users to it; /checkout-hosted/{success,cancel} close the loop via the shared useOrderPoll hook. checkout.session.{completed,async_payment_succeeded,async_payment_failed,expired} are dispatched from the same webhook handler so order state is consistent across both flows. A comparison memo lives in docs/notes/stage-4-elements-vs-checkout.md.
Stage 5 — Refunds + Stablecoin Deep-dive
POST /api/payments/refund issues a full refund via stripe.refunds.create with an order-derived idempotency key, gated on orders.status == "succeeded" and pre-checked against refunds.list to avoid double-issuing. Both success pages expose a Refund button and reflect the refunded state once charge.refunded lands. Stablecoin acceptance, Tempo watch notes, and main-app integration checklists live under docs/notes/.
Per-stage Audit Loop
Every stage closes with a dual-review audit commit (/codex + /code-review) that catches concrete bugs — raw-body cache, filter-first idempotency, requires_action handling, terminal-state downgrade guards, async payment flows, render-phase polling. Audit outputs are versioned under docs/audits/.
Outcome
- Five stages from CLI seeding to refunds, each leaving a working artifact and a learning note.
- Stripe Elements and hosted Checkout flows running off the same webhook-authoritative order state.
- Signature-verified inbound webhooks, idempotent processing keyed on
event.id, and order-derived idempotency on outbound refund creation. - Live keys structurally rejected — server and web bundle both refuse
sk_live_/pk_live_at startup, so the prototype can’t accidentally hit production.
Technologies
- Runtime: Bun 1.2.8
- Frontend: TanStack Router + Query, React 19, Vite, Stripe Elements
- Backend: Hono on Bun
- Storage:
bun:sqlite(orders,processed_events) - Shared types: Zod via
packages/shared - Payments:
stripeSDK, Stripe CLI (stripe listen,stripe trigger,stripe events resend)