Stripe Interview Questions · 2026

Stripe Interview Questions for Software Engineers (2026)

Before you work through Stripe interview questions, read what Stripe itself says about the process. Its careers page says very little, and what it does say is worth reading twice. Most processes include a recruiter screen, a technical or skills-based assessment and a series of interviews with the team you would be joining. Timelines vary by role, level and location: some move in a few weeks, others take longer. That is the whole official description. Everything more specific than that comes from people who went through the loop, so this page names where each detail comes from.

The official process wording comes from Stripe's careers page, checked on 1 October 2026. The round-by-round breakdown and the sample prompts come from interviewing.io's Stripe guide, checked the same day. Technical answers about idempotency, webhooks, pagination, versioning, currencies and authorization holds are checked against Stripe's documentation as it read on 1 October 2026, because a Stripe interviewer is likely to know those docs better than you do. The rest are practice questions matched to the round types. They are not a leaked question bank.

How the Stripe interview works in 2026

Stripe's careers FAQ gives the three building blocks and no more: a recruiter screen, a technical or skills-based assessment, and interviews with the team you would join. It adds that Stripe tries to work quickly and give clear feedback at every stage. For early-career roles, its emerging talent page says internship interview scheduling usually follows local academic calendars, while full-time and apprenticeship roles are hired on a rolling basis.

interviewing.io's guide fills in the engineering version. It describes four stages over roughly six weeks, or as little as two weeks with a referral:

About 6 weeks (2 with referral)Timeline
5 one-hour interviewsOnsite
Own IDE or CoderPadTools
Prohibited (per interviewing.io)AI use
Recruiter call30 min

Background, the role and what you want next. Stripe's careers FAQ describes the people who do well there as motivated by the mission, willing to fix things that don't "belong" to them, and willing to change their mind when the evidence shows they were wrong. A recruiter call is a good place to show you have read that and can point to a time you did it.

Technical phone screen1 hour

A coding interview. interviewing.io says Stripe does not ask LeetCode questions and instead uses practical problems that come from the work Stripe engineers do every day. You can use your own IDE and share your screen, or use the provided platform (CoderPad, or Whimsical for diagrams).

Second recruiter call30 min

Preparation for the onsite. Ask which rounds apply to your level and team, which language and tooling to set up, and how the integration round is run for your role.

Onsite, below Staff5 hours

Five one-hour interviews: coding, system design, bug bash, integrations and behavioral. interviewing.io describes the integration round as using the Stripe API, "based on real-world integrations."

Onsite, Staff and above5 hours

The integration round is replaced by a presentation. You write a one-pager about a past project and present it.

One rule to know before you start practising: interviewing.io states that AI use in Stripe interviews is strictly prohibited. Stripe's careers page does not mention AI tools either way, so confirm the rule for your loop with your recruiter, and practise every round below without an assistant.

A separate warning from Stripe itself: recruiters reach out through LinkedIn or an @stripe.com email address, and will never ask for payment, personal financial information or login credentials. If an "interview" asks for any of those, email careers@stripe.com to check it.

Practical coding, bug bash and integration questions

The coding rounds lean on problems you would plausibly meet in a payments codebase: parsing records, masking sensitive data, rate limiting, safe retries. Two of interviewing.io's sample prompts, the rate limiter and blurring credit card numbers in logs, are below with worked answers. The bug bash questions are practice versions of the bug classes that matter most in money-moving code. Stripe does not publish the code it uses in that round.

Easy questions

13

Add 0.10 and 0.20 in most languages and you get 0.30000000000000004. IEEE 754 floating point stores most decimal fractions as approximations, and once enough transactions run through code that keeps dollars in floats, the drift turns into reconciliation differences nobody can explain.

Stripe's API avoids the problem by taking amounts in the currency's minor unit. Its currency docs give the example directly: send 1000 to charge 10 USD, and 10 to charge 10 JPY, because JPY is a zero-decimal currency. All arithmetic stays in integers. In any coding round that touches money, reach for integer minor units before the interviewer has to ask.

python
# Floats lose precision on decimal fractions
0.10 + 0.20 == 0.30  # False: 0.30000000000000004

# Integer minor units are exact
amount_cents = 1010 + 2020  # 3030, no drift

A refund handler computes the refundable amount as the original charge minus everything already refunded. The bug sits in the loop that sums earlier refunds: its range either skips the first refund or runs one past the end, so the refundable amount is wrong by exactly one refund's value.

The fix is a fence-post check, and the better fix is to drop the index loop and sum the list directly. The follow-up is about tests. A test with zero prior refunds passes against the buggy code, so you need cases with one prior refund and with several, plus one where the requested refund equals the remaining amount exactly.

A function reads response["data"]["payment_method"]["card"]["last4"] without checking that any of the intermediate keys exist. When the payment method is a bank debit instead of a card, there is no card key and the function raises a KeyError.

The fix is defensive access at each level, or better, branching on the payment method's type before reading type-specific fields. The point worth saying out loud: payment objects legitimately change shape by payment method and by API version, so parsing code has to expect that, rather than treating it as an edge case.

Name what you cut, why you cut it and what it cost. "We shipped the webhook handler without retry logic to hit the merchant's go-live date, then added retries the next sprint after we lost two events" is an answer an interviewer can probe. "I always try to balance both" gives them nothing to work with.

Expect the follow-up "Was that the right call?" Have an honest answer. If the shortcut caused an incident, say so and say what you would do differently. The useful signal is that you saw the risk when you took it, not that every bet paid off.

Stripe's careers FAQ is unusually direct about what it looks for: people motivated by the mission, who choose challenge over comfort, who fix things outside their own area, and who change their mind when the evidence says they were wrong. A good answer connects one of those traits to something you have actually done, then to a specific part of Stripe's work.

Be specific about the work. Pick a product or problem and say what is hard about it: idempotent retries across a public API, versioning an API that millions of integrations depend on, or a ledger that has to account for every movement of money. Stripe's engineering blog and docs are public, so reading a post on the team's area before the call is cheap and it shows.

Stripe's careers page says timelines vary by role, level and location, with some processes moving in a few weeks and others taking longer. interviewing.io puts the engineering process at about six weeks, and as little as two weeks with a referral. Ask your recruiter on the first call; they know the current schedule for your team.

According to interviewing.io, yes. Candidates can use their own IDE and share their screen, or use the platform Stripe provides, CoderPad for code and Whimsical for diagrams. If you use your own setup, have a clean project ready with your language's test runner working, so the first five minutes go on the problem and not on tooling.

interviewing.io's guide says AI use in Stripe interviews is strictly prohibited. Stripe's own careers page does not address it. Treat the rule as no AI assistance unless your recruiter tells you otherwise in writing, and do your practice runs without autocomplete assistants so the real round does not feel unfamiliar.

interviewing.io says Stripe does not ask LeetCode questions and uses practical problems drawn from day-to-day engineering work instead. That does not mean data structures disappear. A rate limiter still needs a queue or a bucket, and a log-masking task still needs a careful scan. The difference is that the problem arrives as a feature, often with follow-up requirements, rather than as a puzzle.

Per interviewing.io, Staff and above candidates do a presentation instead of the integration round: you write a one-pager about a past project and present it. Pick a project where you can explain the problem, the options you rejected and why, and what happened after launch. Prepare for questions on the parts that went wrong, not only the result.

Stripe's careers FAQ says its recruiters contact candidates through LinkedIn or an @stripe.com email address, and will never ask for payment, personal financial information or login credentials at any point. If something looks off, Stripe asks you to verify the outreach by emailing careers@stripe.com.

A webhook is Stripe sending an HTTP POST to a URL you own when something happens on its side: a payment succeeds, a subscription renews, a dispute opens. Your endpoint processes the event and returns a 2xx status to acknowledge it.

Many payment state changes happen outside any request your app makes. A bank debit can take days to confirm, a subscription renews on its billing cycle, and a cardholder can open a dispute weeks later. None of that fits in a synchronous API response. Stripe's webhook docs add two details interviewers like to hear: in live mode Stripe retries failed deliveries for up to three days with exponential backoff, and an endpoint can receive the same event more than once, so you should log processed event IDs and skip repeats. The docs also warn not to rely on events arriving in order.

A Charge represents a single attempt to move money. It was the original way to take a card payment and has no real model of a flow where the customer must leave your page and come back.

A PaymentIntent tracks the whole lifecycle of a payment through statuses such as requires_payment_method, requires_confirmation, requires_action, processing, requires_capture, canceled and succeeded. Stripe's lifecycle docs say that when a payment needs extra steps such as 3D Secure authentication, the PaymentIntent moves to requires_action. That resumable state is the reason PaymentIntents exist, and it is what new integrations should build on.

Medium questions

20

interviewing.io lists a rate limiter among its Stripe sample questions. Start with a single-process version. A sliding window log keeps timestamps of recent requests in a queue, drops the ones older than the window and rejects a request when the queue is full. A token bucket refills at a fixed rate and spends one token per request, which allows short bursts.

Then expect the follow-up about multiple API servers. An in-memory limiter only works per process, so a shared limit needs a shared store and an atomic check-and-decrement. Stripe's 2017 engineering post on rate limiters says Stripe used the token bucket algorithm with Redis, and described four layers: a request rate limiter, a concurrent requests limiter, and two load shedders that protect capacity for critical traffic. Knowing that a rate limiter and a load shedder solve different problems is a good answer to "what else would you add?"

python
import threading
import time
from collections import deque

class SlidingWindowRateLimiter:
  def __init__(self, max_requests: int, window_seconds: float):
    self.max_requests = max_requests
    self.window_seconds = window_seconds
    self.requests = deque()
    self.lock = threading.Lock()

  def is_allowed(self) -> bool:
    now = time.monotonic()  # immune to wall-clock jumps
    cutoff = now - self.window_seconds
    with self.lock:
      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 True

You get a CSV of transactions with a user ID, a type (credit or debit) and an amount, and you return each user's balance. Typical extensions: flag any user whose balance goes negative at any point, or sort by timestamp first because rows arrive out of order.

Two things separate a good answer. First, keep money in integer minor units, as Stripe's API does, rather than parsing amounts into floats. Second, decide what happens to bad rows. Silently skipping a malformed row hides money; raising with the line number, or collecting errors and returning them, makes the problem visible. Say which one you chose and why.

python
import csv
import io
from collections import defaultdict

def compute_balances(csv_data: str) -> dict[str, int]:
  balances = defaultdict(int)  # minor units, e.g. cents
  reader = csv.DictReader(io.StringIO(csv_data))
  for line_no, row in enumerate(reader, start=2):
    user_id = (row.get("user_id") or "").strip()
    tx_type = (row.get("type") or "").strip().lower()
    raw_amount = (row.get("amount_cents") or "").strip()
    if not user_id or not raw_amount.isdigit():
      raise ValueError(f"line {line_no}: malformed row {row}")
    amount = int(raw_amount)
    if tx_type == "credit":
      balances[user_id] += amount
    elif tx_type == "debit":
      balances[user_id] -= amount
    else:
      raise ValueError(f"line {line_no}: unknown type {tx_type!r}")
  return dict(balances)

Each request carries an idempotency key. If the key has been seen, return the saved result instead of charging again. Stripe's own API works this way, and its docs give details you can use as requirements: all POST requests accept idempotency keys, keys can be up to 255 characters, the first result is saved whether it succeeded or failed (including 500 errors), and keys can be pruned once they are at least 24 hours old, after which a reused key is treated as a new request.

The detail most candidates miss is parameter checking. Stripe compares the incoming parameters with the original request and returns an error if they differ, which stops a client from reusing a key for a different payment. The sketch below does the same. Follow-ups: holding one lock while the payment runs serialises everything, so how would you lock per key? And with several servers, the store has to be shared, with a conditional insert (INSERT ... ON CONFLICT DO NOTHING in SQL, SET NX in Redis).

python
import json
import threading
from typing import Any, Callable

class IdempotencyError(Exception):
  pass

class IdempotentPaymentHandler:
  def __init__(self, process_fn: Callable[[dict], Any]):
    self.process_fn = process_fn
    self.saved: dict[str, tuple[str, Any]] = {}
    self.lock = threading.Lock()

  def handle(self, key: str, payload: dict) -> Any:
    fingerprint = json.dumps(payload, sort_keys=True)
    with self.lock:
      if key in self.saved:
        saved_fp, saved_result = self.saved[key]
        if saved_fp != fingerprint:
          raise IdempotencyError("key reused with different parameters")
        return saved_result
      result = self.process_fn(payload)
      self.saved[key] = (fingerprint, result)
      return result

Stripe's webhook docs describe the scheme. The Stripe-Signature header looks like t=...,v1=.... Split it on commas, then each element on the first equals sign. Build the signed payload by joining the timestamp, a period and the raw request body, compute an HMAC with SHA-256 using the endpoint's signing secret, and compare the result with the v1 signatures. The docs say to ignore any scheme that is not v1, to prevent downgrade attacks, and that Stripe's libraries use a default tolerance of 5 minutes on the timestamp.

Three points interviewers probe. Use a constant-time comparison such as hmac.compare_digest, so the check does not leak timing. Verify against the raw body bytes, because a framework that parses and re-serialises JSON breaks the signature. And the header can carry more than one v1 signature, so check all of them rather than keeping the last one a dict happens to hold.

python
import hashlib
import hmac
import time

def verify_stripe_webhook(payload: bytes, sig_header: str, secret: str,
             tolerance_seconds: int = 300) -> bool:
  timestamp, v1_sigs = None, []
  for item in sig_header.split(","):
    key, sep, value = item.strip().partition("=")
    if not sep:
      continue
    if key == "t":
      timestamp = value
    elif key == "v1":
      v1_sigs.append(value)
  if not timestamp or not timestamp.isdigit() or not v1_sigs:
    return False
  if abs(time.time() - int(timestamp)) > tolerance_seconds:
    return False
  signed_payload = timestamp.encode() + b"." + payload
  expected = hmac.new(secret.encode(), signed_payload, hashlib.sha256).hexdigest()
  return any(hmac.compare_digest(expected, sig) for sig in v1_sigs)

interviewing.io lists this as a Stripe sample question. Given log lines, replace anything that looks like a card number with a masked version that keeps only the last four digits. The naive answer, a regex for 16 digits, both over-masks (order IDs, timestamps) and under-masks (numbers written with spaces or dashes, 15-digit Amex numbers).

A better answer finds candidate runs of 13 to 19 digits that may contain single spaces or dashes, strips the separators, and only masks runs that pass the Luhn checksum. Then talk about limits: Luhn still matches some non-card numbers, so say whether you would rather over-mask or under-mask in logs (over-mask, for anything that could be a PAN), and point out that the real fix is not logging card data in the first place.

python
import re

CANDIDATE = re.compile(r"\b(?:\d[ -]?){12,18}\d\b")

def luhn_ok(digits: str) -> bool:
  total = 0
  for i, ch in enumerate(reversed(digits)):
    d = int(ch)
    if i % 2 == 1:
      d *= 2
      if d > 9:
        d -= 9
    total += d
  return total % 10 == 0

def redact(line: str) -> str:
  def mask(m: re.Match) -> str:
    raw = m.group(0)
    digits = re.sub(r"\D", "", raw)
    if not 13 <= len(digits) <= 19 or not luhn_ok(digits):
      return raw
    return "*" * (len(digits) - 4) + digits[-4:]
  return CANDIDATE.sub(mask, line)

# redact("card=4242 4242 4242 4242 ok") -> "card=************4242 ok"

interviewing.io describes the integration round as using the Stripe API, based on real-world integrations. A practice version: read line items from a local JSON file, create an object through the API, handle the response, and write a summary to an output file.

Most of the marks are for reading. Find the authentication section before writing code (Stripe's own docs show the secret key passed with HTTP basic auth, as in curl -u sk_test_...:), validate the local data before sending it, handle non-2xx responses by reading the error object rather than only the status code, and send an idempotency key on POSTs so a retry after a timeout does not create a duplicate. Narrate what you are checking in the docs as you go; the interviewer cannot see you read.

"Multiply by 100" breaks on zero-decimal currencies. Stripe's currency docs list them, including JPY, KRW, CLP and VND, and say the amount equals the charge: send 500 to charge 500 JPY. Send 50000 thinking it means 500.00 yen and you have overcharged by 100 times.

The same page has special cases that make a good follow-up. ISK and UGX moved to zero decimals, but for backward compatibility Stripe expects them as two-decimal values whose decimal part is always 00, so 5 ISK is sent as 500. HUF and TWD can be charged with two decimals, but manual payouts must be in amounts divisible by 100. (The page lists UGX among the zero-decimal currencies and in the special-case table, which is worth testing in a sandbox before trusting either.) The design answer is a per-currency rule table that every conversion goes through, using Decimal rather than float, and rejecting fractions a currency cannot carry.

python
from decimal import Decimal

ZERO_DECIMAL = {"bif", "clp", "djf", "gnf", "jpy", "kmf", "krw", "mga",
        "pyg", "rwf", "vnd", "vuv", "xaf", "xof", "xpf"}
WHOLE_UNITS_SENT_AS_TWO_DECIMAL = {"isk", "ugx"}  # decimals always 00

def to_minor_units(amount: str, currency: str) -> int:
  cur = currency.lower()
  value = Decimal(amount)
  if cur in ZERO_DECIMAL or cur in WHOLE_UNITS_SENT_AS_TWO_DECIMAL:
    if value != value.to_integral_value():
      raise ValueError(f"{cur} cannot carry fractional amounts")
    whole = int(value)
    return whole if cur in ZERO_DECIMAL else whole * 100
  cents = value * 100
  if cents != cents.to_integral_value():
    raise ValueError(f"too many decimals for {cur}: {amount}")
  return int(cents)

A customer upgrades mid-cycle, downgrades two days later and upgrades again before the invoice runs. The billing system now has three overlapping or touching periods for what should be one invoice line. Given (start, end) tuples, merge everything that overlaps or touches into the fewest non-overlapping ranges.

Sort by start, walk once, and extend the last merged range whenever the next start is at or before its end. A natural follow-up is the money: each original period carries a prorated amount, and an upgrade and a downgrade over the same days can cancel rather than add. Keep the amounts as signed integer line items and net them per merged range, instead of trying to merge amounts while merging dates.

python
def merge_billing_periods(periods: list[tuple[int, int]]) -> list[tuple[int, int]]:
  merged: list[tuple[int, int]] = []
  for start, end in sorted(periods):
    if merged and start <= merged[-1][1]:  # overlap or touch
      last_start, last_end = merged[-1]
      merged[-1] = (last_start, max(last_end, end))
    else:
      merged.append((start, end))
  return merged

A downstream gateway times out under load. Retrying immediately adds to the load that caused the timeout, and if every client retries on the same schedule they all hit the gateway together the moment it recovers.

Exponential backoff doubles the wait between attempts up to a cap, and jitter randomises it so clients that failed together do not retry together. The follow-up that matters at a payments company: only retry errors that are safe to retry, and only retry a POST that carries an idempotency key. Otherwise a timeout that happened after the charge succeeded becomes a double charge.

python
import random
import time

class TransientError(Exception):
  """Timeouts, connection resets, 5xx: safe to retry with the same idempotency key."""

def call_with_backoff(fn, max_attempts: int = 5, base_delay: float = 0.5,
           max_delay: float = 8.0):
  for attempt in range(max_attempts):
    try:
      return fn()
    except TransientError:
      if attempt == max_attempts - 1:
        raise
      delay = min(max_delay, base_delay * (2 ** attempt))
      time.sleep(random.uniform(0, delay))  # "full jitter"

A handler charges the card without first looking up the request's idempotency key. When the network drops after the charge succeeds but before the response arrives, the client retries, which is correct client behaviour, and the server charges again.

Nothing crashes, so candidates who scan only for exceptions miss it. Ask the question out loud: what happens to the customer if this exact request arrives twice? Then fix it by checking and reserving the key atomically before the charge, and saving the result against the key afterwards.

A delivery function catches every exception from the HTTP call, logs it, and returns success anyway. The caller treats the event as delivered and removes it from the retry queue, so failed deliveries disappear without a trace.

The fix is to return or raise a failure the retry mechanism can act on, and to count failures so an endpoint that keeps failing gets noticed. When the interviewer asks what the receiving merchant would see, the answer is: nothing. Their system silently misses events, which is the hardest kind of bug for them to report.

The function multiplies a minor-unit amount by a float exchange rate and casts the result with int(). Casting truncates toward zero, so 1100.5995 becomes 1100 instead of 1101. One cent is invisible on a single payment, but truncation only ever moves one way, so across millions of conversions it becomes a systematic shortfall.

Round instead of truncating, and state the rounding rule, because "round" is not one rule. Python's built-in round() rounds exact halves to even, while many finance teams expect half-up. Doing the multiply in Decimal with an explicit rounding mode makes the policy visible in code. For tests, pick inputs that produce a fractional part above .5 on purpose; an input that happens to produce a whole number passes against the buggy code.

python
from decimal import Decimal, ROUND_HALF_UP

int(1001 * 1.0995)    # 1100: truncates 1100.5995
round(1001 * 1.0995)   # 1101

(Decimal(1001) * Decimal("1.0995")).quantize(Decimal("1"), rounding=ROUND_HALF_UP)  # 1101

The usual options are URL versions (/v1/), a version header, or content negotiation. Stripe's current approach is a good worked example. Its versioning docs say that since the 2024-09-30.acacia release, Stripe ships a new API version every month with no breaking changes, and twice a year a major release (Basil was one) that can include breaking changes. Requests use the account's default version unless you override it with the Stripe-Version header, and webhook events use the version set when the endpoint was created. On 1 October 2026 the current version listed was 2026-09-30.endive.

The interviewer will ask what counts as breaking. Adding an optional request field or a new response field: usually not. Renaming or removing a field, changing a type, or changing an endpoint's behaviour: breaking. Adding a new enum value is the tricky one, because clients with exhaustive switches break, which is why API docs often warn integrators to expect new values.

Stripe's error object is a good reference. Its API docs list four error types: api_error (rare, a problem on Stripe's side), card_error (the most common, a card that cannot be charged), idempotency_error (a key reused with a different endpoint or parameters) and invalid_request_error (invalid parameters). Errors also carry a machine-readable code, a human-readable message and, where relevant, the param that caused it.

The design principle is that an error should tell the integrating developer what to do next. Should they ask for a different card, fix a parameter, or retry the same request with the same idempotency key? A single "payment failed" message pushes every edge case onto your support team. Design the error shape around the developer's debugging session, not only around HTTP status conventions.

This tests whether you can see what makes something hard to use, change it, and check that the change helped. Strong answers name a specific doc, abstraction or API you reworked, who used it, and what changed afterwards.

"I wrote a wiki page" is weak unless you can show it landed. Did questions in the team channel drop? Did a new hire ship faster? Did another team adopt the interface without a meeting? One concrete signal like that is worth more than a polished description.

Stripe's careers FAQ says people who do well there change their mind when the evidence shows they were wrong. This question tests both halves of that: holding a position with evidence, and updating when the evidence goes the other way. Pure deference is a weak answer, and so is stubbornness.

Expect "What was the outcome?" If you were overruled and the original plan shipped, say so and say what you learned. If you turned out to be wrong, that can be the strongest version of the story.

Stripe's careers FAQ describes ownership that "extends beyond their own expertise" and fixing things that don't belong to you. A good answer walks through a specific incident: what you did in the first half hour, the root cause, the fix, and the change that stopped the same class of problem coming back.

The retrospective is where the follow-ups go. "We added a test" is fine. "We added a test and made the deploy pipeline run a smoke test for that service" shows you fixed the system and not only the bug.

A structure that works: the decision you faced and what you did not know; how you bounded the uncertainty, with a quick experiment, a proxy metric or a stated assumption; and the trigger you set to revisit the call. "I gathered as much data as possible first" is not a process.

Finish with what happened. If the assumption turned out wrong, explain how the trigger caught it and what it cost.

The interviewer wants to know whether you stall waiting for clarity or find a way to make progress. Strong answers name the assumption you made explicit, who you checked it with, and how you structured the work so a later change cost a day instead of weeks.

Avoid turning it into a complaint about a product manager. If the requirements changed again after launch, say what that cost and what you would design differently to absorb the next change.

The power dynamic changes what a good answer looks like. Show that you can hold a technical position with your manager without caving at once or turning it into a standoff.

Name the disagreement, how you made your case (data, a prototype, a cost estimate) and what happened next. If your manager overruled you and was right, say so. If you were right and it still caused friction, describe how you handled the relationship afterwards as well as the technical result.

Hard questions

3

You get pairs such as (USD, EUR, 0.90), (EUR, GBP, 0.85), (GBP, USD, 1.35) and must say whether converting around a cycle returns more than you started with. Here it does: 0.90 × 0.85 × 1.35 is about 1.033.

Treat currencies as nodes and each rate as a directed edge weighted by the negative log of the rate. Multiplying rates around a cycle becomes adding weights, and a cycle whose product is above 1 becomes a negative-weight cycle. Run Bellman-Ford from a virtual source (start every distance at 0): if any edge still relaxes after V-1 rounds, an arbitrage cycle exists. Mention floating-point noise; production code would compare against a small epsilon so rounding in quoted rates does not trigger false positives.

python
import math

def has_arbitrage(rates: list[tuple[str, str, float]], eps: float = 1e-12) -> 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] - eps:
        dist[v] = dist[u] + w
  return any(dist[u] + w < dist[v] - eps for u, v, w in edges)

The function reads a balance, checks it covers the charge, then writes the new balance. That read-check-write has no lock or transaction around it, so two concurrent requests can both read the same balance, both pass the check, and both write, leaving the balance wrong or negative.

Fixes: a database transaction with SELECT ... FOR UPDATE on the row, an atomic conditional update (UPDATE ... SET balance = balance - :amt WHERE id = :id AND balance >= :amt, then check the affected row count), or optimistic locking with a version column. An in-process lock is the wrong answer once more than one application server is running, and saying why is part of the answer.

python
# Buggy: read, check, write with nothing making it atomic
def charge_buggy(user_id: str, amount: int, db) -> bool:
  balance = db.get_balance(user_id)
  if balance >= amount:
    db.set_balance(user_id, balance - amount)
    return True
  return False

# Fixed: lock the row inside a transaction
def charge_fixed(user_id: str, amount: int, db) -> bool:
  with db.transaction():
    balance = db.get_balance_for_update(user_id)  # SELECT ... FOR UPDATE
    if balance >= amount:
      db.set_balance(user_id, balance - amount)
      return True
    return False

Start outside your own database. It only records what your application managed to write, so check the payment provider's records for that customer and time window first. Then work out which of three things the customer is actually seeing.

One: two real payments, where the client sent the request twice without an idempotency key and your app saved only one result, for example because the first response timed out and the client retried. The provider shows two succeeded payments; the fix is idempotency keys on every POST plus a reconciliation job that compares provider records with yours. Two: one payment plus a pending authorization from an earlier failed or abandoned attempt, which shows on the card statement until the issuer releases it. Stripe's docs note that an uncaptured authorization is released when it expires. Three: an authorization and its capture shown as separate lines by the bank for a short time. Only the first needs a refund. Confirm which it is before touching code.

Real-time scenario questions

14

POST /refunds with the payment ID and an optional amount for a partial refund. Return the refund ID, amount and a status (pending, succeeded, failed), because some payment methods take days to settle a refund.

Edge cases to raise yourself: a refund larger than the remaining refundable amount (reject with a clear, machine-readable error and the param that caused it), several partial refunds adding up past the original, and a refund that times out at the network (mark it pending, resolve it asynchronously and send a webhook). An idempotency key matters as much here as on the original charge: a retried refund without one can refund the customer twice.

Offset pagination (page=2&limit=20) breaks when rows are inserted or deleted while a client pages through: rows shift, and the client skips some and sees others twice. Large offsets also get slow, because the database still walks past every skipped row.

Stripe's list endpoints use cursors instead. Its pagination docs describe starting_after and ending_before parameters that take an existing object ID and return objects in reverse chronological order, with a has_more flag in the response so the client knows whether to keep going. Explain why a cursor tied to a stable, unique key survives concurrent writes, and what the index behind it needs to look like.

A request that creates 500 invoice items cannot sensibly be all-or-nothing: a failure on item 217 should not roll back 216 good inserts, and retrying the whole batch risks creating the good ones twice. Report results per item.

Return an array in input order where each entry has its own status and, on failure, its own error code and message. Give each item an idempotency key derived from the batch key and the item's index, so a client that retries the whole batch after a timeout does not reprocess what already succeeded. Decide and document whether a failure stops the batch or lets it continue; continuing by default and letting the client inspect per-item results is the common choice.

Pick the algorithm first. A token bucket per API key handles bursts and is simple to reason about; a sliding window is more exact. With many gateway servers, the counter has to live in a shared store with an atomic check-and-decrement, for example a Redis Lua script, so two servers cannot both spend the last token.

Stripe's 2017 rate limiter post is useful here because it goes past the counter. It describes a request rate limiter and a concurrent requests limiter per user, then a fleet usage load shedder that keeps part of the infrastructure for critical requests, and a worker utilization load shedder that sheds less critical traffic when a box is overloaded. It also discusses choosing between HTTP 429 and 503. Add the operational questions: what happens when Redis is unavailable (fail open or closed?), and what headers tell clients how long to wait.

Authorization reserves funds on the card without moving money; capture moves it. Stripe's docs on placing a hold give the windows: for card-not-present payments, 7 days for customer-initiated transactions on Visa, Mastercard, American Express and Discover, but 5 days for a Visa merchant-initiated transaction (the exact Visa window is 4 days and 18 hours, to allow for clearing). In-person card holds are shorter: 2 days for Mastercard, American Express and Discover. The charge's capture_before field shows when the authorization expires, and if it expires uncaptured, the funds are released and the payment status becomes canceled. Japan-based accounts can hold some JPY transactions for up to 30 days.

So the system needs a scheduler that tracks every open authorization by expiry and captures or cancels each one in time. The edge case: capture fails, or the window passes, after the seller has already shipped. You cannot simply retry a capture on a released hold, so the design needs a fallback, such as re-authorizing, asking the buyer for another payment method, and holding the seller's payout until it resolves.

Start with the resources: customers, products and prices, subscriptions and invoices, with clear ownership. A subscription belongs to a customer and references prices; invoices are generated from subscriptions on a schedule.

Then the decisions an interviewer will push on: proration when a plan changes mid-cycle, how trials are represented, how the next billing date and upcoming invoice are exposed, and what happens when a renewal payment fails (retries, dunning emails, a past_due state, eventual cancellation). POST /subscriptions should accept an idempotency key so a retry after a network failure does not create a second subscription, and errors should be machine-readable, for example a type, a code and the param at fault.

Model connected accounts (the sellers), transfers from the platform to those accounts, and payouts from each account to its bank. Decide on push versus pull, scheduled versus on-demand payouts, minimum payout amounts, and per-currency rules (Stripe's currency docs, for example, require HUF and TWD manual payouts in amounts divisible by 100).

The hard case is a negative balance, when refunds or disputes leave a seller owing the platform. What does a payout request return then? A reasonable design rejects it with a structured error that includes the current balance, documents how the balance recovers (future sales, a debit from the seller's bank, or the platform covering it), and sends webhooks as the balance changes so the platform does not have to poll.

A single-call charge API cannot handle a customer who must leave your page, authenticate with their bank and come back. The payment has to exist in a pending, resumable state. Stripe's PaymentIntent is the reference design: an object with explicit statuses (requires_payment_method, requires_confirmation, requires_action, processing, requires_capture, canceled, succeeded), where requires_action means the customer must complete a step such as 3D Secure before anything else happens.

The interesting questions are around that state. How does the client learn it resolved (redirect back plus a webhook, never the redirect alone)? What if the customer closes the tab? The payment stays waiting, so your system must not fulfil the order, and needs a policy for cancelling abandoned attempts. And how do you stop a double-click from creating two intents for one checkout? Tie the intent to the order and reuse it.

Components: an event store written when events are created, a delivery queue that posts events to each endpoint, a retry scheduler with exponential backoff, and per-endpoint state so one slow merchant cannot hold up the rest. Stripe's own documented behaviour is a sensible target: retries in live mode for up to three days with exponential backoff, at-least-once delivery, and no ordering guarantee, so receivers deduplicate by event ID.

On the sending side, claim each delivery with a conditional update so two workers cannot send it at once, and give the claim a lease so a worker that crashes mid-send does not leave the event stuck in flight forever. Duplicates still happen when an acknowledgement is lost, which is why the receiver-side event ID check is part of the design rather than an afterthought.

python
# Pseudocode: claim with a lease, then deliver
def attempt_delivery(event_id: str, url: str, db, http, lease_seconds: int = 60) -> None:
  claimed = db.claim_if_due(event_id, lease_seconds=lease_seconds)
  if not claimed:
    return  # delivered, or another worker holds the lease
  try:
    resp = http.post(url, json=db.get_event(event_id), timeout=10)
    if 200 <= resp.status_code < 300:
      db.mark_delivered(event_id)
    else:
      db.schedule_retry(event_id)  # backoff from attempt count
  except Exception:
    db.schedule_retry(event_id)

Stripe's fraud product is Radar, and this prompt is the general version of that problem. The constraint is that scoring sits in the payment path, so it has a hard latency budget. A workable design has a feature layer (card, IP, device, recent velocity on the card and the account), a model that scores those features, and a rules layer on top that merchants or risk teams can tune.

The follow-up is where the history comes from. Velocity features need recent activity, and a database query in the payment path costs latency, so precompute and cache features keyed by card and customer with short expiry. For a card you have never seen, score with what is available and lean on rules. Close by covering the feedback loop: disputes arrive weeks later, so labels lag, and the model has to be trained and evaluated with that delay in mind.

Networks and clients retry, so "exactly once" really means at-least-once delivery with idempotent effects. Accept an idempotency key on every write, record the key and the request fingerprint before calling the payment network, and return the saved result on a repeat. If a repeat arrives while the first attempt is still running, return a conflict the client can retry later; Stripe's idempotency docs say such concurrent conflicts are not saved as results, so retrying is safe.

The partial failure case is the one to spend time on: the network approves the charge, then your database write fails. Store a reference ID before calling out, and run a reconciliation job that queries the network for every attempt left in an unknown state and settles your records to match. That job is what turns "probably once" into a guarantee you can audit.

Stripe's currency docs say merchants can charge customers in over 135 currencies and receive funds in their preferred currency, which separates three currencies per payment: the customer's payment method currency, the presentment (charge) currency and the settlement currency. A routing design has to keep all three straight.

Then the routing itself: which processing path handles a given card, currency and country, how rules are stored (a config file is fragile; a versioned rules table is auditable), how fresh FX rates must be and what happens when a rate is stale, and how you record which route and rate each payment used. That record is what support and finance need when a customer disputes an amount months later.

A balance column that gets incremented works until someone asks why a merchant is short and there is no record of the individual movements. Double-entry fixes this by construction: each event posts balanced debits and credits, entries are append-only, and a correction is a new entry rather than an edit, so the history is the audit trail.

Stripe has written about its own system. Its February 2024 post on Ledger describes it as based on double-entry bookkeeping, as an immutable log of events where published transactions cannot be deleted or modified, and as handling five billion events a day, with 99.99% of dollar volume fully ingested and verified within four days. Use that to steer the discussion to scale: how you partition by account, how balances are cached and checked against the entries, and how you detect money that entered one account and never landed in another.

interviewing.io lists Identity Access Management design among its Stripe sample questions. Scope it first: who are the principals (users in a dashboard, API keys, services), what are the resources, and what decisions must the system make (can this key create a refund on this account?).

A solid outline: roles bundling permissions, assigned per account; restricted API keys limited to specific resources and actions; a central policy check that every service calls, with results cached briefly; and an audit log of every grant, revocation and denied request. Then cover the hard parts: revoking a leaked key quickly everywhere (short cache lifetimes or push invalidation), separating test and live credentials, and letting a platform act on behalf of connected accounts without handing over their keys.

Practise the bug bash and integration rounds out loud

These two rounds are where algorithm-only preparation runs out. Both reward narration: say what you think a function is for, what you expect it to do with a retried request or a missing field, and what you are checking in the docs. An interviewer can only credit reasoning they hear.

A cheap way to practise: take a small open-source library in your interview language, read an issue you have not seen before, and talk through finding the cause for 45 minutes with a timer running. Do the same with an API you have never used, building a small script from its docs alone. Practise without AI assistance, since interviewing.io reports it is not allowed in the real rounds.

Leave a Reply

Your email address will not be published. Required fields are marked *