Webhooks: how we know they paid
The thank-you page trap
Kai's first Buy button for Hyper Shards worked like this: after paying on Stripe Checkout, the player is sent back to our success page, /thanks, and that page unlocks the game.
Remy spotted two problems. Anyone can type /thanks into the address bar and get the game free. And a player who pays, then closes the tab before the page loads, never gets what they paid for. A page in the browser can't prove a payment. We need Stripe itself to tell our server.
Webhooks: Stripe rings the doorbell
A webhook is a message one server sends to another the moment something happens. Instead of our server asking Stripe "paid yet?" over and over, Stripe rings the doorbell.
We give Stripe the address of our endpoint: a URL on our server, like a Firebase Cloud Function called stripeHook. When a Checkout payment finishes, Stripe sends it an event: a JSON message with an id like evt_1Q..., the type checkout.session.completed, and the details. These include client_reference_id, the player's Firebase user id, which our server attached when it created the checkout.
Proving it came from Stripe
Our endpoint is a public URL, so a cheater could send it a fake "payment completed" event. That is why Stripe signs every event.
Stripe and our server share a webhook secret (it starts with whsec_). Stripe mixes the secret with the exact bytes of the message and a timestamp to make a code called a signature, sent in the Stripe-Signature header. Our server repeats the maths. Without the secret, nobody can make a matching signature. The timestamp stops replays of old events: Stripe's library rejects signatures older than five minutes by default.
Bolt says: The signature covers the raw bytes. If our code rebuilds the JSON first, the spacing changes and even real events fail. That is why the code uses req.rawBody.
Read the code
1exports.stripeHook = onRequest(async (req, res) => {2 let event;3 try {4 event = stripe.webhooks.constructEvent(req.rawBody,5 req.headers['stripe-signature'], WEBHOOK_SECRET);6 } catch (err) {7 return res.status(400).send('Bad signature');8 }9 await unlock(event);10 res.sendStatus(200);11});
A cheater posts a fake "payment completed" event to this endpoint, with no valid signature. What happens?
- constructEvent throws an error, the server replies 400, and unlock never runs
- The server replies 200 and unlocks the game, because the event type looks right
- Stripe blocks the cheater before the message reaches us
Show the answer
constructEvent throws an error, the server replies 400, and unlock never runs
constructEvent checks the Stripe-Signature header against our webhook secret. A fake fails, so the catch block replies 400 and line 9 never runs. Stripe can't block it for us: the endpoint is public, so our check is the lock.
Unlock on the server
Once the signature checks out, the Cloud Function does the unlock itself:
- Is
event.typeequal tocheckout.session.completed, and is the session'spayment_statusequal topaid? - If so, write
{ hyperShards: true }toowners/{uid}in Firestore, using the user id fromclient_reference_id. - Reply
200quickly, so Stripe knows the event arrived.
Our Firestore security rules let players read their own owners document but never write it. Only the server can, so nobody can unlock the game from the browser.
When our server is down
Servers have bad days: a crash, a slow reply, an update in progress. If our endpoint does not answer with a 2xx success code, Stripe tries again later, waiting longer each time. In live mode it keeps retrying for up to three days, at the time of writing.
So a payment made during a one-hour outage is not lost. When our function is back, the event arrives and the player is unlocked. The Stripe dashboard lists failed deliveries, and we can resend one by hand.
Retries have a side effect: the same event can arrive more than once, and events can arrive out of order.
Quick check
If our webhook endpoint is down for an hour, the payments made in that hour are lost forever.
- True
- False
Show the answer
False
Stripe retries failed deliveries, waiting longer each time, for up to three days in live mode. When the endpoint is back, the events arrive and the players are unlocked.
Idempotency: twice is the same as once
An action is idempotent when doing it twice gives the same result as doing it once. Writing hyperShards: true is idempotent: true twice is still true.
Adding things is not. If a "Gift a copy" event arrives twice and we add a gift code each time, the buyer gets two for one payment. So professionals remember every event they handled. Before acting, check whether stripeEvents/{event.id} exists in Firestore. If it does, reply 200 and stop. If not, save it and do the work, both inside one transaction, so two copies arriving at the same moment can't both get through.
Bug hunt
1async function unlock(event) {2 const s = event.data.object;3 const uid = s.client_reference_id;4 // "Gift a copy" bought: add one gift code5 await db.doc('players/' + uid).update({6 giftCopies: FieldValue.increment(1),7 });8}
After a slow reply, Stripe sent the same "Gift a copy" event twice, and the buyer got two gift copies for one payment. What is the fix?
- Change increment(1) to increment(0.5)
- Before line 5, check stripeEvents/{event.id}: if it exists, stop; if not, save it, all in one transaction
- Ask Stripe to turn off retries for our account
Show the answer
Before line 5, check stripeEvents/{event.id}: if it exists, stop; if not, save it, all in one transaction
Halving would break normal single deliveries, and retries are what save us during outages. Remembering each event id makes the handler idempotent: the second copy is spotted and skipped.
Testing with the Stripe CLI
We never test webhooks with real money. The Stripe CLI is a command-line tool that connects Stripe's test mode to our own computer:
stripe loginlinks it to our Stripe account.stripe listen --forward-to localhost:5001/our-project/us-central1/stripeHookforwards test events to the Firebase emulator, and prints a test webhook secret (whsec_...) to use while testing.stripe trigger checkout.session.completedsends a test event, so we can watch the unlock happen.
Bolt says: Next lesson: selling worldwide, when a buyer pays in euros or yen and our bank account uses dollars.
Fact-checked 9 October 2026
- Stripe Docs: Receive Stripe events in your webhook endpoint docs.stripe.com
- Stripe Docs: Stripe CLI listen docs.stripe.com