What you learn
- What webhooks are, why polling is the wrong instinct, and how to verify webhook signatures
- Idempotency, proration on mid-cycle upgrades, and coupons versus promotion codes
- Embedded checkout and the webhook gotchas that catch every beginner
Summary
In Part 1 you took a payment. But here is the uncomfortable truth: after redirecting a customer to Checkout, your app does not actually know what happened to them. Did they pay? Did the card fail later? Did they cancel next month? Guessing is how people accidentally give away the product for free or keep charging cancelled customers. The answer is webhooks: Stripe calls your app whenever something happens, so your app reacts to reality instead of guessing. This lesson makes your billing trustworthy: verified webhooks, idempotency, proration, coupons, embedded checkout, and the gotchas that bite everyone the first time.
What you will learn
You will learn what a webhook is and why polling Stripe is the wrong instinct, how to verify a webhook signature so attackers cannot fake events, how idempotency stops you double-processing a duplicated event, how proration fairly handles a mid-cycle plan change, the difference between coupons and promotion codes, how embedded checkout works, and the webhook gotchas that cause the classic "it worked in test but broke in production" failure.
Prerequisites
Stripe Part 1 and a working subscription Checkout, plus a backend that can receive HTTP requests (your Convex actions or your framework's server routes), because a webhook is just Stripe sending a request to your backend. The secrets lesson, too, since the webhook signing secret is one more key to guard.
An API is a way for two programs to talk to each other. Learn what an API is, how it works, and why it matters for building with AI.
A .env file stores secrets like API keys outside your code so they never get published. Learn what it is, how it works and how to keep it safe.
JSON, YAML and Markdown are three plain-text formats you will meet constantly. Learn what each is for and how to read them at a glance.
The problem
The naive instinct after Checkout is to poll: every few seconds, ask Stripe "did they pay yet?" This is wrong on every axis. It wastes requests, it is slow, it misses events that happen later (a renewal next month, a card that fails in 30 days, a cancellation), and it does not scale. Worse, relying on the success-URL redirect alone is unreliable - a customer can pay and then close the tab before the redirect fires, and now they paid but your app never recorded it. The right model is the reverse: do not ask, be told. Stripe pushes an event to your backend the instant something happens, and your app reacts. That is a webhook, and getting it right is what makes billing trustworthy.
What webhooks are and verifying signatures
A webhook is Stripe making an HTTP request to a URL you own whenever an event occurs: a payment succeeded, a subscription renewed, a card failed, a subscription was cancelled. Your backend listens at that URL and updates your database in response. But there is a catch: anyone on the internet could send a fake request to that URL pretending to be Stripe, trying to trick your app into granting free access. So you must verify that each request genuinely came from Stripe. Stripe signs every webhook with a secret (the webhook signing secret, starting with whsec_), and you verify that signature on every request. If verification fails, you reject the request. Never trust an unverified webhook. Here is the verification, which is non-negotiable.
// Webhook handler (backend). Verify the signature BEFORE trusting anything.
import Stripe from 'stripe'
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!)
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET! // whsec_xxx
export async function handleStripeWebhook(req: Request) {
const signature = req.headers.get('stripe-signature')!
const rawBody = await req.text() // MUST be the raw body, not parsed JSON
let event: Stripe.Event
try {
// Throws if the signature is invalid or the body was altered.
event = await stripe.webhooks.constructEventAsync(rawBody, signature, webhookSecret)
} catch {
return new Response('Invalid signature', { status: 400 })
}
switch (event.type) {
case 'checkout.session.completed':
// Grant access / mark the user as subscribed in your database.
break
case 'customer.subscription.deleted':
// Revoke access - they cancelled.
break
case 'invoice.payment_failed':
// Warn the user / start a dunning flow.
break
}
return new Response('ok', { status: 200 })
}One detail that breaks people: you must verify against the raw request body, exactly as Stripe sent it. If your framework parses the body into JSON before you verify, the signature will not match and every webhook will fail. Read the raw body first, verify, then parse.
Idempotency: handle duplicates safely
Stripe guarantees it will deliver each event at least once, which means it might deliver the same event more than once - on a retry after a timeout, or a network hiccup. If your handler grants a month of free credits or sends a welcome email every time it sees checkout.session.completed, a duplicate delivery double-grants or double-emails. The fix is idempotency: design your handler so processing the same event twice has the same effect as processing it once. The simplest reliable approach is to record each event ID you have processed and skip it if you see it again. Stripe also recommends responding quickly with a 200 and doing slow work afterward, so Stripe does not time out and retry unnecessarily.
- Stripe delivers each event at least once, so duplicates happen - design for it.
- Store processed event IDs; if you have seen one before, acknowledge it and skip the work.
- Make actions safe to repeat: "set subscribed = true" is naturally idempotent; "add one month" is not.
- Respond 200 fast; if you are slow or error, Stripe retries, which causes more duplicates.
Proration on mid-cycle upgrades
A customer on the monthly plan upgrades to the yearly plan, or moves from a cheaper tier to a pricier one, halfway through their billing period. What should they pay? Proration is Stripe computing the fair amount: it credits the unused portion of what they already paid and charges the difference for the new plan for the rest of the period. You do not calculate this yourself - you tell Stripe to update the subscription to the new price, and Stripe figures out the prorated charge or credit. Your job is to decide the behaviour (charge the difference immediately, or apply it to the next invoice) and to update access in your app when the webhook confirms the change. The mistake to avoid is computing prorated amounts by hand; let Stripe do the arithmetic and react to the resulting events.
- Upgrade mid-cycle: Stripe credits the unused time and charges the prorated difference.
- You update the subscription to the new price ID; Stripe computes the money.
- Choose whether the proration is charged now or added to the next invoice.
- React to the webhook (subscription updated, invoice paid) to sync access in your app.
Coupons and promotion codes
These two are related but not the same, and the distinction matters. A coupon is the underlying discount rule: "20 percent off" or "10 USD off, once". A promotion code is a customer-facing code (like LAUNCH20) that applies a coupon. You create a coupon, then create one or more promotion codes that point at it. The reason for two layers is flexibility: one coupon ("20 percent off") can back several codes with different restrictions (expiry, usage limit, first-time customers only). In Checkout you can enable a promotion-code field so customers type a code themselves, or you can apply a coupon directly to a session in code for an automatic discount. Use promotion codes for marketing campaigns you want customers to enter, and direct coupon application for discounts you grant programmatically.
- Coupon: the discount rule itself (percent off, fixed amount, duration).
- Promotion code: a customer-facing code (LAUNCH20) that applies a coupon.
- One coupon can back many promotion codes with different limits and expiries.
- Enable the promotion-code field in Checkout for customer-entered codes, or apply a coupon in code for automatic discounts.
Embedded checkout
Part 1 mentioned embedded Checkout; here is where it earns its place. Embedded Checkout renders Stripe's secure payment flow inside your own page instead of redirecting to a Stripe-hosted URL, so the customer stays on your domain the whole time. This usually lifts conversion (every redirect is a chance to lose someone) and feels more like part of your product. The mechanics: your backend creates a Checkout Session in embedded ui_mode and returns a client secret, and your frontend mounts Stripe's embedded component with that secret. The card is still collected entirely by Stripe, so you keep all the security and compliance benefits while owning the experience. Reach for embedded once your hosted flow works and you want to tighten conversion.
// Backend: create an EMBEDDED checkout session and return its client secret.
const session = await stripe.checkout.sessions.create({
ui_mode: 'embedded', // embedded, not a redirect
mode: 'subscription',
line_items: [{ price: priceId, quantity: 1 }],
return_url: 'https://app.yoursite.com/welcome?session_id={CHECKOUT_SESSION_ID}',
})
// Send session.client_secret to the frontend, which mounts Stripe's embedded UI.Common webhook gotchas
These are the specific things that make webhooks "work in test but break in production", collected so you can avoid each one. Most production billing bugs trace back to one of these.
- Parsing the body before verifying: the signature check needs the raw body. Read raw first, verify, then parse.
- Using the wrong webhook secret: test and live each have a different whsec_, and the Stripe CLI gives a third one for local forwarding. Match the secret to the environment.
- Not handling duplicates: without idempotency, a retried event double-processes. Record processed event IDs.
- Trusting the success redirect instead of the webhook: the user can close the tab before the redirect, so grant access from the webhook, not the redirect.
- Slow or failing handlers: return 200 fast. If you take too long or error, Stripe retries, multiplying duplicates.
- Forgetting to register the production webhook endpoint: the test endpoint does not carry over to live mode. Add the live URL and its secret when you go live.
# Test webhooks locally with the Stripe CLI - it forwards live events to your machine
# and prints a whsec_ secret to use for local verification.
stripe login
stripe listen --forward-to localhost:5296/api/stripe/webhook
# In another terminal, trigger a fake event to test your handler:
stripe trigger checkout.session.completedTypical mistakes
Beyond the gotcha list: polling Stripe instead of using webhooks; trusting an unverified webhook and letting an attacker fake a "payment succeeded" event to get free access; computing proration by hand instead of letting Stripe do it; confusing coupons and promotion codes; and shipping embedded checkout without first getting the hosted flow and webhooks solid. The throughline: be told, do not ask; verify everything; let Stripe do the money maths; and treat duplicates as inevitable.
Business ROI
This lesson is the difference between billing that quietly loses you money and billing you can trust. Without verified webhooks, you either give away access you were never paid for or keep charging people who cancelled - both are direct revenue and reputation damage. Idempotency prevents the embarrassing double-charge or double-grant. Proration done by Stripe keeps upgrades fair, which removes friction from the most valuable action a customer can take: spending more. And embedded checkout plus promotion codes are direct conversion levers. Spending a day to get webhooks right protects every dollar that flows through your product from here on.
Checklist
Your billing is production-grade when all of these hold.
- You verify every webhook signature against the raw body before acting on it.
- Your handler is idempotent - a duplicated event causes no double-processing.
- You grant and revoke access from webhooks, not from the success redirect.
- Upgrades use Stripe proration, and you have a working promotion code and an embedded or hosted flow live.
Resources
The Stripe webhooks docs, the events reference, and the Stripe CLI are your core tools here - the CLI especially makes local webhook development painless. Keep your test and live webhook signing secrets clearly labelled so you never cross them. Next, the final lesson takes the whole stack live: dev-to-prod migration for Clerk and Convex, DNS, Cloudflare, Search Console and performance.
Your task
Install the Stripe CLI, run stripe listen to forward events to your local backend, and build a webhook handler that verifies the signature and grants access on checkout.session.completed. Trigger a duplicate of the same event and confirm your handler does not double-process it. Then enable a promotion-code field in your Checkout and test a coupon. You now have billing that reacts to reality instead of guessing.
Next lesson
Your product takes payments reliably. The final lesson of the course takes everything live: migrating Clerk and Convex from dev to prod, wiring DNS and Cloudflare, verifying your site in Google Search Console and submitting your sitemap, and getting your Lighthouse scores into the green so the live product is fast and discoverable.

Comments
Loading comments.
Post a comment