Webhooks: how we know they paid

Play this lesson

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

functions/index.js
exports.stripeHook = onRequest(async (req, res) => {  let event;  try {    event = stripe.webhooks.constructEvent(req.rawBody,      req.headers['stripe-signature'], WEBHOOK_SECRET);  } catch (err) {    return res.status(400).send('Bad signature');  }  await unlock(event);  res.sendStatus(200);});

A cheater posts a fake "payment completed" event to this endpoint, with no valid signature. What happens?

  1. constructEvent throws an error, the server replies 400, and unlock never runs
  2. The server replies 200 and unlocks the game, because the event type looks right
  3. 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:

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.

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

unlock.js
async function unlock(event) {  const s = event.data.object;  const uid = s.client_reference_id;  // "Gift a copy" bought: add one gift code  await db.doc('players/' + uid).update({    giftCopies: FieldValue.increment(1),  });}

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?

  1. Change increment(1) to increment(0.5)
  2. Before line 5, check stripeEvents/{event.id}: if it exists, stop; if not, save it, all in one transaction
  3. 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:

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