A senior engineer who went through Stripe's onsite in late 2025 put it this way: the first hour felt like a real workday, not an interview. She was handed a private GitHub repo, told to read the docs, and asked to wire together an integration against a payments API. No algorithm puzzle. No contrived sorting problem. Just code against a real interface with real edge cases, while the interviewer watched how she read documentation.
That description captures what makes Stripe's process unusual. The loop is built around practical engineering tasks: a bug-squash round where you debug code that resembles actual Stripe production issues, an integration round where you build against a real API with internet access allowed, and an API design round focused on developer experience, idempotency, and versioning. Whiteboard puzzles are not the game here. The company wants to see how you work, not whether you can implement Dijkstra under artificial time pressure.
This page covers what the 2026 Stripe software engineer interview loop actually looks like, based on reported candidate experiences from interviewing.io, Glassdoor, Blind, and TechPrep. Questions below are verified from those sources. I've noted where something is level-specific or role-specific.
How the Stripe interview works in 2026
The total process runs 4 to 8 weeks from first contact to verbal offer. Referrals compress that to 3 to 4 weeks in most cases. There are two distinct phases: screening and onsite. The onsite is almost always virtual, conducted over a single day or two consecutive days depending on scheduling.
Background, compensation expectations, and a conversation about your interest in Stripe specifically. Recruiters frequently probe whether you understand Stripe's API-first philosophy and its role in financial infrastructure. Generic "I like fintech" answers do not land well. Know what Stripe Radar, Stripe Connect, or Stripe Treasury actually do before this call.
Live coding with a Stripe engineer, usually on CoderPad. Expect one to two practical problems: parsing structured data, computing balances from a transaction log, or implementing a simple rate limiter. The emphasis is on clean, readable code. Interviewers ask you to walk through edge cases and often push with a follow-up sub-task partway through. AI tools are strictly prohibited and interviewers check.
You get access to a private GitHub repo, Stripe API documentation, and sometimes a local dev environment. The task is to build something against a real-world-style API: read structured data from files, call specified endpoints, handle responses, surface the right fields. Internet access is allowed and expected. The round tests how quickly you can parse documentation and how you handle ambiguous requirements, not whether you have the Stripe API memorized.
You receive roughly 200 lines of code containing 5 to 7 intentional bugs. These are generic versions of bugs Stripe has actually seen in production, according to interviewers themselves. Race conditions, missing idempotency checks, incorrect error handling, off-by-one errors in financial logic. Fixing every bug is not the goal. Interviewers want to see your debugging process and whether you can explain what each bug would cause in a real system.
Design a developer-facing API. Not an internal system, a public API that other engineers will integrate against. Interviewers probe on error codes, versioning strategy, idempotency, rate limiting headers, and developer experience. This round is unusual compared to most companies, which run generic system design. Stripe's version is specifically about what it feels like to consume the API you're designing.
Distributed systems with a payments slant. Idempotency, exactly-once delivery, distributed ledgers, reconciliation. Common prompts include designing a webhook delivery system or a fraud detection pipeline. Interviewers care about failure handling and auditability at least as much as scalability.
Ownership, disagreement handling, simplifying complexity for other engineers. Stripe's behavioral round goes deeper than standard STAR format. Interviewers will follow up on your answers for 10 to 15 minutes on a single example. The company values intellectual honesty about failures, not polished narratives where everything worked out.
The integration round is skipped for roles in the Integrations org itself. Candidates at Staff level or above sometimes skip it as well. Check with your recruiter about which rounds apply to your specific level and team.
Practical coding and integration questions
Stripe's coding problems are grounded in real engineering work. You will not get "find the kth smallest element in a BST" as a standalone problem. You will get transaction log parsing, rate limiter implementation, balance computation, or data validation tasks that would be plausible in a payments codebase. The problems tend to have multiple sub-tasks that build on each other.
One pattern reported across multiple 2024-2025 onsites: interviewers introduce new requirements partway through the problem. You might implement a basic solution, then get asked to extend it to handle concurrent writes, or to add validation for a new input format. How you adapt matters as much as your initial solution.
Easy questions
15Ask a candidate to compute 0.10 + 0.20 in most languages and something like 0.30000000000000004 comes back, not 0.30. That isn't a bug in the code. IEEE 754 floating point represents most decimal fractions as an approximation, and run enough transactions through code that stores dollars as floats and the errors compound into real discrepancies on financial statements.
Stripe's API sidesteps the problem by expressing amounts in the smallest unit of the currency: cents for USD, pence for GBP. A $10.00 charge is sent as amount=1000. All arithmetic happens on integers, so there's no float drift to chase down six months later when a monthly reconciliation is off by three cents across ten million charges. Interviewers want you to reach for this without being prompted, in any coding round that touches money.
# Floats lose precision on decimal fractions
0.10 + 0.20 == 0.30 # False: actually 0.30000000000000004
# Cents as integers are exact
amount_cents = 1010 + 2020 # 3030, no driftA refund handler computes the refund amount as the original charge amount minus any previously refunded amounts. The bug: the code sums previously refunded amounts using a range that is off by one, either skipping the first refund or double-counting the last one, depending on the implementation. This causes refunds to be over or under the correct amount by exactly one transaction's value.
The fix is a fence-post check. Reported as a genuine class of bug in financial logic: off-by-one errors in aggregation over ordered sequences are common in payment reconciliation code. Interviewers ask what tests would have caught it. The expected answer involves testing with both a single prior refund and multiple prior refunds, not just the zero-refund baseline.
A function that parses an API response accesses a nested field directly, such as response["data"]["payment_method"]["card"]["last4"], without checking whether any intermediate key is present. When the API returns a response where the payment method is a bank account instead of a card, the function crashes with a KeyError.
The fix requires defensive access: check for the key before accessing, or use .get() with a default at each level. The deeper point interviewers push on: in payments, API responses are not always the shape you expect, because payment methods vary, optional fields are omitted, and API versions change. Defensive parsing is not paranoia; it's table stakes.
Concreteness is everything here. Name what you cut. Name why you cut it. Name what it cost you. "We shipped the webhook handler without retry logic because the deadline was the merchant's go-live date, then paid down that debt in the next sprint" is the kind of specific answer that lands. "I always try to balance both" tells the interviewer nothing.
The follow-up: "Was that the right call?" Have an honest answer. If the tech debt caused an incident later, say so. Stripe interviewers do not want to hear that every trade-off worked out perfectly. They want to hear that you thought about the risk consciously, made a decision, and learned something from the outcome.
Generic answers do not work at Stripe. "I like fintech" or "Stripe is a great company" will end this conversation early. Interviewers want specificity about the engineering problems: idempotency at scale, multi-currency routing, fraud detection, the developer experience of the API itself, or infrastructure work on payment network integrations. Research the specific team you're interviewing with.
A practical move: read Stripe's engineering blog before the interview. Posts on their payment processing infrastructure, on building APIs that developers love, or on reliability engineering give you concrete talking points that signal you actually know what Stripe engineers work on, not just what Stripe the product does.
Most candidates report 4 to 8 weeks from application to verbal offer. Referrals typically compress this to 3 to 4 weeks. The onsite loop itself is usually scheduled within 1 to 2 weeks after the technical phone screen. The longest wait tends to be between the completed onsite and the final decision, which can take 1 to 2 weeks.
Yes, and Stripe expects you to. The integration round explicitly allows internet access, including API documentation and Stack Overflow. You will typically work in your own local development environment or a provided setup. This is by design: Stripe wants to see how you work with unfamiliar documentation, not whether you have the API memorized.
No. Most candidates do not find and fix all 5 to 7 bugs in 60 minutes. Interviewers evaluate your debugging process, your ability to explain what each bug causes in a real system, and how you prioritize. Candidates who find 3 bugs and articulate each one's impact clearly often score better than candidates who fix 5 bugs in silence without explaining their reasoning.
Stripe allows most common languages. Python, Ruby, Java, Go, and JavaScript are all accepted. Stripe's internal codebase is predominantly Ruby and Go, but interviewers do not require you to use either. Use the language you are most comfortable writing production-quality, readable code in. Interviewers at Stripe specifically call out readable code and descriptive variable names as evaluation criteria.
Yes, as of 2025 and into 2026, the Stripe onsite is conducted virtually. The loop typically runs over a single day or two consecutive half-days. Candidates consistently recommend having a backup internet connection ready. One candidate who went through the process in November 2025 specifically noted that a stable connection matters more than most people expect, because the integration round involves real-time interaction with a repo and external APIs.
System design is typically included at mid-level (L3 equivalent) and above. New grad loops focus on the coding round, bug bash, and integration round. Some teams include a lighter design conversation in the hiring manager round for junior roles, but a full 60-minute system design round is generally reserved for candidates with 2 or more years of experience. Check with your recruiter about the specific round composition for your level.
Rarely as a dedicated round. Most of Stripe's coding evaluation happens through the practical problems described above: transaction parsing, rate limiters, idempotent handlers. Some phone screens open with a lighter warm-up, an interval or hashmap problem, before moving into the main task, but a standalone Hard-difficulty dynamic programming or graph problem is uncommon compared to loops at Meta or Google.
The algorithm bar is generally lower. You're unlikely to see LeetCode Hard-level graph or DP problems the way you would at Meta. But the practical rounds, the bug bash and the integration round especially, require production engineering judgment that a month of pure LeetCode grinding doesn't build. Candidates who only prepped algorithms report feeling blindsided by these two rounds specifically, so total prep time ends up comparable even though the coding difficulty itself is a notch easier.
A webhook is Stripe pushing an event to a URL you own, instead of you polling Stripe to ask "did anything happen." When something changes on Stripe's side, like a charge succeeding, a subscription renewing, or a dispute being opened, Stripe fires an HTTP POST to your configured endpoint with a JSON payload describing that event. Your server processes it and returns a 200 status to acknowledge receipt.
The reason this matters beyond the initial API call is that a lot of payment state changes asynchronously and outside the request/response cycle you control. A customer's bank might take a few seconds or a few days to approve a charge, a subscription renews automatically on a billing cycle with no request from your app at all, and a dispute can be filed by a cardholder weeks after the original purchase. None of those events have a natural place to live in a synchronous API response because your code isn't the one initiating them. Webhooks give Stripe a way to tell you about things that happen on their timeline, not yours, and they're also useful as a reconciliation backstop: even if your client-side confirmation flow fails or the user closes the tab mid-checkout, the webhook still arrives and lets your backend record the truth.
A Charge is the older, simpler object: it represents a single attempt to move money from a customer to you, created and completed in essentially one API call. It works fine for cards that don't need extra verification, but it has no real concept of a multi-step flow, so it struggles with anything that requires the customer to leave your page and come back, like 3D Secure authentication.
A PaymentIntent is the object Stripe introduced to model the entire lifecycle of a payment attempt, not just the final successful moment. It tracks state through statuses like requires_payment_method, requires_confirmation, requires_action, processing, and succeeded, and it can survive a redirect for bank authentication or a retry after a card decline without you having to rebuild the whole transaction from scratch. Internally, a PaymentIntent can end up creating one or more Charge objects as it progresses (a failed attempt and then a successful one, for example), so Charges still exist under the hood, but PaymentIntents are what you should be building against for anything post-2019, since they're what supports SCA and regulatory requirements in Europe. If you see a codebase still calling Charge.create directly for new integrations, that's usually a sign it hasn't been updated to handle strong customer authentication properly.
Medium questions
21Stripe's rate limiter question is a staple of both phone screens and coding rounds. The classic approach uses a sliding window counter: store timestamps of recent requests in a queue, evict timestamps older than the window, and check whether the count exceeds the limit. Token bucket is the alternative: refill tokens at a fixed rate, deduct one per request, reject if empty.
Interviewers push on what happens under concurrent access. A single-process in-memory limiter is straightforward. A distributed limiter (across multiple API servers) requires a shared store, usually Redis, and atomic operations to avoid race conditions between the check and the decrement. The Lua script approach in Redis is commonly discussed here because it gives you atomic read-modify-write without a separate lock.
import time
from collections import deque
import threading
class SlidingWindowRateLimiter:
def __init__(self, max_requests: int, window_seconds: int):
self.max_requests = max_requests
self.window_seconds = window_seconds
self.requests = deque()
self.lock = threading.Lock()
def is_allowed(self) -> bool:
now = time.time()
cutoff = now - self.window_seconds
with self.lock:
# Evict requests outside the window
while self.requests and self.requests[0] < cutoff:
self.requests.popleft()
if len(self.requests) >= self.max_requests:
return False
self.requests.append(now)
return TrueThis is the most commonly reported phone screen problem. You receive a CSV or JSON-formatted list of transactions, each with a user ID, transaction type (credit or debit), amount, and timestamp. The task is to compute the current balance for each user. Follow-up sub-tasks: flag any user whose balance goes negative at any point, or sort transactions by timestamp before computing if they arrive out of order.
The real test is error handling. What if an amount is missing? What if the transaction type is an unknown string? Interviewers at Stripe care about production-quality validation, not just the happy path. A candidate who writes a function that silently skips malformed rows without logging or surfacing an error raises a red flag.
from collections import defaultdict
import csv
import io
def compute_balances(csv_data: str) -> dict:
balances = defaultdict(float)
reader = csv.DictReader(io.StringIO(csv_data))
required_fields = {"user_id", "type", "amount"}
for row in reader:
if not required_fields.issubset(row.keys()):
raise ValueError(f"Missing required fields in row: {row}")
user_id = row["user_id"].strip()
tx_type = row["type"].strip().lower()
try:
amount = float(row["amount"])
except ValueError:
raise ValueError(f"Invalid amount for user {user_id}: {row['amount']}")
if tx_type == "credit":
balances[user_id] += amount
elif tx_type == "debit":
balances[user_id] -= amount
else:
raise ValueError(f"Unknown transaction type: {tx_type}")
return dict(balances)The task is to write a function that processes payment requests where each request includes an idempotency key. If the same key is seen again, return the original result without re-processing. This is one of Stripe's core engineering patterns: Stripe's own API uses idempotency keys on all write operations to allow safe retries without double-charging.
Interviewers follow up on expiry: should idempotency keys live forever? In Stripe's real implementation, keys expire after 24 hours. They also probe on storage: a simple in-memory dict works for single-process scenarios, but what happens when multiple servers handle requests? The answer involves a shared store (Redis or a database) with a conditional insert, either INSERT OR IGNORE in SQL or SET NX in Redis.
from typing import Optional, Callable, Any
import threading
class IdempotentPaymentHandler:
def __init__(self, process_fn: Callable):
self.process_fn = process_fn
self.results: dict = {}
self.lock = threading.Lock()
def handle(self, idempotency_key: str, payload: dict) -> Any:
with self.lock:
if idempotency_key in self.results:
# Return cached result without re-processing
return self.results[idempotency_key]
# Process and cache
result = self.process_fn(payload)
self.results[idempotency_key] = result
return resultStripe signs webhook payloads with HMAC-SHA256 using your webhook endpoint secret. The signature is included in the Stripe-Signature header alongside a timestamp. The verification process: compute the expected signature by hashing the raw request body prefixed with the timestamp, then compare it to the signature in the header using a constant-time comparison function to prevent timing attacks.
Reported from integration round sessions in 2024-2025. The follow-up tests whether you understand why constant-time comparison matters: a standard equality check exits as soon as it finds a mismatch, which leaks timing information an attacker could use to brute-force the signature one character at a time. Python's hmac.compare_digest avoids this.
import hmac
import hashlib
import time
def verify_stripe_webhook(
payload: bytes,
sig_header: str,
secret: str,
tolerance_seconds: int = 300
) -> bool:
parts = {k: v for part in sig_header.split(",")
for k, v in [part.split("=", 1)]}
timestamp = int(parts.get("t", 0))
received_sig = parts.get("v1", "")
# Reject stale webhooks
if abs(time.time() - timestamp) > tolerance_seconds:
return False
signed_payload = f"{timestamp}.".encode() + payload
expected_sig = hmac.new(
secret.encode(), signed_payload, hashlib.sha256
).hexdigest()
return hmac.compare_digest(expected_sig, received_sig)Design and implement a priority queue where each webhook retry has a priority computed from the merchant's tier (enterprise customers go first) and how long ago the last attempt was (older retries get higher priority). The queue should support enqueue and dequeue operations and handle ties consistently.
The implementation uses Python's heapq with a tuple where the first element is the computed priority score (lower is higher priority), and the second element is a tiebreaker to prevent comparison of the payload dict. This is a real pattern in Stripe's internal queuing systems for processing retries under load.
Given a stream of Stripe Connect account events, each with an account ID, event type (created or deactivated), and a timestamp, find all account IDs where a deactivation event occurred within 24 hours of the creation event. This is a real fraud signal Stripe surfaces in their risk systems.
The clean approach: one pass to collect creation timestamps into a dict keyed by account ID, one pass to check deactivation timestamps against the creation time. Edge cases: multiple creation events for the same account (shouldn't happen but the data may be dirty), deactivation events without a matching creation, and events that arrive out of order in the stream. Interviewers ask about all three.
In the integration round, candidates are given API documentation (similar in structure to Stripe's real docs) and asked to build a small feature: read line items from a local JSON file, create a checkout session via POST to the API, handle the response, and write a summary of the result to an output file. Internet access is allowed.
The round tests reading comprehension and resourcefulness more than raw coding ability. Common failure modes: not reading the authentication section carefully (the API key goes in the Authorization header as Bearer, not Basic), not handling non-200 responses, and not validating the local data before sending it to the API. Interviewers specifically look for candidates who check the docs before assuming they know the format.
The "divide by 100" rule that works for USD and EUR breaks the moment a transaction touches a zero-decimal currency. Japanese yen, Korean won, and Chilean peso have no minor unit in practice, so Stripe's API treats the amount you send as the whole currency value: amount=1000 for a 1,000 yen charge, not 10 yen. Send 1000 expecting it to mean 10.00 yen and you've overcharged a customer by 100x.
A handful of currencies go the other direction. The Bahraini dinar and Kuwaiti dinar use three decimal places, so their smallest unit is one-thousandth of the major unit. The correct design isn't a single hardcoded divisor. It's a lookup table keyed by ISO currency code that stores the exponent for each currency, and every place in the codebase that converts between major and minor units goes through that table.
# Exponent = number of minor-unit decimal places per ISO 4217
CURRENCY_EXPONENTS = {
"usd": 2, "eur": 2, "gbp": 2,
"jpy": 0, "krw": 0, "clp": 0, # zero-decimal
"bhd": 3, "kwd": 3, "omr": 3, # three-decimal
}
def to_minor_units(amount: float, currency: str) -> int:
exponent = CURRENCY_EXPONENTS.get(currency.lower(), 2)
return round(amount * (10 ** exponent))A customer upgrades a plan mid-cycle, downgrades two days later, then upgrades again before the invoice generates. The billing system now holds three overlapping or adjacent period records for what should be one clean invoice line. Given a list of (start, end) tuples, the task is to merge everything that overlaps or touches into the fewest possible non-overlapping ranges.
Sort by start time first, then walk the list once, merging into the last range in the output whenever the next range's start falls before or at the current range's end. The twist interviewers add: merging the date ranges is the easy half. You also need to net the prorated dollar amounts attached to each original period, and those don't simply add, since an upgrade and a downgrade on overlapping days can cancel out rather than stack.
def merge_billing_periods(periods: list[tuple[int, int]]) -> list[tuple[int, int]]:
if not periods:
return []
periods.sort(key=lambda p: p[0])
merged = [periods[0]]
for start, end in periods[1:]:
last_start, last_end = merged[-1]
if start <= last_end: # overlap or touch
merged[-1] = (last_start, max(last_end, end))
else:
merged.append((start, end))
return mergedA downstream payment network occasionally times out under load. Retrying immediately just adds to the load that caused the timeout in the first place, and if every client instance retries on the same fixed schedule, you get a thundering herd hitting the gateway in lockstep the moment it recovers.
The fix layers two ideas. Exponential backoff doubles the wait between attempts (1s, 2s, 4s, 8s...) up to a cap, so retries spread out over time instead of hammering a struggling service. Jitter adds randomness to that wait so a hundred clients who all failed at the same instant don't all retry at the same instant too. Stripe's own client libraries implement roughly this pattern for retryable errors, and interviewers expect you to know why plain backoff without jitter isn't enough.
import random
import time
def call_with_backoff(fn, max_attempts: int = 5, base_delay: float = 1.0):
for attempt in range(max_attempts):
try:
return fn()
except TransientError:
if attempt == max_attempts - 1:
raise
delay = base_delay * (2 ** attempt)
jitter = random.uniform(0, delay * 0.5)
time.sleep(delay + jitter)A payment handler processes incoming requests but does not check whether the idempotency key has been seen before. A retry from the client (which is expected behavior when the network drops after the charge succeeds but before the response arrives) creates a second charge. The bug is the absence of a lookup against the idempotency store before the charge is executed.
Finding this bug requires understanding the expected behavior of the system, not just reading the code for syntax errors. Candidates who only look for crashes or exceptions miss it entirely. Interviewers explicitly want you to ask "what does this do to a customer if a request is retried?" before you finish scanning the function.
A webhook delivery function catches all exceptions from the HTTP call and logs them, but then returns a success status regardless of whether the delivery succeeded. Downstream code treats the delivery as successful and removes the job from the retry queue. Failed deliveries are silently dropped.
The fix: propagate the error or return a failure status explicitly, so the retry mechanism can re-queue the job. This type of bug is reported from Stripe bug bash sessions precisely because silent failure in webhook delivery is a real class of bug Stripe has seen in merchant integrations. The interviewer will often ask you to describe what a merchant would observe: their webhook endpoint receives no notifications, and the Stripe dashboard shows "delivered" because the bug was on Stripe's internal delivery service, not the merchant's endpoint.
The function multiplies the source amount by an exchange rate, a float, and then casts the result to an integer to get the minor-unit amount. Casting to int in most languages truncates toward zero rather than rounding, so a result of 1049.997 becomes 1049 instead of 1050. On any single transaction that's invisible. Across a few million conversions a month it's a systematic one-cent shortfall that never nets out, because truncation only ever moves in one direction.
The fix is to round to the nearest integer, not truncate, and ideally to avoid floats in the conversion path entirely by working in integer minor units with a fixed-point multiply. Interviewers ask what test would catch this: a truncation bug won't show up if your only test case happens to produce a clean number, so you need a test with an exchange rate deliberately chosen to produce a non-terminating result, like 1.0 / 3.0.
# Buggy: int() truncates toward zero
minor_units_buggy = int(1050.7 * 0.999997) # 1049, should be 1050
# Fixed: round to nearest
minor_units_fixed = round(1050.7 * 0.999997) # 1050URL versioning (/v1/, /v2/) vs. header versioning (Stripe-Version header) vs. content negotiation. Stripe uses the date-based header approach in its real API: clients pin a version date, and all breaking changes only affect clients that advance to a newer version date. This lets existing integrations run unchanged while new integrations get the improved API.
Interviewers ask what constitutes a breaking change. Adding a new optional field: not breaking. Renaming a field: breaking. Changing a field's type: breaking. Removing an error code: breaking (clients may be catching it). Changing the behavior of an existing endpoint without changing its signature: technically breaking but often treated as a bug fix. The answer matters because it determines how often you need to bump the version.
Stripe's own error format is worth knowing before this round. It uses a structured error object with type (card_error, api_error, idempotency_error), code (a machine-readable string like card_declined, incorrect_cvc), message (human-readable, not localized), and param (which request field caused the error, if applicable).
The design principles: error codes should be actionable. If a developer gets a card_declined error, they need to know whether to ask the customer to use a different card, retry with the same card, or contact their bank. Generic "payment failed" messages force developers to call Stripe support for every edge case. Interviewers want to see that you design error responses with the integrating developer's debugging workflow in mind, not just with HTTP status code conventions in mind.
Stripe cares about craft in communication, not just craft in code. This question tests whether you can identify what makes something complex, make a real change to reduce that complexity, and measure whether it worked. The strongest answers describe a specific piece of documentation, an abstraction you built, or an API you refactored to be more intuitive, with a specific example of who benefited and how.
Weak answers describe "writing a wiki page" or "giving a knowledge transfer session" without concrete evidence that it landed. If you wrote documentation, how do you know it was useful? Did someone reference it? Did the number of "how does X work?" questions drop?
Stripe wants to see intellectual honesty and the ability to hold a position with evidence. "Pushback with evidence" is the expected pattern: you formed a view, shared it clearly, and either changed your mind when you got new information, or held your ground and were proved right (or wrong, which is also fine). Pure deference to seniority is a red flag. So is stubbornness that ignores concrete data.
The follow-up is always: "What was the outcome?" Have a real answer. If you pushed back but were overruled and the original approach shipped, say so and explain what you learned. Interviewers probe whether you can update your view based on what actually happened, not just what you predicted.
The ownership principle at Stripe is not about blame. It's about the instinct to stay engaged with a problem until it's actually resolved, even when it would be easier to hand it off or declare it someone else's fault. The strongest answers describe a specific incident, what you did in the first 30 minutes, what turned out to be the root cause, what you shipped to fix it, and what structural change you made to prevent recurrence.
Interviewers at Stripe follow up on the retrospective. "What did you add to prevent this class of bug?" is almost always asked. "We added a test for that case" is acceptable. "We added a test for that case and changed the deployment process to require a staging smoke test for that service" is better.
Structure that works: first, explain what decision you were facing and what information you lacked. Second, explain how you bounded the uncertainty, through a quick experiment, a proxy metric, or an explicit assumption with a plan to validate it. Third, explain how you committed to the decision and what trigger you set to revisit it. Avoid the answer "I gathered as much data as possible before deciding." That's not a process; it's avoidance.
Stripe ships fast. Engineers make decisions with incomplete data regularly. The interviewer wants to know that you have a real framework for doing this, not that you prefer to wait for certainty.
Stripe ships fast enough that specs are frequently incomplete when engineering starts, so this question tests whether you freeze up waiting for clarity or find a way to make progress anyway. The strongest answers name the specific assumption you made explicit, who you checked it with, and how you structured the work so a changed requirement later cost you a day of rework instead of three weeks.
Avoid the trap of describing this as pure frustration, along the lines of "the PM kept changing their mind." Interviewers want evidence that you handled the ambiguity well, beyond simply surviving it. If the requirements changed again after you shipped, say what that cost and what you'd design differently to absorb the next change more cheaply.
This is a deliberately different question from the standard pushback prompt above, because the power dynamic changes what a good answer looks like. Interviewers want to know you can hold a technical position with your manager present without either caving immediately or turning it into a standoff.
Name the specific disagreement, how you presented your reasoning (data, a prototype, a cost estimate), and what actually happened next. If your manager overruled you and was right, say so plainly. If you were right and it caused friction anyway, that's a valid answer too, as long as you can describe how you handled the relationship afterward rather than just the technical outcome.
Hard questions
3You're handed pairs like (USD, EUR, 0.90), (EUR, GBP, 0.85), (GBP, USD, 1.35) and asked whether converting USD to EUR to GBP and back to USD nets more money than you started with. This shows up in interviews for teams touching Treasury or Connect, where Stripe settles balances across many currencies and a mispriced rate anywhere in the graph is a real financial exposure.
Model currencies as nodes and each rate as a directed edge weighted by the negative log of the rate. Multiplying rates around a cycle becomes summing weights around that cycle, and a cycle that multiplies to greater than 1 (a profitable loop) becomes a negative-weight cycle in log space. Run Bellman-Ford from any node: if it detects a negative cycle after V-1 relaxations, an arbitrage path exists somewhere in the graph.
import math
def has_arbitrage(rates: list[tuple[str, str, float]]) -> bool:
nodes = {c for a, b, _ in rates for c in (a, b)}
dist = {n: 0.0 for n in nodes}
edges = [(a, b, -math.log(r)) for a, b, r in rates]
for _ in range(len(nodes) - 1):
for u, v, w in edges:
if dist[u] + w < dist[v]:
dist[v] = dist[u] + w
return any(dist[u] + w < dist[v] for u, v, w in edges)A common bug bash scenario: a payment processor function reads the user's current balance, checks whether it's sufficient, and then deducts the charge amount. The bug is that this is a read-modify-write sequence without any lock or atomic operation around it. Under concurrent load, two threads can both read the same balance, both pass the sufficiency check, and both execute the deduction, resulting in the user being double-charged or the balance going negative.
The fix requires either a database-level transaction with SELECT FOR UPDATE, an optimistic locking approach (compare-and-swap on the balance), or an in-process lock. The right answer at Stripe is almost always the database transaction, because multiple application servers may be running simultaneously and an in-process lock does not span processes.
import threading
# Buggy version (race condition)
def charge_buggy(user_id: str, amount: float, db) -> bool:
balance = db.get_balance(user_id) # read
if balance >= amount: # check
db.set_balance(user_id, balance - amount) # write (not atomic!)
return True
return False
# Fixed version (database transaction)
def charge_fixed(user_id: str, amount: float, db) -> bool:
with db.transaction():
# SELECT FOR UPDATE prevents concurrent reads of same row
balance = db.get_balance_for_update(user_id)
if balance >= amount:
db.set_balance(user_id, balance - amount)
return True
return FalseThis one isn't a planted bug in a code file. It's a live debugging scenario interviewers walk through verbally, and it trips up candidates who only prepared for reading someone else's broken function. The instinct to check your own database first is reasonable but wrong as a starting point, because your database can only show you what your application successfully wrote, not what actually happened at the card network.
Start at the payment processor's own dashboard or API, since that reflects what the network actually authorized, independent of your app's bookkeeping. Two authorizations there with one Charge row in your database usually points to one of two causes: a webhook delivered twice and the second delivery's insert failed silently against a unique constraint, so your database looks clean but the network doesn't, or the client submitted the payment form twice without an idempotency key and the server processed both as separate charges before the second one had a chance to collide with anything. Ask for the merchant's server logs around the timestamp before touching any code.
Real-time scenario questions
13Resource: POST /refunds with a charge ID and optional amount (full or partial). The response should include the refund ID, amount, status, and the updated charge object (or a link to it). Status values: pending, succeeded, failed. Not all refunds are instant; some payment methods take 5 to 10 business days.
Edge cases the interviewer will probe: can you refund more than the original charge? (No, return 400 with a clear error.) Can you refund a refunded charge? (Depends on whether the original charge has remaining refundable amount.) What happens if the refund request times out at the payment network? (Mark status as pending, poll the network asynchronously, send a webhook when the status resolves.) The idempotency key is critical here too: a retried refund request without idempotency protection can double-refund the customer.
Offset pagination (page=2&limit=20) looks simple until objects are being created and deleted while a client pages through results. If a row is deleted between page 1 and page 2, everything after it shifts up by one, and the client either skips a row it never saw or sees a row twice. Offset queries also get slower as the offset grows, since the database still has to scan and discard every row before the offset.
Stripe's real API sidesteps both problems with cursor-based pagination: starting_after and ending_before take an object ID rather than a page number, and object IDs are assigned in roughly chronological, sortable order. The server response includes a has_more flag so the client knows whether to keep paging without a separate count query. Interviewers want you to explain why a cursor tied to a stable, unique field survives concurrent writes in a way a numeric offset never can.
A single request creating 500 invoice line items can't be all-or-nothing in practice, because failing item 217 shouldn't force you to roll back 216 successful inserts, and retrying the whole batch after a partial failure risks re-creating the ones that already succeeded. The response needs to report per-item outcomes, not one status code for the whole request.
Return 200 with an array matching the input order, where each entry carries its own status and, on failure, its own error code and message. Give each item its own idempotency key, derived from the batch's key plus the item's index, so a client that retries the entire batch after a timeout doesn't double-process the items that already went through. Interviewers push on ordering too: do failures in item 3 block processing of item 4, or does the batch continue past failures by default? Most designs continue by default and let the client inspect the per-item results.
Token bucket vs. sliding window. Token bucket is easier to implement and reason about for burst traffic: each API key gets a bucket with a max capacity, tokens refill at a fixed rate, each request consumes a token. Sliding window is more accurate for per-second rate limiting: count requests in the last N seconds using a Redis sorted set with timestamps as scores.
The distributed challenge: multiple API gateway servers all need to enforce the same rate limit for a given API key. The naive approach puts a Redis counter behind a Lua script (atomic read-increment-check) so there's no race condition between the check and the increment. The more scalable approach uses local counters per server with periodic sync to a central store, tolerating some overage at the edges in exchange for lower latency and Redis load. Stripe's actual implementation, per public engineering blog posts, uses a combination of both depending on tier.
Authorization reserves funds on a customer's card without moving any money, useful when a marketplace needs to confirm a card is good and has sufficient funds before, say, a driver accepts a ride or a seller ships an order that won't be fulfilled for several days. Capture is the separate step that actually moves the money, and card networks only hold an authorization open for a limited window, often around 7 days depending on the network and card type, after which it expires and needs to be re-authorized.
The system needs a scheduler tracking every open authorization with its expiry time, capturing or voiding each one before that window closes. The edge case interviewers ask about: the capture fails because the card was closed or the limit changed in the days between authorization and capture. At that point you can't just retry, since the original hold is gone. Your design needs a fallback path, typically notifying the seller and re-attempting collection through a different method, rather than silently failing the transaction after the goods have already shipped.
Start with the resource model: subscriptions, plans, customers, invoices. Keep them separate with clear ownership. A subscription belongs to a customer and references a plan. Invoices are generated from subscriptions on a schedule.
Key API design decisions Stripe interviewers probe on: how do you handle plan changes mid-cycle (proration)? How do you expose trial periods? How do you surface the next billing date? The idempotency question surfaces here: POST /subscriptions should accept an Idempotency-Key header so that a retry after a network failure does not create a duplicate subscription. Error codes should be machine-readable, not just human-readable: a 422 with a structured error body like {"code": "card_declined", "param": "payment_method_id"} is more useful to an integrating developer than a 400 with a string message.
This is a more senior API design prompt. You need to model connected accounts (sellers on the marketplace), transfers from the platform account to seller accounts, and payouts from seller accounts to their bank. Key decisions: push vs. pull payout model, scheduled vs. on-demand payouts, how minimum payout thresholds work, and how to handle negative balances (seller owes the platform money due to refunds).
Interviewers at Stripe specifically care about the negative balance case because it's a real edge case in the Connect product. If a seller has a negative balance after a large refund, what does your API return when they request a payout? The expected answer: surface a 400 with a structured error indicating insufficient balance, include the current balance in the response body, and document the expected recovery flow in your API design.
A single-call charge API works fine until a regulation shows up that requires the customer to leave your flow entirely, authenticate with their bank on a separate page, and come back before the payment can complete. This is close to what happened to Stripe with Europe's Strong Customer Authentication rules: the old Charges API modeled payment as one atomic call, and SCA needed a payment to exist in a pending, resumable state across a redirect that could take minutes or never come back at all.
The redesign models a payment as an object with an explicit state machine rather than a single request-response pair: requires_payment_method, requires_confirmation, requires_action, processing, then succeeded or failed. requires_action is the interesting state. It means the payment is real and attached to a payment method, but nothing further happens until the customer completes an out-of-band step, and your API needs a way for the client to poll or receive a webhook when that state resolves. Design this and interviewers will ask what happens if the customer closes the browser tab during requires_action. The honest answer: the payment intent just sits there until it expires, and you need a job that cleans up abandoned ones.
Core components: an event table that stores events as they're generated, a delivery queue that picks up events and attempts HTTP delivery to the merchant's endpoint, a retry scheduler that handles failures with exponential backoff, and a deduplication mechanism so that a merchant endpoint that receives the same webhook twice can detect and discard the duplicate.
Stripe sends the event ID in the webhook body. Merchants are expected to store processed event IDs and skip duplicates. But you still need deduplication on Stripe's side too, because the delivery queue may re-deliver an event that was already successfully delivered (if the acknowledgment was lost). The standard fix: mark events as delivered in the database using a conditional update that only succeeds if the current status is "pending," so a second delivery attempt finds the event already marked "delivered" and stops.
# Pseudocode: idempotent delivery with conditional update
def attempt_delivery(event_id: str, merchant_url: str, db, http) -> None:
# Atomic: only mark in_flight if currently pending
updated = db.update_if_status(
event_id, old_status="pending", new_status="in_flight"
)
if not updated:
return # Already delivered or being processed
try:
response = http.post(merchant_url, payload=db.get_event(event_id))
if response.status_code == 200:
db.update_status(event_id, "delivered")
else:
db.update_status(event_id, "pending") # Re-queue for retry
db.increment_attempt_count(event_id)
except Exception:
db.update_status(event_id, "pending")
db.increment_attempt_count(event_id)Stripe's Radar product is the real-world version of this. The constraint is that fraud scoring must complete before the payment is accepted, so latency is a hard requirement, not a nice-to-have. The architecture: a feature extraction layer that computes real-time signals from the incoming request (card BIN, IP geolocation, velocity of recent charges on this card, device fingerprint), a model scoring layer that runs a pre-trained model against those features, and a decision layer that applies rule-based thresholds on top of the model score.
The challenge the interviewer will probe: the feature extraction step needs historical data (velocity, past declines on this card). That historical data lives in a database. Reading from a database inside a synchronous payment flow adds latency. The production solution: pre-compute features for known cards and customers and cache them in Redis with a short TTL. For unknown cards (first-time use), run a fast lookup without the velocity features and fall back to rule-based scoring alone.
The fundamental problem: a payment must be executed exactly once even when the client retries and even when the system fails partway through. The approach: accept a client-supplied idempotency key on every write, store the key alongside the charge record before executing the charge, and use a conditional insert so that a duplicate key maps to the original record rather than creating a new charge. If the charge attempt is in-flight when a retry arrives, return a 409 Conflict with a status indicating the original request is still processing.
Interviewers push on what happens when the charge succeeds at the payment network but the database write of the result fails. This is the partial failure case. The system needs to be able to re-query the payment network for the status of a charge by reference ID, then reconcile the database. This is Stripe's actual reconciliation loop, and mentioning it by name scores well in this round.
Accepting payments in 135+ currencies (as Stripe does) requires routing logic that accounts for currency availability per payment method, FX conversion timing, and settlement currency of the merchant's account. The routing layer needs to pick the optimal acquirer for each transaction: some acquirers offer better interchange rates for certain card BINs or currencies, some have lower failure rates for specific geographies.
The system design question centers on how you model the routing rules (a static config file is fragile; a rules engine backed by a database is more maintainable but slower), how you handle FX rate freshness (rates that are stale by more than a few minutes can make the transaction non-profitable), and how you audit which routing decision was made for a given charge (important for reconciliation and dispute handling).
A naive design just updates a balance column: charge the customer, increment the merchant's balance by the fee-adjusted amount. It works until someone asks why a merchant's balance is short by $40,000 and there's no record of individual movements to audit, only the current number.
Double-entry accounting fixes this by construction: every event writes at least two balanced entries, a debit to one account and an equal credit to another, and the books are only valid if debits and credits sum to zero across the whole ledger. Entries are append-only and immutable; you never edit a past entry, you post a correcting entry instead, which makes the ledger a complete audit trail by default. A current balance is either the running sum of all entries for an account or a cached value reconciled against that sum on a schedule. This is close to what Stripe's own internal ledger service does, and pointing out that debits-equal-credits is itself a built-in integrity check, one that catches bugs the moment the books don't balance, is the kind of detail that separates a strong answer from a generic "I'd use a database" one.
Candidates who prep for Stripe onsites through LastRoundAI's mock sessions run into a consistent gap that prep guides do not cover well: the integration round and bug bash round feel completely different from anything in a LeetCode prep library, and candidates who spent all their prep time on algorithm problems arrive unprepared for 2 out of 5 rounds.
The bug bash round, in particular, requires a different mode of thinking. You are not generating a solution; you are understanding someone else's code well enough to predict how it fails. Candidates who do best in this round narrate their thinking out loud from the first minute, forming hypotheses like "this function doesn't handle concurrent access" and then testing them by tracing through execution paths. Candidates who silently scan the code for 10 minutes and then declare they found a bug get poor marks even when the bug is correct, because the interviewer has no evidence of a reasoning process.
The second gap: idempotency. Candidates who have not built payment systems often have to be prompted to consider idempotency in every round where it's relevant, coding, API design, and system design. At Stripe, unprompted awareness that retries can cause duplicate operations, and knowing how to prevent that, is a strong positive signal. Raise it yourself before the interviewer raises it.

