Full-Stack Questions
Payments and Third-Party Integrations
Stripe Checkout and Payment Intents, webhooks, idempotency keys, S3 uploads, email deliverability, timeouts, retries and circuit breakers.
On this page
In one line: every outside service you call (a payment provider, a file store, an email sender) can be slow, wrong, repeated or down. Good integration code does four things. It lets the provider's server-to-server message decide what is true, not the browser. It makes every step safe to repeat (idempotent). It puts a timeout on every call. It keeps secrets and card data out of places where they can leak.
At 4+ years, interviewers do not ask "how do you take a payment?". They ask "what if the customer pays and then closes the tab?", "what if the same webhook arrives twice?" and "why did the upload work on your laptop but fail in the browser?". This lesson gives you the model behind those answers, and then 19 questions with spoken answers.
I checked the code in this lesson in October 2026 with Stripe SDK 23 (pinned API version 2026-09-30.endive), AWS SDK for JavaScript v3 (3.1148), Nodemailer 10, Resend SDK 6, Opossum 10 and Express 5. The webhook handler, the idempotency retry, the retry-and-breaker client and the DKIM signing were run for real. The S3 URLs were built (not sent) with dummy keys. Several claims about Stripe, AWS and the big mailbox providers come from their documentation, which I could not open in my work environment. I mark those with "per the provider's docs", so double-check the exact numbers before you quote them.
Related lessons: the full design of file uploads is in Design Problems, Lesson 04. The full design of idempotent payments (key table, state machine, reconciliation) is in Design Problems, Lesson 07. The basic Stripe webhook handler is in Libraries, Lesson 13, and a debugging view of duplicate webhooks is in Debugging, Lesson 11. This lesson does not repeat them. It adds what is around them.
Words used in this lesson
- A payment provider is a company (for example Stripe) that talks to the card networks and banks for you and moves the money.
- A webhook is an HTTP request that the provider sends to your server when something happens, such as "this payment succeeded". It is like a delivery company ringing your door bell, instead of you walking to the depot every minute.
- Idempotent means that doing the same thing many times has the same result as doing it once. An idempotency key is a unique value you send with a request so the provider can recognise a repeat and not do the work twice.
- PCI DSS is a security rulebook from the card industry for anyone who handles card numbers. PCI scope is the part of your systems that touches card data, and every part in scope must follow the rules.
- SCA (Strong Customer Authentication) is a European and UK rule that the buyer must prove who they are with two of three things (something they know, have or are). 3D Secure (3DS) is the card-network method that does this, usually with a bank pop-up or a phone approval.
- A presigned URL is a normal storage URL with a signature and an expiry time inside it. Whoever holds it can do one named action (for example upload one file) until it expires, without having any account keys.
- SPF, DKIM and DMARC are three DNS-based checks that prove an email really comes from your domain. They are explained in the email section.
- A circuit breaker is a small piece of code that stops calling a failing service for a while, like the fuse in your house that cuts power when something is wrong.
- A sandbox is a safe copy of a service with fake money or fake mail, used for testing.
Stripe
Checkout or Payment Intents?
Stripe has two main ways to take a one-time payment. A Checkout Session is a payment page that Stripe builds and hosts for you (or embeds). A PaymentIntent is Stripe's record of one payment attempt. You can use it with your own form built from Stripe's Elements (card fields that Stripe draws inside an iframe on your page).
| Checkout | PaymentIntent + Elements | |
|---|---|---|
| Who builds the payment form | Stripe | You (with Stripe's fields) |
| Time to build | Hours | Days |
| Cards, wallets, bank methods, 3DS, tax, coupons | Mostly built in | You add and test each one |
| Control over layout | Limited (branding options) | Full |
| Typical use | Most products, subscriptions, first version | Custom flows, marketplaces, very special UX |
| What you create on the server | checkout.sessions.create | paymentIntents.create |
Checkout can also be embedded in your page, not only opened as a redirect. Under the hood a Checkout Session creates a PaymentIntent for you. Difference in one line: Checkout is a finished payment page, a PaymentIntent is the engine that you wrap in your own page.
The server code for each is short and it type-checks against the SDK:
const session = await stripe.checkout.sessions.create( { mode: 'payment', // or 'subscription' or 'setup' (save a card, no charge) line_items: [{ price: priceId, quantity: 1 }], client_reference_id: orderId, // comes back on the webhook success_url: 'https://app.example.com/orders/done?session_id={CHECKOUT_SESSION_ID}', cancel_url: 'https://app.example.com/cart', }, { idempotencyKey: `checkout:${orderId}` },)// redirect the browser to session.urlconst pi = await stripe.paymentIntents.create( { amount: 1999, currency: 'usd', automatic_payment_methods: { enabled: true } }, { idempotencyKey: `pi:${orderId}` },)// send pi.client_secret to the browser (never the secret API key)Amounts are integers in the smallest unit (1999 means 19.99 USD). Some currencies, such as JPY, have no minor unit, so 1999 means 1999 yen. Never use floats for money.
The golden rule: the webhook is the truth
When the buyer pays, two things happen at the same time. The browser lands on your success_url. Stripe sends a webhook to your server. They are not in a fixed order, and the first one can be missing (the buyer may close the tab, or lose the network). So:
Never mark an order as paid because the browser reached the success page, or because the client says "paid". The success page only shows a message. Your server fulfils the order when a verified webhook says so.
For Checkout, the event is checkout.session.completed. For some slow payment methods (bank debits, for example) the session can complete before the money arrives. So check session.payment_status === 'paid', and also handle checkout.session.async_payment_succeeded and checkout.session.async_payment_failed. For a PaymentIntent, the events are payment_intent.succeeded and payment_intent.payment_failed.
A PaymentIntent moves through these statuses (taken from the SDK types):
| Status | Meaning |
|---|---|
requires_payment_method | Just created, or the last attempt failed. Waiting for a card. |
requires_confirmation | Has a method, waiting to be confirmed. |
requires_action | The buyer must do something, usually the 3DS check. |
processing | Sent to the bank, no answer yet (common for bank debits). |
requires_capture | Authorized (money reserved), and you will capture later. |
succeeded | Paid. |
canceled | Cancelled. |
Card data and PCI scope
The safe design is: card numbers never touch your server. The card fields are drawn by Stripe inside an iframe (or on Stripe's own page), and the browser sends the number straight to Stripe. Your server only sees a token or an id (a pm_... payment method, a pi_... intent). That keeps your PCI scope small. With Checkout or Elements the paperwork you owe is normally the shortest self-assessment form (called SAQ A); check the exact form with your provider and your bank.
- Never log request bodies on payment routes without removing card fields. Never put a card number in a URL, an analytics event or an error report.
- Never store the CVC (the 3 or 4 digit code), even encrypted. The rules forbid it.
- If you build your own card input and post the number to your own API, you move your whole backend into scope. That is the mistake the iframe design prevents.
- Store only what you need: the Stripe customer id, the payment method id, the last four digits and the brand for display.
SCA and 3D Secure
In Europe and the UK, most online card payments need SCA. In practice, Stripe asks the bank, and the bank may ask the buyer to approve in their banking app. For you this shows up as a PaymentIntent in requires_action. If you use Checkout, or Stripe.js confirmPayment, the pop-up is handled for you. Two parts matter for your design:
- Customer present (they are on your page): Stripe.js shows the pop-up. Nothing special to build.
- Customer absent (a subscription renewal, an automatic top-up): the bank may demand authentication but nobody is there. The payment fails with an "authentication required" result. You must email the buyer a link to come back and finish. For subscriptions, Stripe sends
invoice.payment_action_requiredand can send the email for you (a setting in the dashboard). To reduce this, save the card with the right setup when it is first used (setup_future_usageon the PaymentIntent, ormode: 'setup'), so the bank knows the later payments are agreed.
For testing, Stripe gives test cards: 4242 4242 4242 4242 always succeeds, and 4000 0025 0000 3155 asks for 3DS (per Stripe's test docs).
Idempotency keys with Stripe
The long design is in Design Problems, Lesson 07. The Stripe-specific facts are these:
- You pass the key as an option:
stripe.paymentIntents.create(params, { idempotencyKey }). The SDK sends it as theIdempotency-Keyheader. - The SDK already adds a key for you on every POST (a random
stripe-node-retry-...value), so its own automatic retries are safe. I checked this with a fake server that answers 500 once: the SDK called twice and the key was the same both times. - That automatic key is new for each of your calls. It does not protect you if your own code retries, or your user clicks twice. For that you need a key that comes from your business fact, for example
checkout:${orderId}. The same order always gives the same key. - The SDK defaults, from its source: timeout 80 seconds, 2 automatic retries (for connection errors,
409conflicts and5xxerrors, and when Stripe'sstripe-should-retryheader says so), with exponential waiting. - Stripe remembers a key only for a limited time (per its docs, keys can be removed after about 24 hours), and if you reuse a key with different parameters it returns an error, so keys are not a place to hide bugs.
Webhooks: signature, raw body, tolerance
The basic handler is in Libraries, Lesson 13. The points interviewers still push on:
- Why verify? Your endpoint is a public URL. Anyone can post "payment succeeded" to it. The signature proves that Stripe sent the exact bytes. Stripe signs
timestamp + "." + raw bodywith a secret that is unique to each endpoint. - Raw body. If
express.json()parses the body first, the bytes change (spaces, key order) and the check fails. Useexpress.raw({ type: 'application/json' })on this route only, and mount it before the global JSON parser. In Next.js Route Handlers, callawait request.text(), notrequest.json(). - Tolerance. The SDK rejects an event whose timestamp is more than 300 seconds old (5 minutes). This stops someone from replaying a captured request later. In SDK 23 there is a gotcha: passing
0as the tolerance now skips the time check completely, which is not what you want in production. - One secret for each endpoint and each mode. The test endpoint and the live endpoint have different
whsec_secrets. "Works in test, fails in live" is usually the wrong secret. - Retries by Stripe. If you do not answer with a 2xx in time, Stripe tries again for up to about three days in live mode, with growing waits (per its docs). A failing endpoint can be disabled after a long time of failing. So answer fast, and monitor the failures.
- Thin and snapshot events. The classic event carries a full copy of the object at that moment (a snapshot). Newer Stripe APIs also have "thin" events that carry only ids, so you fetch the object yourself. Both use the same ideas below: verify, dedupe, and prefer to read the current state.
Subscriptions, proration and the events that matter
A subscription is a promise to charge a customer every period. In Stripe each period makes an invoice, and the invoice makes a payment. The subscription status can be active, trialing, past_due, unpaid, canceled, incomplete, incomplete_expired or paused.
Proration means charging or crediting only for the part of the period that was actually used when a plan changes in the middle of the period. Example: a customer pays 30 USD per month on the 1st and upgrades to a 60 USD plan on day 16. They get a credit of about 15 USD for the unused half of the old plan and a charge of about 30 USD for the second half of the new plan.
The proration_behavior option on a subscription update decides what happens:
| Value | Effect |
|---|---|
create_prorations (the default) | Prorations are created and added to the next invoice. No new charge today. |
always_invoice | Prorations are created and invoiced now, and Stripe tries to collect. |
none | No proration. The customer pays the new price from the next period. |
const sub = await stripe.subscriptions.retrieve(subId)await stripe.subscriptions.update(subId, { items: [{ id: sub.items.data[0].id, price: newPriceId }], // change the item, do not add a second one proration_behavior: 'always_invoice', // upgrade: charge the difference now})The events a real app handles: checkout.session.completed (start), invoice.paid (give or extend access), invoice.payment_failed (tell the user, show a banner), invoice.payment_action_required (3DS needed), customer.subscription.updated (plan or status changed), customer.subscription.deleted (remove access) and customer.subscription.trial_will_end. For money problems also watch charge.refunded and charge.dispute.created (a chargeback, which is a buyer asking their bank to take the money back).
Dunning means the automatic process of retrying a failed renewal and emailing the customer. Stripe can retry failed payments on a schedule (a setting called Smart Retries or the retry rules). Do not build this yourself first.
Let the customer manage their own plan in the Customer Portal (a Stripe-hosted page) instead of building cancel and card-update screens.
S3 file uploads
The full upload design is in Design Problems, Lesson 04. Here are the questions around it.
Presigned URL or presigned POST?
Both let the browser upload straight to S3, so large files never pass through your server. Your server uses its own credentials to sign a short-lived permission.
| Presigned PUT URL | Presigned POST | |
|---|---|---|
| Browser sends | PUT with the file as the body | POST as a multipart/form-data form with fields, then the file |
| Can limit file size? | Only by signing the exact Content-Length (the size must match exactly) | Yes: the content-length-range condition (min and max) |
| Can limit the type or key prefix? | Sign the Content-Type; the browser must send the same one | Yes: conditions such as starts-with on the content type or key |
| Simplicity | Simplest | More fields, more control |
I built both with dummy keys. The PUT URL has X-Amz-Expires=300 and X-Amz-SignedHeaders=host, and a PUT with a signed ContentLength adds content-length to the signed headers. The POST policy had ["content-length-range", 0, 5242880] and a starts-with rule on the content type, which S3 enforces itself. So if size limits matter, presigned POST is the stronger tool.
The maximum lifetime of a SigV4 presigned URL is 7 days (604800 seconds); the SDK throws an error for 604801. If you sign with temporary credentials (an IAM role), the URL can stop working earlier, when those credentials expire. Use short lifetimes (minutes) for uploads.
A presigned URL is a bearer token: whoever has it can use it. It is not tied to a user. Do not log it, keep it short-lived, and make the key (the file path) on the server, for example uploads/${userId}/${uuid}. Never let the client choose the key.
The checksum trap in the AWS SDK
Newer versions of AWS SDK v3 add a checksum to many requests by default. For a presigned upload this matters: when I signed a PutObjectCommand with the default client, the URL contained x-amz-checksum-crc32=AAAAAA== and x-amz-sdk-checksum-algorithm=CRC32. That value is the checksum of an empty body, calculated when the URL was made, long before any file exists. S3 can reject a real upload that does not match it. With requestChecksumCalculation: 'WHEN_REQUIRED' on the client, those two parameters were gone from the URL.
const s3 = new S3Client({ region: 'eu-west-1', requestChecksumCalculation: 'WHEN_REQUIRED' })const url = await getSignedUrl( s3, new PutObjectCommand({ Bucket, Key: `uploads/${userId}/${crypto.randomUUID()}`, ContentType: type, ContentLength: size }), { expiresIn: 300 },)Many tutorials from before this change do not show it. If someone says "my presigned PUT works with curl but fails with the SDK URL", look here first.
CORS: why the browser blocks the upload
CORS (cross-origin resource sharing) is a browser rule: a page from app.example.com may call another origin (bucket.s3.amazonaws.com) only if that server sends headers that allow it. A PUT with a Content-Type header triggers a preflight: the browser first sends an OPTIONS request to ask for permission. S3 answers it from the bucket's CORS configuration, which you must set:
{ "CORSRules": [{ "AllowedOrigins": ["https://app.example.com"], "AllowedMethods": ["PUT", "GET"], "AllowedHeaders": ["content-type"], "ExposeHeaders": ["ETag"], "MaxAgeSeconds": 3000 }]}AllowedHeaders must list every header the browser sends. ExposeHeaders: ["ETag"] matters for multipart uploads, because JavaScript cannot read the ETag of each part unless the server exposes it. Do not use * as the origin in production. CORS only protects browsers. It does not stop curl, so it is not security by itself.
Multipart upload
A multipart upload splits one big file into parts that are uploaded separately (and in parallel), then joined. The S3 rules (per the AWS docs): each part is 5 MiB to 5 GiB (the last part may be smaller), up to 10,000 parts, and the whole object up to 5 TiB. A single PUT is limited to 5 GiB. The AWS SDK helper (@aws-sdk/lib-storage) has these defaults in its source: parts of at least 5 MB, 4 parts at the same time, a hard stop above 10,000 parts, and an automatic abort of the upload if it fails.
The browser flow:
- Server:
CreateMultipartUploadreturns anUploadId. - Server: for each part, create a presigned
UploadPartURL (it includespartNumberanduploadId). I generated these and checked both appear in the URL. - Browser: uploads each part (retry only that part), and collects the
ETagof each. - Server:
CompleteMultipartUploadwith the list of part numbers and ETags.
Unfinished multipart uploads are invisible and cost money. The parts stay in the bucket until you complete or abort. Add a lifecycle rule that aborts incomplete multipart uploads after a few days.
For a 5 TiB file the part size must be at least about 525 MiB, because of the 10,000-part limit. Pick a part size such as 8 to 16 MiB for normal large files.
Virus scan, content checks, and the CDN
- Never trust the file name, the extension or the
Content-Typeheader. The browser or attacker sets them. Check the first bytes of the file (the "magic bytes": a PNG starts with89 50 4E 47 0D 0A 1A 0A). A Windows program starts withMZ. I wrote a ten-line sniffer and it told a PNG from a program. - Scan with an antivirus engine (ClamAV is the common open-source choice). The pattern: the browser uploads into a private incoming prefix. A storage event triggers a worker that downloads, scans, and then moves the file to a clean prefix or deletes it. The database row stays
pendinguntil the scan passes. Nobody can download apendingfile. - Serve user files safely. An uploaded HTML or SVG file can run script in your site. Serve downloads from a separate domain, set the correct
Content-Type, addX-Content-Type-Options: nosniff, and useContent-Disposition: attachmentfor anything that is not an image you have re-processed. - CDN (content delivery network) is a set of servers close to users that cache your files. Put it in front of the bucket (for example CloudFront with an Origin Access Control, so the bucket itself stays private). Use file names that change when the content changes (a hash in the name) with a long
Cache-Control, instead of paying for slow, partial cache invalidation. For private files use signed URLs or cookies from the CDN.
Transactional email
A transactional email is a message the system sends because of one user's action: a receipt, a password reset, an invitation. (Marketing email is a bulk message to many people. Keep them on separate sender addresses, because complaints about marketing damage the delivery of receipts.)
Which tool?
| Option | What it is | Good for | Watch out for |
|---|---|---|---|
| Nodemailer | A Node library that talks SMTP (the standard email protocol) | Dev, internal tools, and sending through any SMTP server | It is only a client. You still need a server or provider, and you own deliverability. It needs Node's network module, so it does not run on edge runtimes. |
| Amazon SES | AWS's sending service | Large volume, low price, AWS shops | A new account starts in a sandbox that sends only to verified addresses; you must request production access. You build more yourself (templates, bounce handling). |
| Resend | A sending service with an HTTP API and a TypeScript SDK | Fast start, good developer experience, works in edge code | Another vendor; check limits and pricing |
All of them need you to prove your domain, as below.
SPF, DKIM, DMARC
Anyone can type any "From" address. These three DNS records let the receiving server check.
| SPF | DKIM | DMARC | |
|---|---|---|---|
| Full name | Sender Policy Framework | DomainKeys Identified Mail | Domain-based Message Authentication, Reporting and Conformance |
| It answers | "Is this server allowed to send for this domain?" | "Was this message signed by the domain, and is it unchanged?" | "What should the receiver do if the checks fail, and tell me about it" |
| Where | A TXT record on your domain listing allowed senders | A public key in a TXT record at selector._domainkey.yourdomain; the sender signs each message with the private key | A TXT record at _dmarc.yourdomain with a policy |
| Example | v=spf1 include:amazonses.com -all | v=DKIM1; k=rsa; p=MIIB... | v=DMARC1; p=quarantine; rua=mailto:dmarc@example.com |
Facts to know:
- DMARC passes only when SPF or DKIM passes and is aligned: the domain that passed must match the domain in the visible
Fromaddress. A provider that signs with its own domain does not give you alignment, so you set up a custom DKIM domain. - A DMARC
p=policy isnone(just watch and send reports),quarantine(send to spam) orreject(refuse). Start withnone, read theruareports, fix your senders, then move toquarantineandreject. - SPF allows at most 10 DNS lookups (each
include:costs at least one). Too manyinclude:entries make SPF fail with a permanent error. - Big mailbox providers (Gmail, Yahoo) now require SPF, DKIM and DMARC from senders of large volume (the commonly quoted line is 5,000 messages a day), one-click unsubscribe for marketing mail, and a low spam-complaint rate (they say to stay well under 0.3%). Check their current sender guidelines for exact rules.
I signed a message with Nodemailer's dkim option, published the public key in a fake DNS lookup, and verified it with a DKIM checker: it passed. The signature header is added by the sender, and the check fails if anyone changes the body afterwards.
const mailer = nodemailer.createTransport({ host: 'smtp.example.com', port: 587, secure: false, requireTLS: true, // STARTTLS on port 587 auth: { user: process.env.SMTP_USER, pass: process.env.SMTP_PASS }, pool: true, // reuse connections})await mailer.sendMail({ from: 'App <noreply@example.com>', to, subject, text, html })// Resend: HTTP API, and the second argument supports an idempotency keyawait resend.emails.send( { from: 'App <noreply@example.com>', to: [to], subject, html }, { idempotencyKey: `welcome/${userId}` },)Bounces, complaints and suppression
- Hard bounce: a permanent failure, such as "no such mailbox". Never send to that address again.
- Soft bounce: a temporary failure, such as a full mailbox. Retry a few times, then stop.
- Complaint: the user pressed "report spam". Stop sending to that address at once.
Providers tell you about these by webhook (SES uses notifications through other AWS services; Resend has events such as email.bounced, email.complained and email.delivery_delayed, and signs them in the Standard Webhooks style, so you verify them before you trust them). Your job: receive the event, write the address to a suppression list in your own database, and check that list before every send. Repeated sends to dead addresses ruin your reputation, and then even good mail goes to spam.
Send email from a queue, not inside the web request. A slow mail server then cannot slow your checkout, and a retry does not double-send if you use a job id or an idempotency key. See the queue notes in Libraries, Lesson 13.
Integration patterns: timeouts, retries, breakers, limits
Every call to an outside service needs the same small set of protections.
| Pattern | What it does | Rule |
|---|---|---|
| Timeout | Stops waiting after a set time | Always set one. Without it, a hung provider holds your connections and your requests pile up. |
| Retry with backoff and jitter | Tries again after a growing, random wait | Only for temporary errors (timeouts, 429, 502, 503, 504) and only for idempotent calls or calls with a key. |
| Circuit breaker | Stops calling a failing service for a while and answers fast | Use it when a failure would otherwise make every request wait. Return a fallback or a clear error. |
| Rate limit handling | Respects the provider's limits | Read Retry-After on 429, slow down, queue the work. |
| Bulkhead | Gives each provider its own small pool of connections or workers | A slow email provider must not use up the connections that payments need. |
I wrote a small client with all of these and ran it against a fake provider:
async function callWithRetry(url: string, { retries = 3, timeoutMs = 500, baseMs = 20, capMs = 400 } = {}) { for (let attempt = 0; ; attempt++) { try { const res = await fetch(url, { signal: AbortSignal.timeout(timeoutMs) }) // 1. a timeout on every call if (res.ok) return res.json() if (!RETRYABLE.has(res.status) || attempt === retries) throw Object.assign(new Error(`HTTP ${res.status}`), { status: res.status }) const ra = Number(res.headers.get('retry-after')) // 2. obey the provider's hint await sleep(res.headers.has('retry-after') && Number.isFinite(ra) ? ra * 1000 : Math.random() * Math.min(capMs, baseMs * 2 ** attempt)) // 3. full jitter } catch (e: any) { if (e.status || attempt === retries) throw e await sleep(Math.random() * Math.min(capMs, baseMs * 2 ** attempt)) // network error or timeout } }}Results from the run: a provider that answered 429 twice and then 200 succeeded on the third call. A provider that never answered ended with a TimeoutError after about 200 ms (two tries of 100 ms). Full jitter means the wait is a random number between zero and the current limit, so many clients do not all retry at the same moment.
For the breaker I used the Opossum library. Its defaults, from its source, are a 10-second call timeout, a 50% error threshold and a 30-second wait before it tries again. I set small values to see it work:
const breaker = new CircuitBreaker(() => callWithRetry(url, { retries: 0 }), { timeout: 300, errorThresholdPercentage: 50, volumeThreshold: 3, resetTimeout: 200 })breaker.fallback(() => ({ degraded: true }))A provider that always answered 503: the first three calls reached it, the breaker opened, and calls 4 to 6 returned the fallback without touching the provider (the hit counter stayed at 3). After the reset time it went half-open, one trial call succeeded, and it closed. The states are closed (normal), open (blocking) and half-open (testing with one call).
Retries and breakers fight each other if you stack them without thought. Three layers of three retries make 27 calls. Retry in one place, close to the call, and let the breaker count the final result.
Sandbox versus live, and secrets
- Use the provider's test or sandbox mode in development and in CI. It has its own keys, its own data and its own webhook endpoints and secrets. Stripe also offers separate sandboxes (full test copies of an account) besides test mode.
- Keep the mode in config, not in code. One setting (
STRIPE_SECRET_KEY) chooses it. Fail at start-up if a production build has ask_test_key, or if a development build has ask_live_key. - Secret keys stay on the server. Stripe's publishable key (
pk_...) is meant for the browser. The secret key (sk_...) and the webhook secret (whsec_...) are not. In Next.js any variable that starts withNEXT_PUBLIC_is sent to the browser. - Use restricted keys (keys with only the permissions one service needs) where the provider offers them. Give each environment and each service its own key so you can rotate one without breaking the others.
- Store secrets in the platform's secret store or environment settings, never in Git. Turn on secret scanning for the repository. If a key leaks, rotate first, then investigate.
- Test webhooks locally with the provider's tool (the Stripe CLI forwards events to your machine and gives a local
whsec_secret).
The design: duplicate and out-of-order webhooks
The problem: the provider sends each event at least once and does not promise the order (per Stripe's docs). Also, when your endpoint is slow or down, it retries. So your handler will see duplicates and old events after new ones. The design has three layers.
- Verify the signature on the raw body.
- Deduplicate by event id, in the same database transaction as the business change. Then a crash rolls back both, and the retry starts clean.
- Order safely. Choose one of three strategies:
| Strategy | How | Weakness |
|---|---|---|
| Compare timestamps | Apply the event only if it is newer than the stored one (WHERE event_created < new) | Stripe's created has a resolution of one second, so two events can tie |
| State machine | Allow only legal moves (a canceled subscription cannot become incomplete) | Needs a careful table of legal moves |
| Fetch the truth | On any event, ignore the payload fields, call stripe.subscriptions.retrieve(id) and store the current state | One extra API call; still dedupe to save calls |
"Fetch the truth" is the safest, because the result no longer depends on the order of events at all. Many teams combine it with dedupe. Here is the compare-timestamps version. I ran it against signed requests:
// One transaction: claim the event and change the data together.function handle(event: Stripe.Event) { db.exec('BEGIN') try { const claim = db.prepare('INSERT OR IGNORE INTO processed_events (event_id, type) VALUES (?, ?)') .run(event.id, event.type) if (claim.changes === 0) { db.exec('ROLLBACK'); return 'duplicate' } if (event.type.startsWith('customer.subscription.')) { const sub = event.data.object as Stripe.Subscription db.prepare(` INSERT INTO subscriptions (id, status, event_created) VALUES (?, ?, ?) ON CONFLICT(id) DO UPDATE SET status = excluded.status, event_created = excluded.event_created WHERE excluded.event_created > subscriptions.event_created `).run(sub.id, sub.status, event.created) // an older event changes nothing } db.exec('COMMIT') return 'processed' } catch (e) { db.exec('ROLLBACK'); throw e }}Test sequence and results: event 1 (created, time 100) gave processed. Event 3 (active, time 300) arrived early and gave processed. Event 2 (past_due, time 200) arrived late and gave processed, but the row stayed active with time 300, because the older event changed nothing. Event 3 sent again gave duplicate. A request with a bad signature got 400. The table ended with one row (active) and 3 processed event ids.
In Postgres the same idea is INSERT ... ON CONFLICT DO NOTHING and checking the row count. A second request with the same id waits for the first transaction to finish, so two parallel copies of one event cannot both pass.
Side effects that cannot be rolled back, such as sending an email or calling another API, do not belong inside the transaction. Write a row to an outbox table (a table of "things to do") in the same transaction, and let a worker do it afterwards with its own idempotency key. If the work is slow, store the verified raw event in an inbox table with status received, answer 200 at once, and process it in a worker. Reply to the provider in well under a few seconds either way.
Finally, run a reconciliation job (compare your records with the provider's list of payments or subscriptions every night). It repairs events that were lost for any reason, including the long outage that exceeded the retry window.
Common mistakes
- Marking an order paid on the success page, or from client input, instead of the verified webhook.
- Parsing the webhook body as JSON before verifying it.
- Using the same webhook secret (or the same keys) for test and live.
- Letting the client choose the S3 key or the price. The client sends an id, the server looks up the price.
- Retrying a charge after a timeout without an idempotency key.
- Presigned URLs that live for days, or are logged.
- Forgetting to abort unfinished multipart uploads.
- Sending email in the request path, and never handling bounces.
- Calling a provider with no timeout.
- Putting a secret key in
NEXT_PUBLIC_or in a mobile app. - Floats for money.
Interview questions
Checkout or PaymentIntents: how do you choose? (Must know)
I start with Checkout. Stripe builds the payment page, so cards, wallets, 3DS, tax and receipts are already tested and kept up to date. It is hours of work instead of days, and it keeps card data off my site. I choose PaymentIntents with Elements when the product needs a very custom flow, such as a marketplace with special rules or an exact design inside my own page. The price is more code to build, test and maintain. Under the hood Checkout also uses a PaymentIntent, so moving later is possible. In both cases my server creates the object, and my server decides the order is paid only from a webhook.
The customer pays and then closes the tab before the success page loads. How do you make sure the order is fulfilled? (Must know)
The success page must not be the thing that fulfils the order. I fulfil it from the verified webhook, checkout.session.completed or payment_intent.succeeded, which Stripe sends from its server no matter what the browser does. I check that payment_status is paid, because some methods complete before the money arrives, and I also handle async_payment_succeeded. I match the webhook to my order with client_reference_id or metadata that I set when I created the session. The success page just reads my database and shows "paid" or "processing". If the webhook is slow, the page can poll my own API for a few seconds. As a safety net, a nightly reconciliation job compares my orders with Stripe.
What is PCI scope, and how do you keep it small? (Must know)
PCI DSS is the card industry's security rulebook. Anything that stores, processes or passes card numbers is in scope, and must follow many rules. I keep scope small by never letting card numbers reach my systems. I use Checkout or Elements, where the card fields live in a Stripe iframe and the browser sends the number straight to Stripe. My server only sees ids like pm_.... I do not log request bodies on payment routes, I never store the CVC, and I keep only the customer id, the payment method id, the brand and the last four digits. If I built my own input and posted the number to my API, my whole backend would become in scope. With the iframe approach, my paperwork is normally the shortest form, but I confirm the exact form with the provider and the bank.
What is SCA, and why do renewals sometimes fail with it? (Senior signal)
SCA is a European and UK rule that the buyer proves who they are with two of three factors. Cards do it with 3D Secure, usually a bank app approval. With the customer present, Stripe.js or Checkout shows the pop-up and the PaymentIntent goes through requires_action. A renewal happens when nobody is there, so if the bank asks for authentication the payment fails. I handle this in three ways. First, I save the card correctly at the first payment (setup_future_usage or a setup-mode Checkout) so the bank knows later charges are agreed. Second, I listen for invoice.payment_action_required and email the user a link back. Third, I show a banner in the app. Some renewals will still need a human, and I design for that. I test with the 3DS test card.
How do idempotency keys work with Stripe, and what makes a good key? (Must know)
I send an Idempotency-Key with a POST. If Stripe has seen that key, it returns the saved result of the first request instead of doing the work again. That makes a retry after a timeout safe. A good key comes from my business fact, not from the HTTP attempt. For example checkout:${orderId}: the same order always gives the same key, so a double click or a retry from my own code cannot create two sessions. A random key per call would not help, because every retry would be new. The SDK already puts a random key on its own automatic retries, which covers network errors, but not my code retrying or the user clicking twice. If I reuse a key with different parameters, Stripe returns an error. Stripe also forgets keys after a while, so I do not use them as a permanent record. My own table remains the record. The full design is in the idempotent payments lesson.
Your webhook signature check fails in production but works with the Stripe CLI. What do you check? (Trap)
First I check the raw body. If a JSON parser ran before the handler, the bytes changed and the signature cannot match. In Express I put express.raw({ type: 'application/json' }) on the webhook route before express.json(). In a Next.js Route Handler I use await request.text(). Second, the secret: the CLI gives a local whsec_ secret and each dashboard endpoint has its own, and test and live differ. Third, a proxy or middleware that rewrites the body or compresses it. Fourth, the clock: events older than 300 seconds are rejected by default. I never "fix" it by skipping verification. In SDK 23, passing a tolerance of 0 skips the time check, which hides a clock problem rather than solving it.
The same webhook arrives twice and events come out of order. How do you design the handler? (Senior signal)
I assume both will happen. I verify the signature on the raw body. Then in one database transaction I insert the event id into a table with a unique key, and if it already exists I stop and answer 200. In the same transaction I apply the change. For order, I never assume it. I either apply an event only if it is newer than the stored one, or, better, I treat the event as a hint and fetch the current object from the API and store that. Timestamps have one-second resolution in Stripe, so two events can tie, which is another reason to fetch. Side effects like emails go through an outbox table with their own keys. A nightly reconciliation repairs anything that was lost. I tested a version of this with signed requests: a late past_due event did not overwrite a newer active state, and a repeated event was reported as a duplicate.
Your webhook endpoint was down for six hours. What now? (Senior signal)
First I get it healthy again. Stripe keeps retrying a failed event with growing waits for about three days in live mode (per its docs), so most events will arrive on their own, and they will come in a bunch and in odd order. That is why the handler is idempotent and order-safe. I watch the delivery log in the dashboard for events that are still failing and resend them from there. If the outage was longer than the retry window, I run my reconciliation: list events or objects from the API for the period (invoices, checkout sessions, subscriptions) and compare with my database. I also look at why I did not know sooner, and add an alert on the failure rate of the endpoint and on "no webhook received for N minutes".
A customer upgrades in the middle of the month. How does proration work? (Senior signal)
Proration means charging or crediting only for the part of the period that was used. If a customer pays 30 on the 1st and moves to a 60 plan on day 16, they get about 15 credit for the unused half of the old plan and a charge of about 30 for the second half of the new one. In Stripe, I change the existing subscription item, I do not add a second item. The proration_behavior option decides the timing: create_prorations (default) adds the amounts to the next invoice, always_invoice invoices and tries to collect now, and none skips proration. For an upgrade I usually pick always_invoice so they pay now and get access now. For a downgrade I often let it take effect at the end of the period, to avoid credit complexity. I also tell the user the amount before they confirm.
Which events does a subscription app handle, and what does "dunning" mean? (Must know)
I handle checkout.session.completed to start the subscription, invoice.paid to give or extend access, invoice.payment_failed to warn the user, invoice.payment_action_required for 3DS, customer.subscription.updated for plan or status changes, and customer.subscription.deleted to remove access. I also watch charge.refunded and charge.dispute.created. I base access on the stored subscription status (active or trialing give access; past_due gets a grace period I choose; canceled and unpaid do not). Dunning is the process of retrying a failed renewal and emailing the customer. I use Stripe's retry and email settings first, because they are tested, and I add my own in-app banner. I let users manage plans in the Customer Portal.
What is a presigned URL, and what does it not protect you from? (Must know)
It is a normal S3 URL with a signature and an expiry, made by my server with its credentials. It allows one action, for example uploading one key, until it expires, and the browser needs no AWS keys. It does not protect me in these ways. It is a bearer token, so anyone who gets the URL can use it until it expires. A presigned PUT does not limit the size unless I sign an exact Content-Length. It does not check the content: the file can be anything, whatever the Content-Type says. And a client-chosen key could overwrite other users' files, so my server creates the key. For real size and type limits I use a presigned POST with content-length-range and starts-with conditions, which S3 enforces. Then I verify and scan after the upload lands.
A browser upload to a presigned URL fails, but curl works. How do you debug? (Trap)
If curl works, the signature and the URL are fine, so I suspect the browser. The first suspect is CORS: the browser sends a preflight OPTIONS request, and the bucket's CORS rules must allow my origin, the PUT method and the headers I send (like content-type). The console shows a CORS error and the network tab shows the failed preflight. The second suspect is a header mismatch: if I signed a Content-Type or Content-Length, the browser must send exactly those. The third is the checksum parameters: with the default settings in newer AWS SDK v3, the presigned URL I generated had x-amz-checksum-crc32 and a checksum algorithm in the query string, with the checksum of an empty body. I set requestChecksumCalculation: 'WHEN_REQUIRED' on the client and they disappear. The fourth is an expired URL or a clock problem. I check the response body from S3, because it names the exact reason.
How do you upload a 10 GB file from the browser? (Senior signal)
With a multipart upload. The server starts it and gets an upload id, then makes a presigned UploadPart URL for each part. Parts are 5 MiB to 5 GiB, up to 10,000 of them, so I use something like 16 MiB. The browser uploads several parts at once, and if a part fails it retries only that part, so a bad network does not restart everything. It keeps each part's ETag (which needs ExposeHeaders: ETag in the bucket CORS), and the server calls CompleteMultipartUpload with the list. I also store which parts finished, so a page reload can resume. Two things people forget: I add a lifecycle rule to abort incomplete multipart uploads, because the stored parts cost money and are invisible in normal listings, and I make sure the user can only complete uploads they started. For the full design see the file upload lesson.
How do you make user uploads safe? (Senior signal)
I never trust the name, the extension or the Content-Type. The upload goes to a private incoming prefix. A storage event starts a worker that checks the first bytes (magic bytes) against the allowed types, enforces size limits again, scans with an antivirus engine like ClamAV, and for images re-encodes them, which removes hidden content. Only then does it move the file to the clean prefix and mark the database row ready. Nobody can download a pending file. For serving, I use a separate domain or a CDN with nosniff, a correct Content-Type, and Content-Disposition: attachment for risky types, so an uploaded HTML or SVG file cannot run script on my site. The bucket stays private and the CDN reads it through an origin access control.
Explain SPF, DKIM and DMARC. (Must know)
All three are DNS records that prove an email is really from my domain. SPF lists which servers may send for the domain. DKIM adds a signature to each message, made with a private key, and the public key is in DNS under a selector, so the receiver can check the message is from me and unchanged. DMARC ties them together: it says that SPF or DKIM must pass and align with the visible From domain, and what to do if not (none, quarantine, reject), and it sends me reports. I roll out DMARC slowly: p=none first, read reports to find senders I forgot (a marketing tool, a help-desk tool), then tighten. I also watch the SPF limit of 10 DNS lookups. I tested DKIM end to end: Nodemailer signed a message, a checker with the public key passed it, and it no longer passed after the body was changed.
Your password-reset emails go to spam or never arrive. What do you check? (Senior signal)
First, authentication: are SPF, DKIM and DMARC passing and aligned for the exact From domain? The message headers on a received copy show the results. Then the provider dashboard: was the message delivered, deferred or bounced, and is the address on a suppression list? Next, reputation: do I send marketing and transactional mail from the same address and domain? I should split them, for example mail.example.com for receipts. Then content: clear subject, plain-text part, no broken links. Then volume and complaints: a high bounce or complaint rate hurts everything. If I use SES, I check I am out of the sandbox. For bounces I need the event webhook: hard bounces and complaints go on my suppression list and I never send to them again. Last, I send from a queue with retries, not in the request.
How do you protect your app from a slow or failing third-party API? (Senior signal)
I use layers. A timeout on every call (AbortSignal.timeout), because the default wait is very long and a hung provider would hold my connections. Retries with exponential backoff and full jitter, only for temporary errors and only when the call is idempotent or has a key; I obey Retry-After. A circuit breaker, for example Opossum, so that when the provider is down I stop calling and answer at once with a fallback or a clear error. In my test, after three failures the breaker opened and the next three calls never reached the provider. A bulkhead, which is a separate small pool for each provider, so a slow email service cannot use all my connections. Moving non-urgent work (email, analytics) to a queue. And I retry in one layer only: three layers of three retries make 27 calls.
The provider returns 429 Too Many Requests. What do you do? (Must know)
A 429 means I am over the provider's limit. I do not retry in a tight loop, because that makes it worse. I read Retry-After and wait at least that long, with jitter so that many workers do not wake together. Then I fix the cause. For bulk work I put jobs in a queue with a controlled rate, or a limiter, so I send at most N calls per second. I batch where the API allows it (Resend and most APIs have batch endpoints). I cache read results that do not change often. I also use webhooks instead of polling. If I have several workers, the limit must be shared between them, for example with a Redis-based limiter, not one counter per process. And I track 429 counts as a metric, so I see it coming.
How do you handle sandbox versus live and secrets across environments? (Must know)
Each environment has its own set of keys: test keys for development and CI, live keys for production only, and its own webhook endpoints and secrets, because those differ too. The mode is config, not code. I add a start-up check that fails fast if a production build sees a sk_test_ key or a development build sees sk_live_. Secrets live in the platform's secret store, never in Git, and I turn on secret scanning. Only the publishable key goes to the browser; in Next.js anything with NEXT_PUBLIC_ is public, so a secret never gets that prefix. I use restricted keys with minimal permissions where the provider offers them, one per service, so I can rotate one without breaking the others. If a key leaks, I rotate it first and investigate second. For local webhook testing I use the Stripe CLI to forward events.
Key takeaways
- The webhook decides, the browser only displays. Verify on the raw body, fulfil from the event.
- Every step must be safe to repeat. Business-based idempotency keys, an event-id table in the same transaction, and "fetch the current state" when order matters.
- Checkout first, PaymentIntents when you need control. Keep card data in Stripe's iframe to keep PCI scope small. Plan for 3DS on renewals.
- Subscriptions: change the item, choose
proration_behavioron purpose, handleinvoice.*andcustomer.subscription.*, and reconcile every night. - Uploads: presigned POST when limits matter, short expiry, server-made keys, correct CORS (with
ETagexposed),requestChecksumCalculation: 'WHEN_REQUIRED'for presigned PUT, abort old multipart uploads, scan before serving. - Email: SPF, DKIM and DMARC aligned to your From domain; split marketing from transactional; suppress hard bounces and complaints; send from a queue.
- Outside calls: timeout, jittered retry (one layer), circuit breaker, bulkheads, obey
Retry-After. - Secrets: one set per environment, restricted keys, server only, rotate first when leaked.
What's next
To practise, re-read the two design lessons linked at the top (file uploads and idempotent payments). Then explain out loud, without notes, what happens to an order when the same webhook arrives three times in the wrong order.
Published by Jainik · Last updated · How these lessons are made
Keep learning
Related series