Prompt of the Day: Build a Tip Button Component with Stripe
Part 11 of 30 — Prompt of the Day Series
A friend of mine runs a small SaaS tool for freelance writers. Late last year he added a tip jar to his app. Simple idea: after a user exports a doc, a modal pops up with three tip amounts — $1, $3, $5 — and a "No thanks" button. He vibe-coded the whole thing in an afternoon.
Three weeks later he discovered his webhook handler had no idempotency guard. Stripe had retried the same event on slow responses, and a handful of users got charged twice. He caught it in the logs at 2 a.m. He refunded everyone, but the damage to trust was real. As developer and creator Theo Browne put it in January 2025, Stripe introduces a "split brain" into your codebase — the state of the purchase lives in Stripe, but you're expected to mirror it in your own database via webhooks. Over 258 event types, delivered out of order, none guaranteed exactly-once. A March 2026 post-mortem on Keelstack traced a recurring double-charge bug back to this exact pattern: no idempotency key on the webhook handler, Stripe retried on a slow response, two rows inserted.
The good news: the right prompt gets an AI assistant to avoid all of this from the start.
The Prompt
Build a React tip button component and a Node.js/Express API endpoint
for accepting tips via Stripe. Requirements:
1. UI: three preset tip buttons ($1, $3, $5) plus a custom amount input,
all inside a modal that opens after a user action (e.g. doc export).
2. On selection, call POST /api/create-tip-intent with { amountCents: number }.
3. Server: create a Stripe PaymentIntent for the selected amount, return
the clientSecret. Validate that amountCents is a positive integer >= 50
(Stripe minimum).
4. Client: use @stripe/react-stripe-js and the PaymentElement to collect
card details and confirm the payment.
5. Webhook: handle the payment_intent.succeeded event at POST /api/webhooks/stripe.
Use stripe.webhooks.constructEvent() to verify the signature. Guard against
duplicate processing by storing the Stripe event ID in a processed_events
table and checking before inserting — return 200 immediately if already seen.
6. Use environment variables STRIPE_SECRET_KEY, STRIPE_PUBLISHABLE_KEY, and
STRIPE_WEBHOOK_SECRET. Never hardcode keys.
7. Include a README section on running `stripe listen --forward-to localhost:3000/api/webhooks/stripe`
for local webhook testing.
Why It Works
This prompt is dense on purpose. Let's break down the decisions:
Requirement 3 — server-side amount validation is the most important line. If you let the client pass an arbitrary amountCents and your server trusts it without checking, a user can POST { amountCents: 1 } and get away with a sub-minimum charge — or worse, a negative one. Always re-validate on the server.
Requirement 5 — idempotency guard is the lesson from my friend's 2 a.m. refund session. Stripe's documentation states that PaymentIntents should be created exactly once per session, but it doesn't stop Stripe from retrying your webhook if your handler is slow. The processed_events table pattern (check → insert in a transaction) is the canonical fix.
Requirement 7 — stripe listen in the README addresses the #1 reason vibe-coded Stripe integrations fail in development: devs skip webhook testing entirely because setting up local tunnels feels complicated. The Stripe CLI makes it one command.
The prompt also produces clean environment variable hygiene by naming the three keys explicitly. AI assistants that aren't told the variable names will sometimes generate placeholder strings like 'sk_test_YOUR_KEY_HERE' directly in the code.
Here's what the idempotency guard looks like in practice:
// server/routes/webhooks.js
app.post('/api/webhooks/stripe', express.raw({ type: 'application/json' }), async (req, res) => {
const sig = req.headers['stripe-signature'];
let event;
try {
event = stripe.webhooks.constructEvent(req.body, sig, process.env.STRIPE_WEBHOOK_SECRET);
} catch (err) {
return res.status(400).send(`Webhook Error: ${err.message}`);
}
// Idempotency guard — return 200 if already processed
const alreadySeen = await db('processed_events').where({ stripe_event_id: event.id }).first();
if (alreadySeen) return res.status(200).send();
await db('processed_events').insert({ stripe_event_id: event.id, processed_at: new Date() });
if (event.type === 'payment_intent.succeeded') {
const intent = event.data.object;
await db('tips').insert({
stripe_payment_intent_id: intent.id,
amount_cents: intent.amount,
created_at: new Date(),
});
}
res.status(200).send();
});
And the minimal server-side PaymentIntent creation:
// server/routes/tips.js
app.post('/api/create-tip-intent', async (req, res) => {
const { amountCents } = req.body;
if (!Number.isInteger(amountCents) || amountCents < 50) {
return res.status(400).json({ error: 'amountCents must be an integer >= 50' });
}
const paymentIntent = await stripe.paymentIntents.create({
amount: amountCents,
currency: 'usd',
automatic_payment_methods: { enabled: true },
});
res.json({ clientSecret: paymentIntent.client_secret });
});
The Anti-Prompt
Here's the version that causes problems:
Add a Stripe tip button to my app. The user should be able to tip $1, $3, or $5.
Why it fails:
- No mention of server-side validation → the AI will trust client-passed amounts
- No mention of webhooks → the AI may skip them entirely, leaving you with no durable record of successful payments (the redirect-only pattern that breaks when users close the tab)
- No idempotency requirement → you get the double-charge bug
- No environment variable guidance → secret keys often end up hardcoded in the generated code
- No local testing instructions → the webhook code never gets tested before deploy
Vague prompts produce code that works in the happy path demo and fails silently in production. As Stripe's own developer guide from February 2025 notes, production-ready integrations require explicit handling of webhook retries, signature verification, and error states — none of which an AI will add unless you ask.
Variations
Pay-what-you-want (open amount only)
Replace the preset tip buttons with a single free-text input for a custom dollar
amount. Convert dollars to cents server-side. All other requirements from the
original prompt remain.
Stripe Payment Link (no-code version) If you just need a tip jar without custom UI, Stripe's Payment Links let you create a hosted payment page from the dashboard — no code required. Enable "customer adjusts quantity" to let donors set the amount. This is the right tool if you don't need the tip to be embedded in your app's UI.
Recurring tip / membership
Modify the tip button to offer a "Support monthly" toggle. When toggled on,
create a Stripe Subscription for a recurring $X/month instead of a
one-time PaymentIntent. Keep the same idempotency guard on the webhook,
but handle invoice.payment_succeeded instead of payment_intent.succeeded.
Adding tip to an existing checkout
If you already have a Stripe Checkout session, you can add a tip as a second line item rather than a separate flow. Pass submit_type: 'donate' on the session to change the button label to "Donate" — a small UX detail that significantly improves conversion on tip-style payments per Stripe's charity payment processing guide.
Your Checklist
- Amount validation runs on the server, not just the client
-
STRIPE_SECRET_KEY,STRIPE_PUBLISHABLE_KEY, andSTRIPE_WEBHOOK_SECRETare all in.env, not in source code - Webhook handler calls
stripe.webhooks.constructEvent()with the raw request body (not parsed JSON) - A
processed_eventstable (or equivalent) guards against duplicate webhook processing - You have run
stripe listen --forward-to localhost:3000/api/webhooks/stripeand confirmed the webhook fires during local testing - The UI handles payment errors gracefully (card declined, insufficient funds) — don't just log them
- You have tested with Stripe's test card
4242 4242 4242 4242before going live
Ask The Guild
Have you built a tip or donation flow with Stripe? What was the hairiest bug you hit — webhook retry, amount validation, or something else entirely? Drop your war story (or your cleanest solution) in the Guild community thread. Bonus points if you share the prompt you used to generate the initial code.