Stripe Integration Interview Round
- Understand what the integration round really is: a live work-sample against a real payments API
- Run the order that wins it, getting one authenticated call working end to end before building anything else
- Handle the real-world concerns Stripe is watching for: auth, pagination, retries with idempotency keys, and rate limits
- Walk a worked integration in Python against a Stripe-shaped API, from a paginated GET to an idempotent POST
No other company's loop has anything like the integration round. There is no algorithm hidden in it. You clone a private starter repo, get a set of API docs, and build a small feature that talks to a real payments API over HTTP. The open internet is yours. AI assistants are not. People who have done it keep saying the same thing: it feels more like a design round than a coding round.
The most-reported task is the "request replayer." You are handed a file of recorded HTTP requests, something like requestlog-charges.json. Your job is to replay them against api.stripe.com and check that each one worked. The wrinkle that makes it real: the recorded requests and the live responses carry their own IDs and timestamps, so you cannot compare response bodies for equality. You validate on the HTTP status code instead. Get a 200 back, the replay succeeded. One constraint, and it sets the shape of the whole problem.
How Stripe frames it
This is the round that mirrors the actual job. A Stripe engineer spends real days reading an unfamiliar API, wiring a service to it, and making sure the integration holds up against real network conditions. The round manufactures exactly that situation and watches you work it in real time. No abstraction layer sits between you and it.
Working beats complete. A small integration that genuinely runs end to end outscores a sprawling half-built one that has never returned a 200. It authenticates, makes a real call, and handles one failure mode. The reports are consistent that this round is weighted more heavily than candidates expect. The people who struggle are the ones who tried to build the whole feature before proving a single call worked. Make "one real request succeeded" your first milestone, and don't let it slide to later.
The approach that wins it
The mistake is to open the repo and start writing the finished feature. The docs are large, the starter code has more in it than you need, and an hour disappears before anything has talked to the API. Run it in the opposite order. Map the surface area, get the smallest possible call working, then layer on the real-world handling once you have a foothold.
Skim, do not read
Spend five minutes on the repo tree and the docs index. You are hunting for two things: where requests get made in the starter code, and the one or two endpoints the task calls for. Ignore the rest of the docs until you need them.
Get one call working first
Before any feature logic, send a single authenticated request and print the response. Auth plus one GET that returns a 200 is the milestone that proves your client, your key, and your understanding of the docs are all correct.
Read the errors
When a call fails, the body tells you why. A 401 means the key or auth header is wrong; a 400 means the request shape is. Stripe returns structured error messages. Read them instead of guessing, because guessing against a real API burns the clock fast.
Add pagination, retries, and rate-limit handling
Now layer the real concerns on top of the working call. Page through long result sets, retry transient failures with backoff, send an Idempotency-Key on every write, and slow down when the API says you are going too fast.
Keep it clean and tested
Write the README as you go and add a couple of small tests. Stripe grades for code it would approve in a pull request, so a working feature with a clear README and one test beats a bigger one left messy.
Make every write safe to send twice. An idempotent request is one you can fire twice and get one result, no duplicate charge. You get there by attaching a unique Idempotency-Key header. If a POST times out and you retry with the same key, the API recognizes it and returns the original result instead of charging the customer again. One header. It is also one of the cheapest things you will write all round: the entire defense against a double charge is that single deduplication key riding along on the request.
A worked integration
Walk a small integration against a Stripe-shaped API the way you would in the round. We will authenticate, list events with pagination, and create a charge with an idempotency key. The HTTP client is Python's httpx, though requests works the same way. Real client, real headers, the way you would write it in the round.
Step 1: Auth and one call
Auth here is a secret key sent as HTTP basic auth, where the key is the username and the password is empty. Read the key from the environment, never hard-code it, and prove a single call comes back with a 200 before you write anything else.
import osimport httpxAPI_KEY = os.environ["STRIPE_API_KEY"] # read the secret key from the env, never hard-code itBASE_URL = "https://api.stripe.com/v1"# one client for the whole session: basic auth (key as username, empty password)client = httpx.Client(base_url=BASE_URL, auth=(API_KEY, ""), timeout=10.0)# the milestone call: one authenticated GET that should return 200resp = client.get("/balance")resp.raise_for_status() # turns a 4xx/5xx into an exception we can readprint(resp.status_code, resp.json()["object"]) # -> 200 balanceIf this prints 200 balance, the key works, the auth header is right, and you understand the response shape. That is the foothold. Everything else builds on it. If instead you get a 401, the key or the auth tuple is wrong. raise_for_status surfaces that right there, rather than letting a broken client limp into the next step.
Step 2: A paginated GET
List endpoints return at most a page of results, defaulting to a small page size, with a has_more flag and the items in a data array. Pagination is cursor-based. To get the next page you pass the id of the last item you saw as the starting_after parameter. Loop until has_more is false, and you have every record without ever guessing a page count.
def list_all_events(limit=100): """Page through every event using Stripe's cursor pagination.""" events = [] params = {"limit": limit} # ask for the largest page the API allows while True: resp = client.get("/events", params=params) resp.raise_for_status() page = resp.json() events.extend(page["data"]) if not page["has_more"]: # the API tells you when there are no more pages break params["starting_after"] = page["data"][-1]["id"] # cursor = id of the last item seen return eventsall_events = list_all_events()print(f"fetched {len(all_events)} events")has_more and the cursor, and it works for three records or thirty thousand. Skimming the docs for the pagination convention is faster than reverse-engineering it from one response.Step 3: An idempotent POST
Writes are where the round separates people. Creating a charge is a POST with form-encoded fields, and Stripe works in the smallest currency unit, so 2000 means twenty dollars, never a float. The piece that matters most: attach a unique Idempotency-Key header so a retry after a timeout cannot double-charge the customer.
import timeimport uuiddef create_charge(amount_cents, currency, source, idempotency_key): """Create a charge that is safe to retry: the same key never charges twice.""" headers = {"Idempotency-Key": idempotency_key} data = { # form-encoded; amount is in the smallest unit (cents) "amount": amount_cents, "currency": currency, "source": source, } backoff = 1.0 for attempt in range(4): # retry transient failures with exponential backoff resp = client.post("/charges", data=data, headers=headers) if resp.status_code == 429: # rate limited: the API is asking us to slow down wait = float(resp.headers.get("Retry-After", backoff)) time.sleep(wait) backoff *= 2 continue if resp.status_code >= 500: # transient server error: same key makes the retry safe time.sleep(backoff) backoff *= 2 continue resp.raise_for_status() # a 4xx here is our bug; read the error body return resp.json() raise RuntimeError("charge failed after retries")# the key is generated ONCE per logical charge, so every retry reuses itcharge = create_charge(2000, "usd", "tok_visa", idempotency_key=str(uuid.uuid4()))print(charge["id"], charge["status"]) # -> ch_... succeededIt all comes down to where the key is created. It is generated once for the logical charge and passed in, so all four retry attempts send the same key. Picture the first POST reaching Stripe while only the response gets lost on the way back. The retry hands over a key the server has already seen, and the API returns the original charge rather than making a new one. Generate a fresh key inside the loop instead, and you have rebuilt the exact double-charge bug idempotency exists to prevent.
What a strong integration round looks like
- Gets one authenticated call returning a 200 before writing any feature logic
- Skims the docs for the one endpoint the task needs, instead of reading the whole site
- Pages off
has_moreand the cursor, so the code works for any result-set size - Sends an
Idempotency-Keyon every write and reuses it across retries - Reads the error body on a failure and ships a short README plus a test or two
What a weak one looks like
- Builds the full feature first, then spends the round debugging a client that never returned a 200
- Fights the docs, guessing request shapes against a real API instead of skimming for the answer
- Assumes one page of results, or paginates a hard-coded number of times
- Generates a new idempotency key inside the retry loop, re-creating the double-charge bug
- Ignores 429s and retries instantly, hammering a rate-limited API until it gives up
A second scenario: a webhook receiver
The first walkthrough had you calling out to Stripe. The other half of a real integration is Stripe calling you. When a charge succeeds or a dispute opens, it sends an HTTP POST to an endpoint you own, carrying the event. Your job is to stand that endpoint up: take the request, confirm it really came from Stripe, parse the event, and act on it exactly once. Same round, traffic flowing the other way. It leans on the same idempotency instinct you just used on writes.
Three concerns drive the design, and they map cleanly to three pieces of the handler.
Verify the signature, because the sender is the one thing you cannot take on faith. Each webhook arrives with a header that carries a timestamp and an HMAC. The HMAC is a hash of the raw request body plus that timestamp, keyed by a secret only you and Stripe share. You recompute the same hash with your secret and compare. A match means the message came from Stripe and nobody touched it in flight. The replay guard rides along on the timestamp: reject anything older than a few minutes, so a captured-and-resent request cannot be fired at you later.
Handle it idempotently, because at-least-once delivery means duplicates are part of the contract. A webhook gets retried when Stripe does not get a fast 2xx back, and a retry can arrive even after your first attempt quietly succeeded. Every event carries a stable id like evt_..., so you dedupe on it: record the ids you have processed, and if one comes back, acknowledge it and do nothing. Same "safe to run twice" property as an idempotency key, enforced on the receiving side.
Return 2xx fast and push slow work to the background, because the sender retries on timeout. Stripe gives the delivery a short window to respond. If your handler emails a receipt or hits a payment processor inline, it blocks and the response times out. Stripe then redelivers, which multiplies the slow work you have not finished. Do the cheap, safe steps inline, verify and dedupe and enqueue, then return 200 and let a worker do the rest.
Verify, dedupe, dispatch
Here is a small handler in idiomatic Python, written framework-agnostic so the shape is clear, then wired to a Flask route. The order is deliberate. Read the raw bytes, then verify the signature over those exact bytes. Check the timestamp, dedupe on the event id, hand off the work, and return 200. The signature compare uses hmac.compare_digest so a timing side channel cannot leak the secret.
import hashlibimport hmacimport jsonimport osimport timeWEBHOOK_SECRET = os.environ["STRIPE_WEBHOOK_SECRET"] # shared secret, from the envTOLERANCE_SECONDS = 300 # reject events older than 5 minutes# stand-in for a durable store (Redis SET, a unique column, etc.)seen_event_ids = set()def parse_signature_header(header): """Pull the timestamp and the v1 signature out of Stripe's Stripe-Signature header.""" parts = dict(item.split("=", 1) for item in header.split(",")) return parts["t"], parts["v1"]def verify_signature(raw_body, signature_header, secret, tolerance=TOLERANCE_SECONDS): """Return True only if the HMAC matches the raw body AND the timestamp is recent.""" timestamp, expected_sig = parse_signature_header(signature_header) # replay guard: a captured request resent later is too old to accept if abs(time.time() - int(timestamp)) > tolerance: return False # recompute the HMAC over "<timestamp>.<raw_body>", exactly as Stripe signs it signed_payload = f"{timestamp}.".encode() + raw_body computed_sig = hmac.new(secret.encode(), signed_payload, hashlib.sha256).hexdigest() # constant-time compare so a timing side channel can't leak the secret return hmac.compare_digest(computed_sig, expected_sig)def handle_webhook(raw_body, signature_header): """Verify, dedupe on event id, dispatch, and return an HTTP status code.""" if not verify_signature(raw_body, signature_header, WEBHOOK_SECRET): return 400, "signature verification failed" # not from Stripe, or replayed event = json.loads(raw_body) # safe to parse now: we trust it event_id = event["id"] if event_id in seen_event_ids: # at-least-once: we've seen this one return 200, "duplicate ignored" # ack so Stripe stops retrying seen_event_ids.add(event_id) # record BEFORE handing off enqueue_for_processing(event) # slow work runs in a worker, not here return 200, "ok" # fast 2xx; Stripe won't redeliverdef enqueue_for_processing(event): """Push to a queue and return immediately; a background worker does the real work.""" queue.put(event["type"], event["data"])# --- wired to a Flask route ---from flask import Flask, requestapp = Flask(__name__)@app.post("/webhooks/stripe")def stripe_webhook(): raw_body = request.get_data() # RAW bytes, not request.json signature_header = request.headers.get("Stripe-Signature", "") status, message = handle_webhook(raw_body, signature_header) return message, statusThe load-bearing detail is that the signature is checked against request.get_data(), the raw bytes off the wire. Use a re-serialized copy of the parsed JSON and you lose. Parsing and re-dumping the body reorders keys and changes whitespace, so the bytes you hash no longer match the bytes Stripe hashed, and every signature fails. Verify first on the raw body, then parse once you have decided to trust it.
A webhook receiver that holds up
- Verifies the HMAC over the raw request bytes, before parsing anything
- Compares signatures with
hmac.compare_digest, never== - Rejects events whose timestamp is outside the tolerance window, to block replays
- Dedupes on the event
idso a redelivered event is handled exactly once - Returns a fast 2xx and runs the slow work in a background worker
One that breaks in production
- Verifies against the parsed-then-re-serialized body, so every signature mismatches
- Compares signature strings with
==, leaking timing information about the secret - Trusts any POST to the URL, since the endpoint is public to the whole internet
- Processes every delivery, double-charging or double-fulfilling on a retry
- Emails or calls a processor inline, times out, and gets the event redelivered
What trips people up
Almost every failure here comes down to sequencing or a missed concern rather than the code itself. The recurring one is building before proving. Candidates write the whole replayer, run it, and only then discover the client was never authenticating, with most of the round already gone. Send one call first.
The other misses cluster around the real-world concerns. Pagination gets skipped because the sample data fit in a single page, so the integration silently drops everything past the first hundred records. Idempotency gets left off the writes, which is the one omission Stripe is most likely to notice on a payments API. Rate limits get ignored, so a tight retry loop hammers the API and stalls. And people fight the docs, reverse-engineering a request shape by guesswork when the answer was two clicks away in the reference. Skim for the one endpoint you need, get it working, then harden it. Run it in that order and the round mostly takes care of itself.
Key Takeaways
- ✓The integration round is a live work-sample: clone a private repo, read real API docs, and build a working feature against a payments API. The open internet is allowed; AI assistants are not.
- ✓Get one authenticated call returning a 200 before you build anything. Working beats complete, and this round is weighted more than candidates expect.
- ✓Handle the real-world concerns Stripe is watching for: auth and keys, cursor pagination, retries with backoff, and rate limits.
- ✓Send an idempotency key on every write and reuse it across retries. It is the clearest "has moved money before" signal and costs one header.
Stripe interview guideKnow what Stripe asks before you walk in.
21 coding · 4 system design · 31 behavioral questions
Get their questions first.
InterviewLegend is independent and not affiliated with or endorsed by any company it covers.