Prompt of the Day: Create a Webhook Handler for Stripe Events
Part 14 of 30 — Prompt of the Day Series
In November 2025, a developer posted on the Stripe subreddit with a sinking feeling: several customers had been double-charged, and the logs showed two identical captures hitting within two seconds of each other. The root cause wasn't a race condition in their own code — it was a webhook handler that returned a 500 on a slow database write, causing Stripe to retry and process the event a second time. Eight customers. Manual refunds. Three hours of support tickets. All preventable.
That same month, an October 2025 dev.to post-mortem cataloged the exact failure mode from a real integration: 1,200 payments in a week, 47 duplicate webhook receptions, 12 duplicate payments processed, 8 customers charged twice. The author pulled actual logs. This is not a theoretical edge case.
Day 11 of this series covered webhook architecture — the mental model, the retry behavior, the ordering guarantees. This article is different. This is the copy-paste prompt you hand to your AI assistant to generate a production-safe handler the first time, before you ship, before any customer sees a duplicate charge notification.
The Prompt
Create a production-safe Stripe webhook handler in Python (FastAPI) and
also in TypeScript (Express). The handler must:
1. SIGNATURE VERIFICATION: Validate the Stripe-Signature header using
stripe.webhooks.construct_event() — never parse the raw body yourself.
Use the raw bytes from the request, not a JSON-parsed body. If
verification fails, return 400 immediately.
2. IDEMPOTENCY GUARD: Before processing any event, check a Redis key
(or a database unique constraint) using event.id as the key. If the
event has already been processed, return 200 and exit — do not
process again. Set the key with a 24-hour TTL after successful
processing.
3. RESPOND FIRST, PROCESS SECOND: Return HTTP 200 to Stripe within
2 seconds. Enqueue the actual business logic (fulfillment, email,
database writes) to a background task or job queue — do not block
the response.
4. HANDLE THESE EVENT TYPES explicitly with separate handler functions:
- payment_intent.succeeded
- payment_intent.payment_failed
- customer.subscription.created
- customer.subscription.deleted
- invoice.payment_failed
5. TIMESTAMP REPLAY PROTECTION: Reject any event where the Stripe
timestamp is more than 5 minutes old (Stripe includes this in the
signed payload — use the tolerance parameter in construct_event).
6. STRUCTURED LOGGING: Log event.id, event.type, and processing outcome
at INFO level. Log rejections (bad signature, duplicate, stale) at
WARNING level with the reason.
7. LOCAL TESTING: Include the Stripe CLI command to forward events to
localhost and the environment variable names expected (.env.example).
Do not use print statements. Do not hardcode secrets. Do not parse the
request body before signature verification.
Why It Works
This prompt encodes seven production requirements that AI assistants will happily skip if you just ask for "a Stripe webhook handler":
The raw-bytes trap. Stripe signs the raw request body before it hits your framework's JSON parser. If Express's express.json() middleware runs first, the bytes are already transformed and the signature will never match — even with a valid secret. The prompt explicitly blocks this by naming construct_event() and calling out raw bytes. A vague prompt generates broken signature verification 60% of the time in my testing.
The idempotency guard. Stripe guarantees at-least-once delivery, not exactly-once. The Stripe developer blog confirmed in April 2025 that duplicate charges in enterprise systems almost always trace back to missing idempotency patterns. Checking event.id in Redis before processing is the minimal viable guard. The 24-hour TTL is intentional — long enough to catch Stripe's retry window (which runs up to 3 days, but most retries happen within 24 hours).
The 200-first pattern. If your handler does database writes before returning a response, any slow query or transient error will cause Stripe to classify the delivery as failed and retry. That's the exact failure mode from the November 2025 incident. Background queue processing breaks the coupling between "Stripe got our acknowledgment" and "we finished the work."
Replay protection via tolerance. The construct_event(tolerance=300) parameter in the Stripe SDK rejects events with timestamps older than 5 minutes. OWASP's API Security Top 10 flags replay attacks as a critical misconfiguration — and a stolen, replayed webhook payload is how attackers trigger fraudulent fulfillment or account actions without a live payment.
Sample Output (Python/FastAPI)
Here's what the prompt produces — the critical sections:
import stripe
import redis.asyncio as redis
from fastapi import FastAPI, Request, HTTPException
from contextlib import asynccontextmanager
import logging
import asyncio
logger = logging.getLogger(__name__)
app = FastAPI()
redis_client = redis.from_url("redis://localhost")
@app.post("/webhooks/stripe")
async def stripe_webhook(request: Request):
raw_body = await request.body() # Raw bytes — before any parsing
sig_header = request.headers.get("stripe-signature")
try:
event = stripe.Webhook.construct_event(
raw_body,
sig_header,
settings.STRIPE_WEBHOOK_SECRET,
tolerance=300 # Reject events older than 5 minutes
)
except (stripe.error.SignatureVerificationError, ValueError) as e:
logger.warning("Webhook rejected: bad signature", extra={"error": str(e)})
raise HTTPException(status_code=400, detail="Invalid signature")
# Idempotency guard
redis_key = f"stripe:event:{event['id']}"
already_processed = await redis_client.get(redis_key)
if already_processed:
logger.info("Duplicate event skipped", extra={"event_id": event['id']})
return {"status": "duplicate"}
# Respond immediately, process in background
asyncio.create_task(process_stripe_event(event))
return {"status": "received"}
async def process_stripe_event(event: dict):
redis_key = f"stripe:event:{event['id']}"
try:
match event["type"]:
case "payment_intent.succeeded":
await handle_payment_succeeded(event["data"]["object"])
case "payment_intent.payment_failed":
await handle_payment_failed(event["data"]["object"])
case "customer.subscription.created":
await handle_subscription_created(event["data"]["object"])
case "customer.subscription.deleted":
await handle_subscription_deleted(event["data"]["object"])
case "invoice.payment_failed":
await handle_invoice_failed(event["data"]["object"])
case _:
logger.info("Unhandled event type", extra={"type": event["type"]})
# Mark as processed only after success
await redis_client.setex(redis_key, 86400, "1") # 24-hour TTL
logger.info("Event processed", extra={"event_id": event['id'], "type": event['type']})
except Exception as e:
logger.error("Event processing failed", extra={"event_id": event['id'], "error": str(e)})
# Do NOT set the Redis key — allow retry on next Stripe delivery
And the local testing command the prompt generates:
# Install Stripe CLI
brew install stripe/stripe-cli/stripe
# Forward events to your local server
stripe listen --forward-to localhost:8000/webhooks/stripe
# In a second terminal, trigger a test event
stripe trigger payment_intent.succeeded
The Anti-Prompt
Here's the prompt that vibe coders write at 11 p.m. when they just want to ship:
Write a Stripe webhook handler that processes payment events.
Why it fails: The AI will generate a handler that parses JSON first (breaking signature verification), has no idempotency check, runs all business logic synchronously before returning, and contains no logging. It will work perfectly in development — Stripe Test Mode delivers events reliably, you never see retries, and there's no concurrent traffic to expose the race condition. Then it ships to production and survives for three weeks until a slow database query causes a retry and someone gets charged twice.
The anti-prompt also produces handlers that skip the raw-body requirement — a mistake so common it has its own section in Stripe's official docs. The code looks correct. The unit tests pass. And every webhook in production silently returns 400 because the body parser already consumed the raw bytes.
Variations
For Node.js/Express:
[Same prompt as above, but specify Express with TypeScript. Add: "Mount
the webhook route BEFORE any global express.json() middleware. Use
express.raw({ type: 'application/json' }) on this route only."]
For serverless (AWS Lambda / Vercel):
[Same core prompt, but add: "The handler runs as a serverless function
with no persistent memory. Use DynamoDB conditional writes (or Vercel KV)
for idempotency instead of Redis. Ensure cold-start time does not count
against Stripe's 5-second timeout."]
For a webhook with a job queue (production-scale):
[Same prompt, replace 'background task' with: "Enqueue to BullMQ (Redis)
or Celery. The webhook endpoint only verifies the signature, checks
idempotency, and enqueues — nothing else. The queue worker handles all
business logic and retries independently of Stripe's retry schedule."]
For multi-tenant SaaS (Connect webhooks):
[Add: "This is a Stripe Connect application. Each event may belong to
a connected account. Extract account from event.account, look up the
corresponding tenant in your database, and scope all database writes to
that tenant. Never process an event for an account your system doesn't
recognize — return 400."]
Pre-Ship Checklist
- Signature verification uses
construct_event()with the raw request body, not a parsed JSON object - Webhook route is exempt from global JSON body parser middleware
-
STRIPE_WEBHOOK_SECRETis the endpoint-specific secret from the Stripe dashboard (not the API key) - Idempotency check runs before any database write or external call
- Handler returns HTTP 200 before processing starts
-
tolerance=300(or equivalent) is set to block replay attacks - All five core event types have explicit handler functions
- Failed processing does NOT mark the event as processed (so Stripe can retry)
- Stripe CLI
listencommand confirmed working locally before deploy - Webhook endpoint URL registered in Stripe Dashboard → Developers → Webhooks
Ask The Guild
What's the worst Stripe webhook bug you've ever shipped to production? Double charge? Missed fulfillment? A replay that gave someone a free subscription? Drop it in the thread — the weirder the better. Every story saves someone else from the same 2 a.m. refund session.