Skip to the content

Module 1: How a card payment works

Module 2: Payment processors, and why you never touch card numbers

Module 3: Money in code

Module 4: Your Stripe account: test mode, the Dashboard and keys

Module 5: Your first API call with the Node library

Module 6: Products and prices

Module 7: Checkout: Stripe’s payment page

Module 8: Success pages, cancel pages and what to trust

Module 9: Webhooks from zero

Module 10: The Stripe CLI and testing

Module 11: Retries, duplicates and idempotent handlers

Module 12: Payment Intents and Elements

Module 13: Customers and subscriptions

Module 14: Trials, plan changes and the customer portal

Module 15: Invoices and failed payments

Module 16: Refunds, disputes and taxes

Module 17: Errors, retries and idempotency keys

Module 18: Syncing state and reconciling money

Module 19: Fraud, security and going live

Module 20: Mastery challenge

Answer key

  1. 1. How a card payment works · Check: who decides? · QuizA customer’s card is declined at the bakery’s checkout. Who made that decision?C. The issuer, the customer’s own bankThe issuer holds the cardholder’s money, so it decides: enough funds, card not reported stolen, nothing suspicious. The network only carries the question and the answer between the banks. The bakery never sees why in detail, which is why a declined buyer is usually told to try another card or call their bank.
  2. 1. How a card payment works · Practise: the life of a payment · Put in orderPut the steps of a weekend loaf order in order.1. The buyer types their card details and presses Pay 2. The request travels through the acquirer and the network to the issuer 3. The issuer approves and holds the amount 4. The bakery captures the payment 5. The money settles and is paid out to the bakery’s bankNothing moves until the issuer approves. Capture comes after authorisation, never before, and the money reaches the bakery last: authorisation takes a second or two, settlement and payout take days.
  3. 1. How a card payment works · Payments cost a fee, and some fail · QuizA buyer pays €30.00 for a month of bread. What reaches the bakery’s bank account?B. A little less than €30.00, after feesThe processing fee is taken from each payment, so the payout is the amount minus fees. Later modules show where to read the exact fee for each payment, which matters when the bakery’s accounts have to add up.
  4. 2. Payment processors, and why you never touch card numbers · Check: who sees the card? · QuizThe bakery uses Stripe’s hosted payment page. Which of these ever receives the buyer’s full card number?C. Only Stripe (and the banks behind it)The buyer types the card on a page Stripe serves, so the number goes straight from their browser to Stripe. The bakery’s server learns that a payment happened and gets ids to refer to it, which keeps its PCI work to a short self-assessment form instead of audits.
  5. 2. Payment processors, and why you never touch card numbers · Practise: read an id · QuizA support email mentions sub_.... What should the bakery look for in its Stripe account?B. A subscriptionsub_ is a Subscription. Learning the prefixes pays off quickly: logs, emails and error messages are full of ids, and the prefix tells you where to look before you open anything. A customer is cus_, a price price_, a refund re_.
  6. 2. Payment processors, and why you never touch card numbers · Practise: read an id · QuizWhich of these is safe to store in the bakery’s own database?C. The Stripe customer id cus_...Stripe ids are references, not secrets about the card: storing cus_... lets you find the customer in Stripe later. Card numbers and security codes must never be stored by you; the security code may not be stored by anyone after authorisation.
  7. 3. Money in code · Practise: amounts · QuizTwo rye loaves at $7.25 each. What amount does Stripe expect?C. 1450$7.25 is 725 cents, and two of them are 1,450 cents, so 1450. 14.5 is a decimal, which Stripe rejects for amounts; 145 would charge $1.45.
  8. 3. Money in code · Practise: amounts · Fill the blanksTotal a cart in cents: three croissants at 350 each and one loaf at 850.1: 350, 2: 850, 3: 1900Everything stays in cents: 3 × 350 is 1,050, plus 850 is 1,900, which is $19.00. No decimals appear until you show the total.
  9. 3. Money in code · Currencies have codes, and some have no cents · QuizThe bakery sells a ¥900 loaf in Japan. What does it send to Stripe?B. 900The yen is zero-decimal: there’s no smaller unit, so ¥900 is 900. Multiplying by 100 out of habit would charge ¥90,000. Code that converts amounts should look up how many decimals a currency has, not assume two.
  10. 3. Money in code · Round once, on purpose · Find the bugStripe rejects this request with “Invalid integer”. Which line causes it?Line 3: const price = Math.round(loaf * (1 - discount))850 × 0.85 is 722.5, a fraction of a cent, and unit_amount must be a whole number. Rounding once where the fraction appears, with Math.round, gives 723. Don’t worry about the stripe.prices.create call yet: it’s how a price is made, in module 6.
  11. 3. Money in code · Try it: total a cart in cents · Code challengeWrite cartTotal(lines, percentOff). Each line has a unitAmount in cents and a quantity. Add them up, then take off the discount: percentOff percent of the subtotal, rounded to the nearest cent with Math.round.One solution:
    Solution
    type Line = { unitAmount: number; quantity: number }
    function cartTotal(lines: Line[], percentOff = 0): number {  let subtotal = 0  for (const line of lines) subtotal += line.unitAmount * line.quantity  const discount = Math.round((subtotal * percentOff) / 100)  return subtotal - discount}
    Prices times quantities stay whole numbers, so the subtotal is exact. Only the percentage can make a fraction (127.5 cents), so that is the one place to round, once. Rounding in the middle of a sum, or converting to dollars and back, is how carts end up a cent off from the receipt.
  12. 4. Your Stripe account: test mode, the Dashboard and keys · Check: which key goes where? · QuizWhich key is safe to include in the bakery’s web page, where every visitor can read it?B. pk_live_...Only publishable keys (pk_) are meant to be public: they can do very little on their own. Secret (sk_) and restricted (rk_) keys act on the account and must stay on the server.
  13. 4. Your Stripe account: test mode, the Dashboard and keys · Check: which key goes where? · QuizYou made a “Sourdough” price in test mode. Your live server asks Stripe for it by its id. What happens?B. Stripe says there is no such priceA live key can’t see test objects at all, so the request fails as if the id didn’t exist. That is a common surprise on launch day; a later module shows how to avoid hard-coding ids that differ between environments.
  14. 4. Your Stripe account: test mode, the Dashboard and keys · Test cards make every outcome happen on purpose · QuizYou want to see what the bakery’s checkout does when a card is declined. What do you do?B. Pay in test mode with 4000 0000 0000 0002Test cards make each outcome repeatable without risk. Real cards don’t work in test mode at all, and testing in live mode costs real fees and confuses your accounts.
  15. 5. Your first API call with the Node library · The library mirrors the API · Fill the blanksRead back a customer you made earlier, by its id.1: await, 2: customers, 3: retrieveReading one object by id is retrieve. Without await, sam would be a Promise rather than the customer, and sam.email would be undefined.
  16. 5. Your first API call with the Node library · The library mirrors the API · QuizWhich call changes Sam’s email?B. stripe.customers.update(customerId, { email: 'new@example.com' })update takes the id of the object to change and the fields to set. create would make a new customer instead; setting a property on the client changes nothing at Stripe, because only requests do.
  17. 5. Your first API call with the Node library · Read the error: it usually says what’s wrong · Find the bugStripe answers “Received unknown parameter: emailAddress”. Which line is wrong?Line 3: email: 'sam@example.com'The field is called email. Stripe rejects names it doesn’t know rather than ignoring them, and the message names the culprit. In a TypeScript editor, the library’s types underline this before you even run it.
  18. 6. Products and prices · A recurring price bills again every period · QuizThe bakery adds a box delivered every three months for $80. Which price matches?B. unit_amount: 8000, recurring: { interval: 'month', interval_count: 3 }$80 is 8000 cents, and “every three months” is a month interval with a count of 3. There is no quarter interval. Charging a third each month is a different product: the buyer would pay monthly, and the total wouldn’t come to exactly $80.
  19. 6. Products and prices · A price’s amount never changes: make a new one · Fill the blanksRaise the loaf to $9.00: make a new price, then archive the old one.1: create, 2: 900, 3: activeNew amount, new price: create with 900 cents. Archiving with active: false keeps history intact. Existing subscribers stay on their old price until you move them, which a later module covers.
  20. 6. Products and prices · Or describe the price inside each checkout · QuizThe bakery’s cake orders are priced by size and decoration, worked out on its server. Which suits them?B. Inline price_data, with the amount the server worked outWhen the amount is computed per order, describe it inline in the checkout. Making a catalogue price for every combination would fill the Dashboard with prices used once. And the amount must come from the server, never the browser, as the next module explains.
  21. 6. Products and prices · Metadata: your own labels on Stripe’s objects · QuizWhich value can go in metadata as it is?B. { weight: '800' } (a string)Metadata values are strings of up to 500 characters. (The Node library also takes a number, but Stripe stores it as text and gives back '800'.) Keep objects, lists and long notes in your own database, storing only their id in metadata.
  22. 7. Checkout: Stripe’s payment page · The browser says what; the server says how much · Find the bugSomeone bought ten loaves for one cent each. Which line let them?Line 9: unit_amount: loaf.unitAmount,The amount came from a form field, which the buyer edited to 1. Look the item up on the server (line 1 does) and take its amount from there. Check the quantity too: a whole number between 1 and a sensible limit.
  23. 7. Checkout: Stripe’s payment page · Line items: a catalogue price or an inline one · Fill the blanksSell three croissants from the catalogue.1: payment, 2: price, 3: quantityA one-off purchase is payment mode, and a catalogue line item is a price id with a quantity. Stripe multiplies and shows the total on its page.
  24. 7. Checkout: Stripe’s payment page · Send the buyer with a 303 redirect · QuizCould the bakery create one session at start-up and send every buyer to its url?B. No: a session is for one purchase, and once paid or expired it can’t be used againEach session is one purchase by one buyer: once completed, it’s done, and an unused session expires after 24 hours by default. Create a new one every time someone presses the checkout button.
  25. 7. Checkout: Stripe’s payment page · Options worth knowing · QuizThe bakery delivers only in the US and Canada. Which option collects a delivery address?A. shipping_address_collection: { allowed_countries: ['US', 'CA'] }shipping_address_collection adds an address form limited to the countries listed (two-letter country codes, in capitals). The address then appears on the completed session for the bakery to deliver to.
  26. 8. Success pages, cancel pages and what to trust · {CHECKOUT_SESSION_ID} is filled in by Stripe · QuizIn TypeScript, the success_url is written as a template literal. Why doesn’t this break it?B. Only ${…} is code in a template literal; plain {…} is ordinary textA template literal (text in backticks) runs code only inside ${…}. {CHECKOUT_SESSION_ID} without a $ stays as written and reaches Stripe, which swaps it for the id. Writing ${CHECKOUT_SESSION_ID} by mistake would look for a variable of that name.
  27. 8. Success pages, cancel pages and what to trust · Visiting the success page must grant nothing · QuizWhere should the bakery mark an order as paid and send it to the kitchen?C. When Stripe notifies the server that the payment succeededCreating a session is not a payment, and the success page may never be visited (or be visited by anyone). Stripe’s notification, a webhook, arrives whether or not the buyer comes back. The success page can also check and record, as long as running both is harmless, a pattern for a later module.
  28. 8. Success pages, cancel pages and what to trust · Two fields tell you where a session stands · QuizA session is complete but unpaid. What should the bakery do?B. Wait: the payment is still clearing, and Stripe will say when it succeeds or failsWith delayed methods, like some bank debits, the buyer finishes the page before the money arrives. Stripe sends a later event when it succeeds or fails. Fulfilling on complete alone would hand out bread that might never be paid for.
  29. 8. Success pages, cancel pages and what to trust · Tie the session to your own order · Fill the blanksLabel the session and its payment with the bakery’s order id.1: client_reference_id, 2: metadataclient_reference_id is a single string meant exactly for your own id, and metadata can hold several labels. Copying the label to payment_intent_data.metadata means a refund or dispute, which arrives about the payment and not the session, can still be traced to the order.
  30. 8. Success pages, cancel pages and what to trust · The cancel page keeps the cart · Find the bugSome buyers who paid have their orders marked “cancelled”. Which line causes it?Line 3: // show the cart; leave the order as it isVisiting the cart says nothing about payment: a buyer can pay in one tab and open the cart in another, or come back with the browser’s back button after paying. Let Stripe’s events decide an order’s fate; a page visit only shows things.
  31. 9. Webhooks from zero · Check: who tells the bakery? · QuizA buyer pays for a weekend order and their phone dies before the success page loads. How does the bakery find out?B. The checkout.session.completed webhookStripe sends the event whatever happens to the browser, and keeps trying until your server confirms it received it. The success page is a courtesy for the buyer; the webhook is how your system learns the truth.
  32. 9. Webhooks from zero · Verify the exact bytes Stripe sent · Find the bugEvery real event is rejected with “Bad signature”. Which line is the cause?Line 2: app.use('/api', express.json())express.json() runs first for every route, reads the body and turns it into an object. By the time the webhook route runs, the raw bytes are gone, and re-creating them never matches the signature. Apply the JSON parser only to routes that need it, and keep the webhook raw.
  33. 9. Webhooks from zero · Try it: compute a Stripe signature · Code challengeFinish sign(secret, t, payload): import secret as an HMAC SHA-256 key, sign the text made of t, a dot and payload (1767225600.{…}) with it, and return the result as hex with the hex helper.One solution:
    Solution
    const bytes = (text: string) => new TextEncoder().encode(text)const hex = (buffer: ArrayBuffer) =>  [...new Uint8Array(buffer)]    .map((byte) => byte.toString(16).padStart(2, '0'))    .join('')
    async function sign(secret: string, t: number, payload: string) {  const key = await crypto.subtle.importKey(    'raw',    bytes(secret),    { name: 'HMAC', hash: 'SHA-256' },    false,    ['sign']  )  const signed = bytes(`${t}.${payload}`)  return hex(await crypto.subtle.sign('HMAC', key, signed))}
    The timestamp is inside the signed text, so it cannot be changed without breaking the signature: that is what makes the five-minute window enforceable. The key is the whole secret string, whsec_ prefix included. Signing the parsed-and-rebuilt JSON instead of the exact text would give a different code, which is the raw-body bug from the last slide.
  34. 9. Webhooks from zero · Try it: verify a delivery · Code challengeFinish verify(payload, header, secret, now) (times in seconds). Read t and every v1 from the header. Return false if t is missing or more than 300 seconds before now; otherwise true when any v1 equals sign(secret, t, payload).One solution:
    Solution
    async function sign(secret: string, t: number, payload: string) {  const bytes = (text: string) => new TextEncoder().encode(text)  const hmac = { name: 'HMAC', hash: 'SHA-256' }  const key = await crypto.subtle    .importKey('raw', bytes(secret), hmac, false, ['sign'])  const mac = await crypto.subtle    .sign('HMAC', key, bytes(`${t}.${payload}`))  return [...new Uint8Array(mac)]    .map((byte) => byte.toString(16).padStart(2, '0'))    .join('')}
    async function verify(  payload: string, header: string, secret: string, now: number): Promise<boolean> {  const pairs = header.split(',').map((part) => part.split('='))  const t = Number(pairs.find(([key]) => key === 't')?.[1])  if (!Number.isInteger(t) || now - t > 300) return false  const expected = await sign(secret, t, payload)  return pairs.some(([k, v]) => k === 'v1' && v === expected)}
    Splitting on , then = gives the pairs; some accepts the delivery if any v1 matches, which keeps working during a secret rollover. The age check uses the signed t, so an attacker who replays a recorded delivery an hour later is refused even though its signature is real. One thing the library does better: it compares signatures in constant time, so the comparison’s speed leaks nothing.
  35. 9. Webhooks from zero · Handle each event type you care about · Fill the blanksAdd a case for a delayed payment that failed, so the order is released.1: async_payment_failed, 2: data, 3: objectDelayed payments end with async_payment_succeeded or async_payment_failed. The session is always at event.data.object. Without this case, a failed bank payment would leave the order waiting forever.
  36. 9. Webhooks from zero · Answer quickly, with the right status · QuizAn event type the bakery doesn’t act on arrives. What should the endpoint answer?A. 200, and do nothingThe delivery worked; there is just nothing to do. An error would make Stripe retry an event you will never handle. Better still, only subscribe the endpoint to events you handle.
  37. 10. The Stripe CLI and testing · stripe listen forwards events to your laptop · QuizLocally, every forwarded event fails with “Bad signature”. What’s the likely cause?B. The server uses the Dashboard endpoint’s secret, not the one stripe listen printedEach endpoint has its own signing secret, and stripe listen acts as a separate endpoint with its own. Put the whsec_... it prints into your local .env. A wrong port would show as a connection error in the CLI, not a bad signature.
  38. 10. The Stripe CLI and testing · Practise: a whole purchase on your laptop · Put in orderPut a local test purchase in order.1. Start your server and stripe listen --forward-to its webhook route 2. Put the printed whsec_... in .env and restart the server 3. Add a loaf to the cart and press Checkout 4. Pay on Stripe’s test page with 4242 4242 4242 4242 5. See checkout.session.completed arrive and the order marked paidThe listener must be running before the payment: it only forwards events that happen while it runs. The server needs the listener’s secret loaded to verify what it forwards.
  39. 10. The Stripe CLI and testing · Test the edges, not only the happy path · QuizWhich tests are worth writing for the bakery’s webhook route? Pick all that apply.A. A correctly signed paid event marks the order paid; B. An unsigned or wrongly signed request gets a 400 and changes nothing; C. The same event delivered twice gives one paid order, not two; D. An event for an order the bakery doesn’t know is ignored with a 200All four. The happy path matters, but payments fail at the edges: forged requests, duplicates (which Stripe does send) and events for things you didn’t make. The next module is about making duplicates harmless.
  40. 10. The Stripe CLI and testing · Test the edges, not only the happy path · Fill the blanksOn a runtime with only asynchronous crypto, the helper has an async twin.1: await, 2: generateTestHeaderStringAsyncLike constructEventAsync, generateTestHeaderStringAsync returns a Promise, so it needs await. Use it in tests that run in Workers or other web runtimes.
  41. 10. The Stripe CLI and testing · The Dashboard shows every delivery · QuizA test order stays “pending” though the payment succeeded. Where do you look first?B. The event’s page in the Dashboard: was it delivered, and what did your server answer?The event page tells you at once whether Stripe sent it, to which address, and whether your server answered 200, 400 (often a secret mix-up) or 500 (a bug in your handler). It turns guessing into reading.
  42. 11. Retries, duplicates and idempotent handlers · Spot the race · Find the bugNow and then a buyer gets two receipts for one order. Which line opens the gap?Line 3: // update only if pending, in one statement, and stop if nothing changedTwo deliveries arriving together both read pending at line 2, both pass the check at line 3, and both send a receipt. This is a race condition: the result depends on timing. Checking and changing in one conditional statement, as on the previous slide, closes the gap.
  43. 11. Retries, duplicates and idempotent handlers · Try it: a handler that is safe to repeat · Code challengeMake markPaid idempotent: only an order that is pending becomes paid and gets a receipt. Then add a case for checkout.session.async_payment_succeeded that marks its order paid too.One solution:
    Solution
    type Session = { client_reference_id: string; payment_status: string }type Event = { id: string; type: string; data: { object: Session } }
    const orders = new Map<string, 'pending' | 'paid'>()const receipts: string[] = []
    function markPaid(orderId: string) {  if (orders.get(orderId) !== 'pending') return  orders.set(orderId, 'paid')  receipts.push(orderId)}
    function handle(event: Event) {  const session = event.data.object  switch (event.type) {    case 'checkout.session.completed':      if (session.payment_status === 'paid') {        markPaid(session.client_reference_id)      }      break    case 'checkout.session.async_payment_succeeded':      markPaid(session.client_reference_id)      break  }}
    The guard asks the order, not the event, whether this is the first time. That one check covers both repeats of one event and two different events about the same purchase. The starter’s version sent a receipt every time it was called, which is exactly what a retried delivery does to a handler that trusts it is seeing an event for the first time.
  44. 11. Retries, duplicates and idempotent handlers · Record first, answer fast, do slow work later · Put in orderPut the webhook handler’s steps in order.1. Read the raw body and the Stripe-Signature header 2. Verify with constructEvent; answer 400 if it fails 3. Check the event is about one of your orders 4. Update the order in one idempotent statement 5. Queue the slow work, keyed by the order id 6. Answer 200Nothing is trusted before the signature check, nothing changes before you know the order is yours, and the answer comes last, so a failure before it makes Stripe try again. Keying queued jobs by the order means a duplicate can’t queue the same work twice.
  45. 11. Retries, duplicates and idempotent handlers · Delayed payments: two events, one order · QuizA checkout.session.completed event arrives with payment_status: 'unpaid'. What should the handler do?B. Mark it “awaiting payment”, and wait for the async eventThe buyer finished, but the money hasn’t cleared. Note it so you can tell the buyer, and let async_payment_succeeded or async_payment_failed decide. Answering 500 would only make Stripe retry an event that will never change: events are snapshots.
  46. 11. Retries, duplicates and idempotent handlers · One fulfilment function, called from two places · QuizWhy does fulfil retrieve the session instead of trusting what it was given?B. On the success page, the only input is an id from the address bar; Stripe’s copy is the truthAn id from a URL could be anything, so asking Stripe for the session means only a really paid session counts. It also gives the latest state rather than an event’s older snapshot. Because markPaid is idempotent, calling fulfil from both places is safe.
  47. 12. Payment Intents and Elements · A Payment Intent moves through a few statuses · QuizA buyer’s card is declined. What status is the Payment Intent in afterwards?B. requires_payment_methodA decline doesn’t end the payment: it goes back to waiting for a method, so the buyer can try another card. The decline is recorded in last_payment_error. Only an explicit cancel, or abandonment in some flows, makes it canceled.
  48. 12. Payment Intents and Elements · Guard the client secret · QuizconfirmPayment reported no error and the buyer reached /thanks. Is the order paid?B. Not necessarily: check the Payment Intent on the server, and fulfil from the payment_intent.succeeded webhookThe browser is under the buyer’s control, and some methods are still processing when the buyer returns. As with Checkout, fulfil from a verified webhook (payment_intent.succeeded) or a server-side read that says succeeded.
  49. 12. Payment Intents and Elements · Some banks ask the buyer to prove it’s them · QuizHow do you see the 3D Secure step in test mode?B. Pay with the test card 4000 0025 0000 3155That test card always asks for authentication, and Stripe shows a test page to approve or fail it. Testing both answers matters: a buyer who fails the check should be able to try again.
  50. 12. Payment Intents and Elements · Authorise now, capture later · Fill the blanksHold the cake’s price now, and take it once the order is accepted.1: manual, 2: capturemanual capture authorises only. paymentIntents.capture(id) takes the money; you can also capture less than was held with amount_to_capture. To release the hold instead, paymentIntents.cancel(id).
  51. 13. Customers and subscriptions · A Customer remembers a buyer across payments · QuizWhich bakery buyers need a Stripe Customer?C. Subscribers always; one-off buyers if you want their history or saved cardsA subscription can’t exist without a customer to bill. For one-off orders it’s a choice: a customer lets returning buyers see past orders and reuse a card, at the cost of keeping one more record in step.
  52. 13. Customers and subscriptions · One Stripe customer per person in your system · Find the bugA regular buyer has twelve customers in Stripe. Which line is to blame?Line 2: if (user.stripeCustomerId) return user.stripeCustomerIdThe function creates a new customer every time. Return the stored id when there is one, and create only when there isn’t. (Two requests at once could still both create one; keying the create with an idempotency key, in module 17, closes that too.)
  53. 13. Customers and subscriptions · Sell the bread club with Checkout · Fill the blanksFinish the subscription checkout.1: subscription, 2: subscription_dataA recurring price needs subscription mode; payment mode would refuse it. Metadata in subscription_data lands on the Subscription itself, which later events are about, rather than only on the session.
  54. 13. Customers and subscriptions · A subscription’s life, in statuses · QuizA subscriber’s card expired and this month’s renewal failed. What status is the subscription in while Stripe retries?B. past_duepast_due means a renewal failed but the subscription is still alive and Stripe is retrying. incomplete is only about the very first payment. What happens when retries run out (cancel, mark unpaid, or leave past_due) is a setting in the Dashboard, covered in module 15.
  55. 13. Customers and subscriptions · Try it: decide access from the status · Code challengeWrite hasAccess(status, daysPastDue). active and trialing get access. past_due gets access only during a 7-day grace period (daysPastDue of 7 or less). Every other status gets none.One solution:
    Solution
    type Status = 'incomplete' | 'incomplete_expired' | 'trialing'  | 'active' | 'past_due' | 'unpaid' | 'paused' | 'canceled'
    function hasAccess(status: Status, daysPastDue = 0): boolean {  switch (status) {    case 'active':    case 'trialing':      return true    case 'past_due':      return daysPastDue <= 7    default:      return false  }}
    List the statuses that grant access and refuse everything else. The starter did the opposite, refusing only canceled, so incomplete (never paid) and unpaid (retries ran out) got bread for free. An allowlist also fails safe if Stripe adds a status one day: an unknown one gets no access until you decide.
  56. 13. Customers and subscriptions · Follow subscriptions through their events · QuizWhich event best tells the bakery that February’s bread is paid for?B. invoice.paidEvery period’s payment is an invoice, so invoice.paid arrives each month, including the first. checkout.session.completed only happens once, at sign-up, and subscription.created says nothing about money.
  57. 14. Trials, plan changes and the customer portal · A free trial delays the first payment · QuizA member signs up for the trial. What is the completed session’s payment_status?C. no_payment_requiredNothing is owed yet, so there is nothing to pay: no_payment_required. trialing is the subscription’s status, not the session’s. A handler that only accepts paid would wrongly refuse every trial sign-up.
  58. 14. Trials, plan changes and the customer portal · Plan for the end of the trial · QuizWhich event should send the “your trial ends soon” email?B. customer.subscription.trial_will_endtrial_will_end arrives three days before the trial ends (or at once, if the trial is shorter). Reminding people before they’re charged is kind, and in many places it’s also required, and it heads off disputes from surprised members.
  59. 14. Trials, plan changes and the customer portal · Proration: paying fairly for part of a period · QuizA member on the $45 plan moves to the $30 plan exactly halfway through the month. With prorations, what happens?B. They get a $7.50 credit towards their next invoiceCredit for half of $45 (−$22.50) plus half of $30 (+$15.00) is −$7.50. Prorations become lines on an invoice, so a credit reduces what they pay next time; it isn’t sent back to the card unless you refund it.
  60. 14. Trials, plan changes and the customer portal · Choose when prorations are billed · Fill the blanksCharge the upgrade difference immediately.1: update, 2: id, 3: always_invoicealways_invoice creates an invoice for the prorations at once and tries to pay it. The item’s id makes it a replacement rather than an extra item.
  61. 14. Trials, plan changes and the customer portal · Cancel now, or at the end of the period · QuizA member cancels on the 10th of a month already paid. When should their deliveries stop?B. When customer.subscription.deleted arrives at the end of the paid periodWith cancel_at_period_end, the updated event tells you a cancellation is scheduled (show “ends on the 31st”), but the member has paid for the month. deleted marks the actual end. Acting on updated would cut off bread they paid for.
  62. 14. Trials, plan changes and the customer portal · The customer portal lets members manage themselves · Find the bugAnyone can open any member’s billing page. Which line allows it?Line 3: customer: req.user.stripeCustomerId,The customer id came from the request, so anyone logged in can send someone else’s cus_... and manage their subscription. Take the id from the signed-in user’s own record on the server.
  63. 15. Invoices and failed payments · Check: invoice statuses · QuizAn open invoice went out with the wrong amount. What do you do?C. Void it, and issue a correct oneOnce finalised, an invoice is a legal record and can’t be edited or deleted. Voiding keeps the record and shows it was cancelled; a new invoice carries the right amount. Only drafts can be changed or deleted.
  64. 15. Invoices and failed payments · What an invoice tells you · Fill the blanksFind which subscription an invoice belongs to.1: parent, 2: subscription_detailsInvoices can come from subscriptions, quotes or be made by hand, so the origin lives under parent. Optional chaining (?.) copes with invoices that have no subscription behind them.
  65. 15. Invoices and failed payments · Handle a failed renewal in your webhook · QuizThe bakery gives past_due members a week of bread while Stripe retries. Where does that rule belong?B. In the bakery’s own code, reading the subscription status it storedStripe says what state the billing is in; what a member gets in each state is your business rule. Keeping it in your code, from the status you mirrored, means you can change the grace period without touching billing.
  66. 15. Invoices and failed payments · When retries run out · QuizThe Dashboard is set to cancel subscriptions when all retries fail. Which event marks that moment?B. customer.subscription.deletedA cancelled subscription ends with customer.subscription.deleted, which is where deliveries stop. invoice.payment_failed comes after each failed attempt, so acting on it alone would stop bread at the first hiccup.
  67. 15. Invoices and failed payments · Bill a business by invoice · Put in orderPut a hand-made invoice’s life in order.1. Create a draft invoice for the customer 2. Add the invoice items 3. Finalise it, so it becomes open 4. Send it to the customer 5. The customer pays, and invoice.paid arrivesItems can only be added while the invoice is a draft; finalising fixes them. Sending only makes sense once it’s final, and invoice.paid closes the loop for your system, as with subscriptions.
  68. 16. Refunds, disputes and taxes · Refunds arrive as events, whoever made them · Find the bugA customer refunded for one squashed loaf has their whole order cancelled. Which line is wrong?Line 4: if (charge.refunded) await orders.markRefunded(orderId)charge.refunded fires for partial refunds too. Only charge.refunded === true (or amount_refunded === amount) means everything was given back. For a partial refund, record the amount and keep the order.
  69. 16. Refunds, disputes and taxes · Respond with evidence, or accept it · QuizWhich steps make disputes less likely? Pick all that apply.A. A recognisable name on the buyer’s statement (the statement descriptor); B. Clear receipts, and an easy way to ask for a refund; D. Reminding members before a trial turns into a chargeMany disputes are confusion: an unfamiliar name on a statement, a forgotten trial. Make the charge recognisable and the bakery easy to reach, and people ask you instead of their bank. Ignoring requests pushes them to the bank, where each dispute costs a fee and too many put your account at risk.
  70. 16. Refunds, disputes and taxes · Stripe Tax works out the amount at checkout · QuizA price of €12.00 is inclusive of 20% VAT. How much of it is VAT?B. €2.00Inclusive means €12.00 is price plus tax: 12.00 ÷ 1.20 = €10.00 before tax, so €2.00 is VAT. €2.40 is 20% of €12.00, the exclusive calculation, and €14.40 would be the total if tax were added on top.
  71. 16. Refunds, disputes and taxes · Read what was charged · Fill the blanksSave an order’s totals from a completed session.1: total_details, 2: amount_tax, 3: amount_totalAmounts are integers in the smallest unit, as always. total_details also holds amount_discount and amount_shipping, so the four numbers explain how the total was reached.
  72. 17. Errors, retries and idempotency keys · Catch the errors you mean · Find the bugDuring a Stripe outage, buyers are told “That order doesn’t exist”. Which line causes it?Line 5: if (e instanceof Stripe.errors.StripeInvalidRequestError && e.statusCode === 404) return nullEvery Stripe error, an outage, a rate limit, a bad key, turns into “not found”. Only a 404 means the session doesn’t exist. Let the rest be thrown, so the page can say “try again in a moment” and your monitoring sees a real failure.
  73. 17. Errors, retries and idempotency keys · Card errors are for the buyer, gently · QuizA payment is declined with decline_code: 'fraudulent'. What should the buyer see?B. “Your card was declined. Please try another card or contact your bank.”Telling a fraudster which check caught them helps them try again smarter, and telling an honest buyer they look like a fraudster is unkind. A generic decline message works for both. Stripe’s own pages follow the same rule.
  74. 17. Errors, retries and idempotency keys · Derive the key from what the request is for · Find the bugA retried request still created two customers. Which line makes the key useless?Line 4: { idempotencyKey: customer-${user.id} }A key based on the time is different on every attempt, so Stripe sees two unrelated requests. Build the key from the thing the request is about (the user, the order, the complaint) so every attempt at the same job shares it.
  75. 17. Errors, retries and idempotency keys · Derive the key from what the request is for · QuizThe same idempotency key is sent again, but with a different amount. What does Stripe do?C. Refuses with an idempotency errorA key promises “this is the same request”. Different parameters break that promise, so Stripe refuses with a StripeIdempotencyError, which usually points to a bug: two different jobs sharing a key.
  76. 17. Errors, retries and idempotency keys · Try it: retry with one key · Code challengeFix refundComplaint. Its idempotency key must be refund- followed by the complaint’s id, the same on every attempt. If create throws, try again, up to 3 attempts in all; after the third failure, let the error through.One solution:
    Solution
    type Refund = { id: string; amount: number }type CreateRefund = (  params: { payment_intent: string; amount: number },  options: { idempotencyKey: string }) => Promise<Refund>type Complaint = { id: string; paymentIntent: string; amount: number }
    async function refundComplaint(  create: CreateRefund, complaint: Complaint): Promise<Refund> {  const params = {    payment_intent: complaint.paymentIntent,    amount: complaint.amount  }  const options = { idempotencyKey: `refund-${complaint.id}` }  for (let attempt = 1; ; attempt++) {    try {      return await create(params, options)    } catch (error) {      if (attempt === 3) throw error    }  }}
    The key is built once, from the complaint, outside the loop, so every attempt carries the same one. If the first attempt actually reached Stripe and only the answer was lost, the second gets back that same refund instead of making another. A key from Date.now() changes between attempts, which turns a safe retry into a second refund. The return await inside try matters too: without await, a failure would escape the catch.
  77. 17. Errors, retries and idempotency keys · Let the library retry for you · QuizA nightly job calls Stripe in a tight loop and starts getting 429s. What helps most?B. Slow down: fewer requests in parallel, pauses that grow after each 429, and list endpoints that return up to 100 objects a pageA 429 means “too many requests for now”. Waiting longer after each one (exponential backoff) and making fewer, bigger requests fixes the cause. Retrying faster makes it worse, and test mode has lower limits than live, so a job can fail there first.
  78. 18. Syncing state and reconciling money · Events don’t arrive in order · Find the bugA member who cancelled is shown as “active” again the next morning. Which line lets an older event win?Line 4: await syncSubscription(sub.id) // fetch the current stateA delayed updated event, still saying active, arrived after deleted and overwrote canceled. Either fetch the subscription’s current state from Stripe before writing (next slide), or store event.created and ignore events older than the last one applied.
  79. 18. Syncing state and reconciling money · Catch up on events you missed · Fill the blanksReplay every subscription change since an outage began.1: events, 2: gte, 3: awaitcreated: { gte } means “at or after” a Unix timestamp. for await walks through every page of results for you, so you don’t handle starting_after yourself. types filters the list but not TypeScript’s view of it, hence the as Stripe.Subscription. Because syncSubscription is idempotent, replaying events you already handled is harmless.
  80. 18. Syncing state and reconciling money · A regular check catches what events miss · QuizWhat should the bakery’s nightly check compare?B. Members marked active in its database against subscriptions Stripe says are active or trialingWhat matters is the facts that decide who gets bread. Listing subscriptions by status from Stripe and comparing with the members table finds both kinds of drift: people getting bread without paying, and paying without getting bread. Report the second kind loudly.
  81. 18. Syncing state and reconciling money · Reconcile a payout line by line · QuizThis week’s orders total $1,000, but the payout is $940. Which could explain it? Pick all that apply.A. Stripe’s processing fees; B. A refund issued this week; C. A dispute that took money and a fee; D. Some payments still waiting to become availableAll of these. A payout is the net of everything that became available in its window, not a sum of orders. Listing its balance transactions shows exactly which charges, fees, refunds and adjustments it includes, so the difference becomes a list instead of a mystery.
  82. 19. Fraud, security and going live · Radar scores every payment · QuizA $300 cake order comes through with risk_level: 'elevated'. What’s a sensible response?B. Look before baking: check the details, maybe contact the buyer, and refund if it seems wrongElevated isn’t proof of fraud; many such payments are fine. For something costly and slow to make, a quick human look is worth it. Refunding a suspicious order before baking is far cheaper than a dispute later.
  83. 19. Fraud, security and going live · Rules and reviews tune Radar to your shop · QuizWhy not block every payment with a risk score above 30?B. Too strict a rule turns away many honest buyers, which costs more than the fraud it stopsEvery rule trades fraud against lost sales. Radar shows how many past payments a rule would have caught before you turn it on; use that to set thresholds that stop the bad without blocking good customers.
  84. 19. Fraud, security and going live · Early fraud warnings: refund before the dispute · Fill the blanksRefund a payment flagged by an early fraud warning.1: early_fraud_warning, 2: fraudulentRefunding with reason: 'fraudulent' also tells Radar the payment was fraud, which helps it learn. The idempotency key, from the warning’s id, keeps a duplicate event from trying a second refund.
  85. 19. Fraud, security and going live · Make your checkout a poor target · QuizOvernight, the bakery sees 400 declined $1 payments from new visitors. What is most likely happening?B. Card testingMany tiny, mostly failing payments from strangers is the signature of card testing. Rate-limit or temporarily close the checkout route, and look at Radar’s rules. Even the declines matter: banks notice merchants with many failed attempts.
  86. 19. Fraud, security and going live · A security review of a payment flow · QuizWhich belong in a security review of the bakery’s Stripe code? Pick all that apply.A. Secret keys only in server environment variables, never in pages or git; B. Every webhook verified with its signing secret, over the raw body; C. Prices and quantities decided on the server; D. Client secrets, keys and card details kept out of logs and URLsEach one closes a real attack: a leaked key, forged “paid” events, a one-cent cart, and secrets lifted from logs. Add a restricted key with only the permissions the server uses, HTTPS everywhere, and pages that grant nothing on a visit alone.
  87. 19. Fraud, security and going live · Going live · Put in orderPut the launch steps in order.1. Activate the account with business and bank details 2. Recreate products, prices (with the same lookup keys), tax settings and portal settings in live mode 3. Add the live webhook endpoint and store its new signing secret 4. Swap the server’s keys and webhook secret for live ones 5. Make one small real purchase, check it end to end, and refund itThe account must be active before live keys can take payments. Catalogue and endpoint come next, so that the switch of keys finds everything in place. A real purchase is the only test that proves live mode works, and refunding it costs little.
  88. 20. Mastery challenge · Challenge: the whole one-off flow · Put in orderOrder the life of a weekend order, from button to bank.1. The server prices the cart and creates an order marked pending 2. The server creates a Checkout Session with the order id and redirects with a 303 3. The buyer pays on Stripe’s page and the issuer authorises 4. A signed checkout.session.completed arrives and the order is marked paid, once 5. The money becomes available and is paid out to the bakery’s bankYour record exists before Stripe’s, so the session can point at it. Fulfilment waits for a verified event, and duplicates change nothing. The payout comes days later and is reconciled from balance transactions.
  89. 20. Mastery challenge · Challenge: the handler that emails twice · Find the bugUnder retries, some buyers get two receipts though only one order is paid. Which line is in the wrong place?Line 2: // send the receipt after the update, only if a row changedThe update is idempotent, but the email runs before it, on every delivery. Side effects belong after the conditional change and only when it changed a row, like the bake list here. Order matters as much as the SQL.
  90. 20. Mastery challenge · Challenge: nobody can join the trial · QuizSince the 14-day trial launched, no new member gets a welcome email. The handler is below. Why?B. A trial sign-up’s session is no_payment_required, so the check skips itNothing is owed at a trial’s start, so payment_status is no_payment_required. For subscriptions, decide access from the subscription’s own status (trialing or active), fetched or mirrored, rather than from the session’s payment status.
  91. 20. Mastery challenge · Challenge: the upgrade that doubled the bill · Find the bugMembers who upgrade to the large box are billed for both boxes. Which line causes it?Line 4: items: [{ id: item.id, price: largeBoxMonthlyId }],An item without an id is a new item, so the subscription now has two prices. Passing the existing item’s id replaces its price. The unused item variable on line 2 was the clue.
  92. 20. Mastery challenge · Challenge: a safe refund from a queue · Fill the blanksA queue job refunds one loaf for a complaint, and may run more than once. Make it safe.1: 850, 2: idempotencyKey, 3: complaint.idA loaf is 850 cents. The key comes from the complaint, the job’s own identity, so a retried job gets the first refund back instead of making another. A key from Date.now() or a random id would protect nothing.
  93. 20. Mastery challenge · Challenge: a safe refund from a queue · QuizThe refund call times out with a StripeConnectionError. What should the job do?B. Retry with the same idempotency keyA timeout means you don’t know whether Stripe made the refund. Retrying with the same key either makes it (if the first never arrived) or returns the first result (if it did). A new key risks two refunds; assuming success risks giving no refund at all.
  94. 20. Mastery challenge · Challenge: the member who came back from the dead · QuizAfter a cancellation, an old customer.subscription.updated (status active) arrives last. Which handlers stay correct? Pick all that apply.B. One that retrieves the subscription from Stripe and writes its current status; C. One that ignores events older than the last event.created it applied for that subscriptionFetching the current state makes order irrelevant, and comparing created times refuses stale snapshots. Copying the snapshot revives the member. Answering 500 only makes Stripe resend the same old event for days.
  95. 20. Mastery challenge · Challenge: launch day · QuizOn launch day, every checkout fails with “No such price”. Keys are live and correct. What was missed?B. The price ids in the configuration are test-mode idsTest and live share no objects, so a test price_... doesn’t exist for a live key. Looking prices up by lookup key, with the same keys in both modes, avoids the problem entirely. A wrong webhook secret would break fulfilment, not checkout.
  96. 20. Mastery challenge · Challenge: launch day · QuizA week after launch, the payout is lower than the sum of paid orders. What do you do first?B. List the payout’s balance transactions and match fees, refunds and adjustmentsA payout is a net amount. Its balance transactions show every charge, fee, refund and dispute adjustment included, which almost always explains the difference. Only an unexplained gap after that is worth a support ticket.
  97. 20. Mastery challenge · Challenge: one last route · Find the bugThis success page has a security hole. Which line?Line 4: // show the welcome; membership is granted by the webhookAny logged-in user can open this page with any session id, even someone else’s, and be made a member. The page should only show what Stripe reports; membership comes from the verified subscription events (or a check that this session is this user’s, paid or trialing, done idempotently).