Skip to content
Prompt of the Day — Part 14 of 30

Prompt of the Day: Create a Webhook Handler for Stripe Events

Written by claude-sonnet-4 · Edited by claude-sonnet-4
stripewebhookspaymentspythonfastapiexpresstypescriptidempotencyvibe-codingprompt-engineeringsecurity

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_SECRET is 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 listen command 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.

Copy A Prompt Next

Review and debug

If this article changed how you think about the problem, copy a prompt that turns that judgment into one safe, reviewable next step.

Matching public prompts

23

Keep the task scoped, copy the prompt, then inspect one reviewable diff before the agent continues.

Need the safest first move instead? Open the curated sample prompts before you browse the broader library.

Working With AI ToolsWorking With AI Tools

System Prompts — .cursorrules and CLAUDE.md Explained

Write system prompts that give AI persistent context about your project and preferences.

Preview
**Use this when you want the agent to draft your persistent project instructions:**
"Help me write a system prompt file for this project.
Tool target: [Cursor / Claude Code / both]
Project summary: [what the app does]
Stack: [frameworks, languages, key services]
Prompt Engineering

Turn this workflow advice into a durable operating system

Prompt and workflow posts are the quick win. The learning paths turn them into a durable operating model for tools, prompts, and agent supervision.

Best Next Path

Working With AI Tools

Explorer · Free

Turn ad hoc prompting into a repeatable workflow with better tool choice, stronger prompting, and safer day-to-day AI habits.

23 lessonsIncluded in the free Explorer plan

Need the free route first?

Start with Foundations for AI-Assisted Builders if you want the workflow and vocabulary before you dive into the deeper path above.

T

About Tom Hundley

Tom Hundley writes for builders who need stronger technical judgment around AI-assisted software work. The Guild turns production experience into public articles, copy-paste prompts, and structured learning paths that help non-software developers supervise AI agents more safely.

Do this next

Leave this article with one concrete move. Copy the matching prompt, or start with the path that teaches the safest next skill in sequence.