Stripe CLI and Local Webhook Testing
Stripe CLI and Local Webhook Testing
The Feature
A developer can trigger Stripe events (subscription created, payment failed, invoice paid) from the command line and see them arrive at the local FastAPI backend within seconds, with correct webhook signatures for verification testing.
The Decision
The Stripe CLI is the official tool for local webhook development. The alternative, creating a publicly accessible URL and configuring it in the Stripe dashboard, requires a stable URL (which means Cloudflare Tunnel must be running) and sends real webhook events to a development machine. The Stripe CLI provides a local-only webhook forwarding path with its own signing secret, keeping test events isolated from production configuration.
The Docker Compose file includes the Stripe CLI as a service, so it starts automatically with docker compose up. No separate terminal window needed.
The Implementation
Stripe CLI Authentication
# Outside Docker, authenticate the Stripe CLI once
stripe login
# This creates ~/.config/stripe/config.toml with your API key
# The Docker container mounts this config file
Docker Compose Service
# Addition to docker-compose.yml
services:
stripe-cli:
image: stripe/stripe-cli:latest
command: >
listen
--forward-to backend:8000/api/webhooks/stripe
--api-key ${STRIPE_SECRET_KEY}
depends_on:
- backend
environment:
STRIPE_DEVICE_NAME: marketflow-dev
When the Stripe CLI container starts, it outputs a webhook signing secret:
> Ready! Your webhook signing secret is whsec_abc123...
This signing secret is different from the production webhook secret. For local development, set STRIPE_WEBHOOK_SECRET in .env to this value. The backend uses it to verify webhook signatures.
Testing Webhook Delivery
# Trigger a specific event
docker compose exec stripe-cli stripe trigger checkout.session.completed
# Trigger a subscription lifecycle
docker compose exec stripe-cli stripe trigger customer.subscription.created
docker compose exec stripe-cli stripe trigger invoice.payment_succeeded
docker compose exec stripe-cli stripe trigger customer.subscription.updated
# Trigger a payment failure
docker compose exec stripe-cli stripe trigger invoice.payment_failed
# View recent events
docker compose exec stripe-cli stripe events list --limit 5
Minimal Webhook Endpoint (Placeholder)
# backend/app/routers/webhooks.py
# This is a placeholder. Full implementation in Chapter 8.
from fastapi import APIRouter, Request, HTTPException
import stripe
from app.config import settings
router = APIRouter(prefix="/api/webhooks")
@router.post("/stripe")
async def stripe_webhook(request: Request) -> dict[str, str]:
payload = await request.body()
sig_header = request.headers.get("stripe-signature")
# SAFE: Always verify the webhook signature
try:
event = stripe.Webhook.construct_event(
payload, sig_header, settings.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")
# Log the event type for now
print(f"Received Stripe event: {event['type']}")
return {"status": "received"}
This placeholder verifies signatures from day one. When Chapter 8 adds the full webhook handling logic, the verification is already in place. Starting with unverified webhooks and adding verification later is a pattern that produces security vulnerabilities in the gap between “later” and “now.”
The Trap
# TRAP: Using the raw request body after reading it as JSON
@router.post("/stripe")
async def stripe_webhook(request: Request) -> dict[str, str]:
data = await request.json() # Parses and re-serializes
payload = json.dumps(data).encode() # Different bytes than original
sig_header = request.headers.get("stripe-signature")
# This ALWAYS fails because the re-serialized JSON has different
# whitespace, key ordering, or encoding than the original payload
event = stripe.Webhook.construct_event(
payload, sig_header, settings.stripe_webhook_secret
)
# SAFE: Use request.body() to get the raw bytes
@router.post("/stripe")
async def stripe_webhook(request: Request) -> dict[str, str]:
payload = await request.body() # Raw bytes, exactly as Stripe sent them
sig_header = request.headers.get("stripe-signature")
event = stripe.Webhook.construct_event(
payload, sig_header, settings.stripe_webhook_secret
)
Stripe signs the raw HTTP body. If you parse it as JSON and re-serialize it, the bytes change and the signature no longer matches. This is the single most common Stripe webhook integration bug, and it is invisible in testing if you skip signature verification.
The Cost
| Component | Cost |
|---|---|
| Stripe CLI | $0 (free) |
| Stripe test mode | $0 (no charges in test mode) |
| Stripe webhook events | $0 (no per-event cost) |
Stripe test mode provides a complete simulation of the payment system. No charges are processed, no money moves, and all API calls return realistic responses. The transition from test mode to live mode is a key swap, not a code change.