Integration Q2 - Stripe Integration Round (Build the Settlement Endpoint)
🔌 Stripe's other signature round: the integration. Where the Bug Squash is about repairing an unfamiliar codebase, the integration round is about wiring one up to a live API. You are handed a small service that is almost working, and your job is to add the missing piece, call the API correctly, reshape the data it returns, and send a result back. It is the closest thing in the loop to the actual day job at Stripe: read an API, transform some money, hand back a clean response.
This question is free, and we believe variations of it have been reported by candidates many times. Treat it as a full rehearsal: an unfamiliar repo, a real (fake) API to call, a transform to get exactly right, and a suite of failing tests that only goes green when the integration genuinely works.
🌐 What the integration round almost always is. Strip away the story and it is the same shape nearly every time: you read and parse an HTTP response, manipulate and transform it into the form the task asks for, and make a POST back to one of the interviewer's endpoints. Stripe keeps a whole set of fake, dedicated test endpoints for exactly this (the same spirit as their public test mode), so you are integrating against something that behaves like the real API without touching real money. We use a local fake endpoint here that mirrors that behaviour closely, so the shapes you parse and post look like the real thing.
Because the work is HTTP in, transform, HTTP out, pick a language with effortless HTTP and JSON support. Most candidates reach for Python for this round, and it is a strong default: urllib/requests for the calls, json.loads/json.dumps for the bodies, dictionaries for the reshaping, and a REPL to poke at the data. We use Python below for that reason. The approach is identical in any language.
How the integration round actually works
The shape candidates consistently report:
- You choose your language and IDE beforehand, share your screen, and clone a small service the interviewer gives you. It already runs; something is just missing or wired up wrong.
- You are given an API to integrate with (here, a payment processor's test API) and a short spec: fetch some data, transform it a particular way, and send the result to a specific endpoint.
- The repo ships with a suite of failing tests that encode the spec. Expect on the order of 10 to 15 failures. Some pass already (the scaffolding works), which tells you the plumbing you can trust.
- It runs about 45 to 60 minutes. Getting one clean end-to-end call working, then refining the transform, beats half-building three things.
- The single hard rule: fix the code, never the tests. The tests are the spec. Editing one to force it green is an instant fail signal.
What they are really grading. Can you read an unfamiliar API response and figure out its exact shape, reshape it without mangling the data (money, especially), and make a correct request back? They want to see you print the raw data first, reason about edge cases (signs, currencies, pagination), and build the integration in small verified steps, narrating as you go.
The method that is actually being graded
Say this loop out loud, then run it for every failing test. This is what they grade:
- Run the suite first. The red/green map tells you what is missing and gives you a way to verify every change.
- Print the raw API responses before you transform anything. You cannot reshape data whose shape you are guessing at. Five
printcalls now save twenty minutes of wrong assumptions later. - Take one failing test at a time. Read what it asserts; that is the spec. Reproduce, read the real values.
- Make the smallest change that matches the failure, then re-run. Narrate what you changed and why.
- Get to one green end-to-end call early, then tighten the details (signs, exponents, validation).
The repo you are handed
paykit-settle is a small service that produces an account's daily settlement. Once a day it should pull every balance transaction from the processor, roll them up per currency, normalize each currency into the account's settlement currency, and POST the result back. Amounts are in integer minor units (cents), the way real payment APIs represent money. Read README.md and the tests/ first: they are the spec, and fake_processor.py is a real in-process HTTP server, so your client makes genuine network calls against it.
Explorer
1# paykit-settle2 3A small service that produces the **daily settlement** for a connected account.4Once a day it pulls every balance transaction for the account, rolls them up5into a per-currency summary, normalizes each currency into the account's6settlement currency, and posts the result back to the processor.7 8```9 GET /v1/balance_transactions (paginated)10client -> GET /v1/fx/rates?base=usd11 POST /v1/settlements (the result)12```13 14## Layout15 16```17settle/18 client.py HTTP client for the processor API (GET / POST, JSON)19 rates.py FX rates provider + minor-unit exponent helpers20 models.py BalanceTransaction21 settlement.py build_settlement / run_settlement (the roll-up + transform)22 service.py tiny HTTP router that exposes the settlement endpoint23 errors.py24fake_processor.py the processor's test API (paginated txns, fx rates, settlements)25data/ the fixtures the fake processor serves26tests/ the suite (do not edit these)27run_tests.py runs the suite without pytest installed28```29 30## Running it31 32```33python3 run_tests.py # run the suite34python3 fake_processor.py # poke the test API by hand on :878735```36 37## The task38 39The /settlements/run endpoint does not exist yet, the outbound POST does not40go through, and the roll-up is wrong. Make the suite green without touching the41files under tests/. A good first move is to print the raw API responses so you42can see the exact shape of the data before you start transforming it.Three things are wrong or missing, and the README says so plainly: the /settlements/run endpoint does not exist yet, the outbound POST does not go through, and the roll-up is incorrect. Everything else (pagination on the GET, the FX/exponent helper, the models) already works and is yours to lean on.
Step 1: Run the suite and read the map
Never start reading source top to bottom. Run the tests and let the failures point you:
$ python3 run_tests.py
...FF....F.FFFFF.FFF
FAILED tests.test_client::test_post_sends_json_and_is_accepted
urllib.error.HTTPError: HTTP Error 400: Bad Request
FAILED tests.test_client::test_post_response_is_parsed_and_body_roundtrips
urllib.error.HTTPError: HTTP Error 400: Bad Request
FAILED tests.test_settlement::test_excludes_pending_transactions
AssertionError
FAILED tests.test_settlement::test_usd_line_separates_charges_from_refunds
AssertionError
FAILED tests.test_settlement::test_usd_net_subtracts_refunds_and_fees
AssertionError
FAILED tests.test_settlement::test_eur_line_is_normalized_to_settlement_currency
AssertionError
FAILED tests.test_settlement::test_jpy_zero_decimal_line_is_normalized
AssertionError
FAILED tests.test_settlement::test_total_net_is_the_sum_in_settlement_currency
AssertionError
FAILED tests.test_service::test_run_route_is_registered
AssertionError
FAILED tests.test_service::test_run_settles_end_to_end_and_posts_result
AssertionError
11 failed, 9 passed in 8.67s
Now you have a map: 11 red, 9 green across three modules. Say it out loud: "The client's POST is 400ing, so my outbound call is malformed. The settlement roll-up is wrong in several ways. And the run endpoint is not even registered. Nine pass, so the GET client, the FX helper, and the models all work. I'll go: fix the POST, fix the transform, then add the endpoint that ties them together." That ordering, plumbing then transform then wiring, is itself a strong-hire signal.
Step 2: Print the raw data before you touch it
This is the habit that separates a smooth integration from a guessing game. Before reshaping anything, start the fake processor and print exactly what the API returns. Open a REPL or write a throwaway script:
import json
from settle.client import ProcessorClient
client = ProcessorClient("http://127.0.0.1:8787") # python3 fake_processor.py in another shell
page = client.get("/v1/balance_transactions?account=acct_123&limit=4")
print(json.dumps(page, indent=2))
{
"object": "list",
"data": [
{ "id": "txn_01", "type": "charge", "amount": 20000, "currency": "usd", "fee": 610, "status": "available" },
{ "id": "txn_02", "type": "charge", "amount": 5000, "currency": "usd", "fee": 175, "status": "available" },
{ "id": "txn_03", "type": "refund", "amount": -3000, "currency": "usd", "fee": 0, "status": "available" },
{ "id": "txn_04", "type": "charge", "amount": 10000, "currency": "eur", "fee": 320, "status": "available" }
],
"has_more": true
}
Three things jump out the moment you print it, and every one is a trap if you had guessed:
for t in page["data"]:
print(t["id"], t["type"], t["amount"], t["currency"], t["status"])
txn_01 charge 20000 usd available
txn_02 charge 5000 usd available
txn_03 refund -3000 usd available <- refunds are NEGATIVE, not a separate positive number
txn_04 charge 10000 eur available <- more than one currency in the same account
"Refund amounts are already signed negative. There are multiple currencies. And has_more is true, so I have to page." Page all the way through and you also find a pending row (not yet settled, must be excluded) and a jpy row (yen has no decimal places, so 150000 means 150000 yen, not 1500.00). You only know all of this because you printed it. Say so: "Before I transform, I want to see the real data, signs, currencies, statuses, so I don't bake in a wrong assumption."
🌐 Group 1: Make the POST actually go through
Two tests fail with HTTP Error 400. The processor is rejecting the settlement POST outright, so nothing downstream can work until the outbound call is correct. Look at client.post: it stringifies the body with str(...) (Python's repr, not JSON), never sets Content-Type: application/json, and returns the raw bytes instead of parsed JSON.
settle/client.py
# before
def post(self, path, body):
data = str(body).encode("utf-8")
req = urllib.request.Request(self.base_url + path, data=data, method="POST")
req.add_header("Authorization", f"Bearer {self.api_key}")
with urllib.request.urlopen(req, timeout=5) as resp:
return resp.read()
# after
def post(self, path, body):
data = json.dumps(body).encode("utf-8")
req = urllib.request.Request(self.base_url + path, data=data, method="POST")
req.add_header("Authorization", f"Bearer {self.api_key}")
req.add_header("Content-Type", "application/json")
return self._send(req)
Say this: "Three problems in one method. str(body) produces Python repr with single quotes, which is not valid JSON, so the server 400s. There is no Content-Type, so even valid JSON would be ambiguous. And it returns raw bytes, so the caller cannot read resp["id"]. I serialize with json.dumps, declare the content type, and route through _send, which already parses the response and raises on errors." Reusing the existing _send (instead of writing a second parse-and-error path) is exactly the instinct they want to see.
Explorer
1import json2import urllib.error3import urllib.request4 5from .errors import ProcessorError6 7 8class ProcessorClient:9 """Thin HTTP client for the processor API.10 11 Every successful call returns a parsed JSON object (a dict). Non-2xx12 responses raise ProcessorError with the status and the raw body.13 """14 15 def __init__(self, base_url, api_key="sk_test_paykit"):16 self.base_url = base_url.rstrip("/")17 self.api_key = api_key18 19 def get(self, path):20 req = urllib.request.Request(self.base_url + path, method="GET")21 req.add_header("Authorization", f"Bearer {self.api_key}")22 return self._send(req)23 24 def post(self, path, body):25 data = json.dumps(body).encode("utf-8")26 req = urllib.request.Request(self.base_url + path, data=data, method="POST")27 req.add_header("Authorization", f"Bearer {self.api_key}")28 req.add_header("Content-Type", "application/json")29 return self._send(req)30 31 def _send(self, req):32 try:33 with urllib.request.urlopen(req, timeout=5) as resp:34 raw = resp.read().decode("utf-8")35 except urllib.error.HTTPError as e:36 raise ProcessorError(e.code, e.read().decode("utf-8"))37 return json.loads(raw)💱 Group 2: The settlement roll-up
This is the heart of the question: the transform. Six tests describe exactly how the roll-up should behave, and the skeleton gets two things wrong. Both are the classic money-integration mistakes.
It throws away the sign on refunds
test_usd_line_separates_charges_from_refunds expects charges and refunds tracked separately (charged 25000, refunded -3500). The code does b["charged"] += abs(t.amount), which folds refunds and negative adjustments into charges as if they were income.
settle/settlement.py
# before
for t in available:
b = buckets.setdefault(t.currency, {"charged": 0, "refunded": 0, "fees": 0})
b["charged"] += abs(t.amount)
b["fees"] += t.fee
# after
for t in available:
b = buckets.setdefault(t.currency, {"charged": 0, "refunded": 0, "fees": 0})
if t.amount >= 0:
b["charged"] += t.amount
else:
b["refunded"] += t.amount
b["fees"] += t.fee
Say this: "abs() is the tell. A refund is negative on purpose; taking its absolute value turns money going out into money coming in, so the net is overstated. I split on the sign: positives are charges, negatives are refunds and adjustments."
It sums different currencies as if they were the same
test_total_net_is_the_sum_in_settlement_currency expects one total in USD. The skeleton sets net_settlement = net (no conversion) and adds raw minor units across currencies, so 150000 yen gets added as if it were 150000 cents ($1500.00). The FX helper to fix this, rates.convert, already exists and handles exponents; the bug is that the transform never calls it.
settle/settlement.py
# before
for currency in sorted(buckets):
b = buckets[currency]
net = b["charged"] - b["fees"] # refunds never subtracted
net_settlement = net # no FX: raw minor units
lines.append({ ... })
total_net += net_settlement # adds jpy minor units to usd minor units
# after
for currency in sorted(buckets):
b = buckets[currency]
net = b["charged"] + b["refunded"] - b["fees"]
net_settlement = rates.convert(net, currency, settlement_currency)
lines.append({ ... })
total_net += net_settlement # every line normalized before summing
Say this: "You can never add two currencies directly, and minor units do not even share a scale: yen has zero decimal places, dollars have two. I convert each currency's net into the settlement currency with the helper that is already here, which divides out the exponents and applies the rate, then sum those." Pointing out that 150000 JPY is $1000.00, not $1500.00, shows you actually understand the exponent trap.
Explorer
1from .models import BalanceTransaction2 3PAGE_SIZE = 44 5 6def _fetch_all_transactions(client, account, date):7 """Page through every balance transaction for the account on a given date."""8 txns = []9 starting_after = None10 while True:11 path = f"/v1/balance_transactions?account={account}&created={date}&limit={PAGE_SIZE}"12 if starting_after is not None:13 path += f"&starting_after={starting_after}"14 payload = client.get(path)15 for row in payload["data"]:16 txns.append(BalanceTransaction.from_dict(row))17 if not payload.get("has_more"):18 break19 starting_after = payload["data"][-1]["id"]20 return txns21 22 23def build_settlement(client, rates, account, date, settlement_currency):24 """Roll the available transactions up into one settlement payload.25 26 Per currency we separate money that came in (charges) from money that went27 back out (refunds and negative adjustments), subtract the fees, and then28 normalize every currency's net into the settlement currency before summing.29 """30 txns = _fetch_all_transactions(client, account, date)31 available = [t for t in txns if t.status == "available"]32 33 buckets = {}34 for t in available:35 b = buckets.setdefault(t.currency, {"charged": 0, "refunded": 0, "fees": 0})36 if t.amount >= 0:37 b["charged"] += t.amount38 else:39 b["refunded"] += t.amount40 b["fees"] += t.fee41 42 lines = []43 total_net = 044 for currency in sorted(buckets):45 b = buckets[currency]46 net = b["charged"] + b["refunded"] - b["fees"]47 net_settlement = rates.convert(net, currency, settlement_currency)48 lines.append({49 "currency": currency,50 "charged": b["charged"],51 "refunded": b["refunded"],52 "fees": b["fees"],53 "net": net,54 "net_settlement": net_settlement,55 })56 total_net += net_settlement57 58 return {59 "account": account,60 "date": date,61 "settlement_currency": settlement_currency,62 "lines": lines,63 "total_net": total_net,64 }65 66 67def run_settlement(client, rates, account, date, settlement_currency="usd"):68 """Build the settlement and post it back to the processor."""69 payload = build_settlement(client, rates, account, date, settlement_currency)70 response = client.post("/v1/settlements", payload)71 return {72 "settlement_id": response["id"],73 "status": response["status"],74 "total_net": payload["total_net"],75 "currency": settlement_currency,76 }🧩 Group 3: Build the new endpoint
Now wire it together. The service router answers /healthz but has no /settlements/run, so every request to it 404s. Add the route: parse the JSON request body, validate the required fields, call run_settlement (which builds the settlement and POSTs it), and return the response.
settle/service.py
# before
def handle(self, method, path, body=None):
if method == "GET" and path == "/healthz":
return 200, {"status": "ok"}
return 404, {"error": f"no route for {method} {path}"}
# after
def handle(self, method, path, body=None):
if method == "GET" and path == "/healthz":
return 200, {"status": "ok"}
if method == "POST" and path == "/settlements/run":
req = json.loads(body) if isinstance(body, (str, bytes)) else (body or {})
account = req.get("account")
date = req.get("date")
if not account or not date:
return 400, {"error": "account and date are required"}
result = run_settlement(
self.client, self.rates, account, date,
req.get("settlement_currency", "usd"),
)
return 200, result
return 404, {"error": f"no route for {method} {path}"}
Say this: "The endpoint reads the request body with json.loads, guards the required fields with a 400 so a bad request fails loudly instead of deep in the transform, runs the settlement, and returns it. run_settlement already makes the outbound POST and returns the processor's id and status, so the handler just passes that back." Validating input before doing real work is a small touch that reads as production-minded.
Explorer
1import json2 3from .settlement import run_settlement4 5 6class Service:7 """The settlement service's tiny HTTP router.8 9 'handle' takes a method, a path, and a request body (a JSON string or an10 already-decoded dict) and returns a (status_code, response_dict) tuple.11 """12 13 def __init__(self, client, rates):14 self.client = client15 self.rates = rates16 17 def handle(self, method, path, body=None):18 if method == "GET" and path == "/healthz":19 return 200, {"status": "ok"}20 21 if method == "POST" and path == "/settlements/run":22 req = json.loads(body) if isinstance(body, (str, bytes)) else (body or {})23 account = req.get("account")24 date = req.get("date")25 if not account or not date:26 return 400, {"error": "account and date are required"}27 result = run_settlement(28 self.client,29 self.rates,30 account,31 date,32 req.get("settlement_currency", "usd"),33 )34 return 200, result35 36 return 404, {"error": f"no route for {method} {path}"}The result: green, with the tests untouched
The POST serializes and parses, the roll-up tracks signs and normalizes currencies, and the endpoint exists. Here is the transform on the real fixture, the thing the end-to-end test checks:
currency charged refunded fees net -> usd (settlement)
eur 14000 0 465 13535 14712
gbp 8000 0 280 7720 9772
jpy 150000 0 0 150000 100000 (150000 yen = $1000.00)
usd 25000 -3500 785 20715 20715
total = 145199 ($1451.99)
The service builds that payload, POSTs it to /v1/settlements, gets back { "id": "setl_0001", "status": "created" }, and returns it. Three files changed, every change a few lines, the tests byte-for-byte identical to when you started:
Explorer
1import json2 3from .settlement import run_settlement4 5 6class Service:7 """The settlement service's tiny HTTP router.8 9 'handle' takes a method, a path, and a request body (a JSON string or an10 already-decoded dict) and returns a (status_code, response_dict) tuple.11 """12 13 def __init__(self, client, rates):14 self.client = client15 self.rates = rates16 17 def handle(self, method, path, body=None):18 if method == "GET" and path == "/healthz":19 return 200, {"status": "ok"}20 21 if method == "POST" and path == "/settlements/run":22 req = json.loads(body) if isinstance(body, (str, bytes)) else (body or {})23 account = req.get("account")24 date = req.get("date")25 if not account or not date:26 return 400, {"error": "account and date are required"}27 result = run_settlement(28 self.client,29 self.rates,30 account,31 date,32 req.get("settlement_currency", "usd"),33 )34 return 200, result35 36 return 404, {"error": f"no route for {method} {path}"}$ python3 run_tests.py
....................
20 passed in 7.72s
A correct outbound call, a transform that respects signs and currencies, and a clean endpoint on top: that is the entire integration round in miniature.
What earns a Strong Hire
✅ Strong-Hire signals
- You run the tests first and use the failures as your map, then get one end-to-end call green before polishing.
- You print the raw API response and read its real shape (signed refunds, multiple currencies, pagination,
pending) instead of assuming. - You make the outbound call correctly:
json.dumpsplusContent-Type: application/json, and you parse the response, reusing the client's existing_sendrather than duplicating it. - You handle money like money: refunds keep their sign, and you never add two currencies without normalizing, knowing yen and dollars do not even share an exponent.
- You validate the request body before doing work, returning a clean 400 on missing fields.
- You never touch the tests, and you re-run after each change, narrating what you verified.
🚩 No-Hire signals to avoid
- Transforming data you never printed, then debugging a wrong assumption for twenty minutes.
- POSTing a Python dict or a
str(...)body and not noticing the 400, or returning raw bytes the caller cannot read. - Summing minor units across currencies, or dropping the sign on refunds with
abs(). - Building the endpoint but skipping validation, so a missing field explodes deep in the transform.
- Editing a test to force it green, or going silent when the outbound call fails.
Your game plan, start to finish
- Run the suite immediately and read the summary as a to-do list: plumbing, transform, wiring.
- Start the fake API and print the raw responses. Confirm the shape before you reshape it.
- Get the outbound call correct first (serialize the body, set the header, parse the response), so a real POST can succeed.
- Fix the transform in small steps, signs first, then currency normalization, re-running after each.
- Add the endpoint last, parse and validate the request, return the result, and confirm the end-to-end test is green.
Practice this loop, read an API, print it, reshape it, post it back, against any public test API in your strongest language until "print first, transform carefully, verify each step" is automatic. Do that, and Stripe's integration round becomes a demonstration of exactly the work they are hiring you to do. 🚀