API Integration in JavaScript: Reading Responses, Handling Errors, and Stripe
Read API responses the right way: res.json() vs res.text(), error handling, timeouts, retries, React data fetching, and real Stripe payments and webhooks.
When a company puts you in an "API integration" round, they are rarely testing whether you can recite what REST stands for. They hand you a real (or realistic) API and watch how you consume it: do you read the response in the right format, check the status before you trust the body, handle the errors that actually happen in production, and structure the code so it is testable? The single skill underneath all of that is reading the response correctly, because an API can answer in JSON, plain text, binary, or multipart form data, and calling the wrong reader is the first thing that quietly breaks an integration. This guide centers on exactly that, res.json() versus res.text() and the rest, across the browser and Node, then builds outward to auth, error handling, retries, structure, and what the round is really testing. JavaScript throughout, with real examples.
How to read this guide. Each idea is introduced in plain language, then shown with real code, and where it helps for an interview, an "Interview answer" you can say almost word for word. The heart of the guide is the section on reading responses by format; everything else (REST basics, auth, errors, structure) supports building an integration that actually works. Current as of 2026, using the built-in fetch available in both modern browsers and Node.What an API Integration Actually Is
When you integrate with an API, you are a client of someone else's service. You send an HTTP request describing what you want, and the server sends back an HTTP response: a status code, some headers, and a body. Your whole job is to construct the request correctly, then read the response correctly and handle whatever comes back, including the failures.
Analogy. Calling an API is like ordering at a restaurant through a hatch. You pass a written order in (the request), and a tray comes back out (the response). Sometimes the tray has your meal (a 200 with data), sometimes a note saying "we are out of that" (a 404), sometimes "kitchen on fire, try later" (a 500), and sometimes nothing comes back at all because the hatch jammed (a network error). A good integration handles every one of those, not just the happy meal.
The request and response each have the same anatomy: a start line (method and URL, or status code), headers (metadata like content type and auth), and an optional body (the actual data). Getting the integration right means getting all three right in both directions, and reading the response body in the format the server actually used is where most bugs start.
REST Fundamentals, Fast
REST is a style of API where you act on resources (a user, an order, a product) addressed by URLs, using HTTP methods (verbs) to say what you want done. The verbs you will use:
- GET reads a resource. Safe and has no side effects.
- POST creates a resource or triggers an action. Not idempotent: sending it twice can create two things.
- PUT replaces a resource wholesale. Idempotent: sending it twice lands in the same state.
- PATCH partially updates a resource.
- DELETE removes a resource. Idempotent: deleting twice still ends deleted.
Idempotency (whether repeating a request is safe) matters enormously for error handling, because it decides whether you can retry a failed request. GET, PUT, and DELETE are naturally safe to retry; POST needs care, which is what idempotency keys (later) are for.
The response's status code tells you what happened, in families:
- 2xx success: the request worked (200 OK, 201 Created, 204 No Content).
- 3xx redirect: look elsewhere;
fetchusually follows these for you. - 4xx client error: you sent something wrong (400 bad request, 401 unauthenticated, 403 forbidden, 404 not found, 429 too many requests). Retrying the same request will not help, except 429.
- 5xx server error: their server failed (500, 502, 503, 504). Often transient and worth retrying.
Interview answer: "Which status codes are safe to retry?" Transient ones: 408 request timeout, 429 too many requests, and 500, 502, 503, 504 server errors, because the same request may succeed once the temporary condition clears. Do not retry 400, 401, 403, 404, or 422, because the request is wrong and will fail identically every time. And only auto-retry non-idempotent methods like POST if you have an idempotency key, or you risk duplicate side effects.
Anatomy of a Request
A fetch call lets you set the method, headers, and body. The essentials:
const res = await fetch("https://api.example.com/v1/orders?status=open", {
method: "POST",
headers: {
"Content-Type": "application/json", // what you are SENDING
"Accept": "application/json", // what you want BACK
"Authorization": "Bearer " + token,
},
body: JSON.stringify({ item: "widget", qty: 3 }),
});The pieces: path params identify a resource in the URL path (/orders/42), query params filter or page (?status=open&page=2), headers carry metadata and auth, and the body carries the payload you are sending. The Content-Type header declares the format of the body you send; the Accept header asks for a format back. Reading the response's own Content-Type header is how you know which reader to call, which is the next section.
Headers: Auth, Content Negotiation, and More
Headers are key-value metadata attached to a request or response, separate from the body. The body is the content; headers are the information about it: who you are, what format you are sending, what format you want back, caching, and more. They travel in both directions.
Analogy. If the request is a posted parcel, the body is what is inside the box, and the headers are everything written on the outside: the return address, "fragile," "contents: books," and your ID badge taped to the top. The courier reads the labels before ever opening the parcel.
Auth lives in headers
Authentication almost always travels in the Authorization header, in a few standard shapes. The format is Authorization: <scheme> <credentials>, where the scheme tells the server how to read what follows:
// Bearer token (OAuth2, JWT): the most common today
headers: { "Authorization": "Bearer eyJhbGciOiJIUzI1NiI..." }
// Basic auth: base64 of "username:password", over HTTPS only
headers: { "Authorization": "Basic " + btoa("user:pass") }
// API key in a custom header (name varies by provider)
headers: { "X-API-Key": "sk_live_abc123" }A header is preferred over putting the key in the URL because URLs get logged, cached, and shared, while headers are less exposed.
The headers you set on almost every request
const res = await fetch(url, {
method: "POST",
headers: {
"Authorization": "Bearer " + token, // WHO you are
"Content-Type": "application/json", // format of the body you are SENDING
"Accept": "application/json", // format you want BACK
"Idempotency-Key": crypto.randomUUID(), // safe-retry key (e.g. Stripe)
},
body: JSON.stringify(data),
});Authorizationcarries your identity/credentials.Content-Typedeclares the format of the body you send, so the server can parse it. (Reminder: forFormDatamultipart you do not set this; the runtime adds it with the boundary.)Acceptasks for a particular response format.Idempotency-Keymakes a retried POST safe (the server deduplicates on it).- Others you will meet:
User-Agent,Cookie,Cache-Control, and provider-specificX-headers.
Reading headers off the response
Responses have headers too, read with res.headers.get(...). These are how you know the body format, how long to back off, and your remaining quota:
const res = await fetch(url);
res.headers.get("content-type"); // which body reader to use (json/text/blob)
res.headers.get("retry-after"); // seconds to wait after a 429
res.headers.get("x-ratelimit-remaining"); // many APIs report remaining quota hereGotcha: auth headers and where the secret lives. Putting a token in a header is correct, but which token and where the code runs matter. A server secret (a Stripe secret key, a server API key) must only ever be attached to requests made from your server, never from browser code, because anything in a browser bundle is readable by users. Browser requests should carry only short-lived, user-scoped tokens, with the real secret kept server-side behind your own API. This is the header version of the "never hardcode secrets" rule.
Reading the Response by Format
This is the core of integrating well. A fetch response body is not a string or an object you can touch directly. It is a stream of bytes, and you turn it into something usable by calling one of five reader methods, each producing a different format:
| Method | Returns | Use for |
|---|---|---|
res.json() | a parsed JS value (object, array, etc.) | JSON APIs (the most common case) |
res.text() | a string | plain text, HTML, XML, CSV, or anything you want raw |
res.blob() | a Blob (file-like binary) | images, PDFs, audio, downloads |
res.arrayBuffer() | an ArrayBuffer (raw bytes) | low-level binary, hashing, decoding |
res.formData() | a FormData object | multipart or urlencoded form bodies |
Each returns a promise, so you await it.
Analogy. The response body is a sealed package on a conveyor belt. The five methods are five different ways to open it:json()unwraps it and assembles the contents into a labeled object,text()just reads the packing slip as plain words,blob()hands you the sealed item to save as a file,arrayBuffer()gives you the raw material to inspect byte by byte, andformData()unpacks a multi-compartment parcel. You pick the opener that matches what is actually inside, which theContent-Typelabel tells you.
res.json(): the common case
Most APIs answer in JSON. res.json() reads the whole body and parses it into a JavaScript value:
const res = await fetch("https://api.example.com/users/1");
const user = await res.json(); // { id: 1, name: "Ada", ... }
console.log(user.name);Despite the name, it does not return "JSON"; it returns the parsed result, which can be an object, an array, a string, or a number, whatever the JSON encoded. It throws if the body is not valid JSON, which is a real hazard: an API that returns an empty body or an HTML error page on failure will make res.json() throw a confusing parse error. Always check the status first (covered shortly), and consider that some endpoints legitimately return an empty body (a 204), where calling json() will fail.
res.text(): raw strings
res.text() gives you the body as a plain string, without parsing. Use it for plain text, HTML, XML, CSV, or when you want to inspect the raw body before deciding what to do:
const res = await fetch("https://api.example.com/report.csv");
const csv = await res.text(); // "id,name\n1,Ada\n2,Linus\n"A useful pattern: when you are not sure an endpoint always returns JSON, read it as text first and parse defensively, so a non-JSON error page does not throw an unhelpful error:
const raw = await res.text();
let data;
try {
data = JSON.parse(raw);
} catch {
data = raw; // it was not JSON; keep the raw text (often an error page)
}res.blob(): binary files
res.blob() returns a Blob, a file-like object of immutable binary data, ideal for images, PDFs, audio, and anything you want to save or display rather than read as text. In the browser you can turn it into a displayable or downloadable URL:
// Browser: fetch an image and show it
const res = await fetch("https://api.example.com/avatar.png");
const blob = await res.blob();
const url = URL.createObjectURL(blob);
document.querySelector("img").src = url;
// later, free it: URL.revokeObjectURL(url);In Node, you can write the same Blob to disk:
// Node: fetch a PDF and save it
import { writeFile } from "node:fs/promises";
const res = await fetch("https://api.example.com/invoice.pdf");
const buffer = Buffer.from(await res.arrayBuffer());
await writeFile("invoice.pdf", buffer);res.arrayBuffer(): raw bytes
res.arrayBuffer() gives you the body as an ArrayBuffer, the lowest-level representation of binary data. Reach for it when you need the actual bytes: decoding audio, computing a hash, or (as above in Node) converting to a Buffer to write a file.
const res = await fetch("https://api.example.com/audio.mp3");
const bytes = await res.arrayBuffer(); // ArrayBuffer of raw bytes
// e.g. feed into the Web Audio API, or hash it, etc.res.formData(): form and multipart bodies
res.formData() parses a response body encoded as multipart/form-data or application/x-www-form-urlencoded into a FormData object you can iterate. You see this less often when reading responses (it is more common when a server consumes an upload), but it exists and is the correct reader when the Content-Type says so:
const res = await fetch("https://api.example.com/form-echo");
const form = await res.formData();
for (const [key, value] of form) {
console.log(key, value); // each field, including any File parts
}Let the Content-Type choose the reader
You do not have to guess. The response tells you its format in its Content-Type header, and reading it lets you pick the right method:
const res = await fetch(url);
const type = res.headers.get("content-type") || "";
let data;
if (type.includes("application/json")) {
data = await res.json();
} else if (type.startsWith("text/")) {
data = await res.text();
} else if (type.includes("multipart/form-data") || type.includes("x-www-form-urlencoded")) {
data = await res.formData();
} else {
data = await res.blob(); // fall back to binary
}The one-read rule (the biggest gotcha). The response body is a stream that can be consumed only once. After you call any one reader, the body is "used up" and calling a second reader throws. This trips up everyone at least once:
const text = await res.text(); // body consumed here
const json = await res.json(); // THROWS: body already usedIf you genuinely need to read the body two ways (for example, log the raw text and parse it), clone the response first, because the clone has its own independent stream:
const res = await fetch(url);
const copy = res.clone();
const raw = await copy.text(); // read the clone as text
const data = await res.json(); // read the original as JSONInterview answer: "What is the difference betweenres.json()andres.text(), and can you call both?"res.text()returns the body as a raw string;res.json()returns the body parsed into a JavaScript value, and it throws if the body is not valid JSON. You choose based on the response'sContent-Type. You cannot call both on the same response, because the body is a one-time stream that is consumed by the first reader; a second call throws. If you need both, callres.clone()first and read the clone, since it has an independent copy of the stream.
Multipart, urlencoded, and Other Formats (Sending)
Reading covers responses; you also need to send the right format. Three common request bodies:
JSON is the default for most APIs: stringify it and set the content type.
await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: "Ada" }),
});Multipart form data (for file uploads and mixed fields) uses a FormData object. The critical rule: do not set the Content-Type header yourself. The browser and Node both generate it automatically with a boundary token that delimits the parts, and if you set it manually you omit that boundary and the server cannot parse the body.
const form = new FormData();
form.append("title", "Vacation photo");
form.append("file", fileObject, "beach.jpg"); // a File/Blob in the browser
await fetch(url, {
method: "POST",
body: form, // NO Content-Type header: it is set automatically with the boundary
});In Node, FormData, Blob, and File are globals with native fetch, so the same code works; you build a Blob/File from a buffer for the file part. The same don't-set-Content-Type rule applies.
URL-encoded form data (the classic HTML form encoding) uses URLSearchParams:
const body = new URLSearchParams({ username: "ada", remember: "true" });
await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body, // serializes to "username=ada&remember=true"
});Gotcha: the manualContent-Typeon multipart. Setting"Content-Type": "multipart/form-data"yourself is one of the most common upload bugs. Without the auto-generated boundary, the request body is unparseable and the server often receives what looks like an empty body, with no error thrown. Pass theFormDataas the body and let the runtime set the header.
Streaming a Large Response
For very large responses you may not want to buffer the whole thing in memory with json() or text(). The response body is a ReadableStream, so you can process it chunk by chunk:
const res = await fetch("https://api.example.com/huge-export.ndjson");
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
// process complete lines out of `buffer` as they arrive
}This keeps memory flat regardless of response size, the same streaming principle that matters for large file handling generally.
Error Handling, as a First-Class Concern
This is the section that wins the round, because handling failures well is exactly what separates a real integration from a demo. The foundational fact about fetch:
fetchdoes not reject on HTTP errors. A 404 or 500 still resolves the promise; the request technically succeeded in reaching the server and getting an answer.fetchonly rejects when the request never completed at all (network down, DNS failure, timeout, CORS block). So you must checkres.ok(or the status) yourself; otherwise you sail straight past a 500 and try to parse an error page as your data.
const res = await fetch(url);
if (!res.ok) {
// res.ok is true only for 2xx. Handle the error BEFORE reading the body as success.
throw new Error(`Request failed: ${res.status} ${res.statusText}`);
}
const data = await res.json();This gives you the two distinct failure categories to handle:
- HTTP errors: the request completed but returned a 4xx or 5xx.
res.okis false. You read the status to decide what to do. - Transport errors: the request never completed.
fetchrejects, so atry/catchcatches it. This is your network failures, timeouts, and CORS blocks.
A complete wrapper handles both:
async function request(url, options) {
let res;
try {
res = await fetch(url, options);
} catch (err) {
// transport-level failure: network, DNS, timeout, CORS
throw new Error(`Network error reaching ${url}: ${err.message}`);
}
if (!res.ok) {
// HTTP-level failure: server answered with an error status
const detail = await res.text().catch(() => "");
throw new Error(`HTTP ${res.status} from ${url}: ${detail.slice(0, 200)}`);
}
return res;
}What is AbortController? (Cancelling a request)
Before timeouts, meet the tool that powers them. AbortController is a small built-in object (in both browsers and Node) whose entire job is to cancel an in-progress async operation, most commonly a fetch. You create one, hand its signal to the operation, and later call abort() to stop it.
const controller = new AbortController();That gives you exactly two things to work with: controller.signal (an AbortSignal you pass into the operation so it can listen for a cancel) and controller.abort() (the method you call to actually cancel).
Analogy.AbortControlleris a remote with a kill switch, andcontroller.signalis the receiver you install in the device. You give the receiver (signal) to thefetch, which listens. When you press the button (abort()), the receiver fires and the fetch stops immediately, wherever it was. You hold the controller; the fetch holds the signal. They are two pieces because two parties are involved: the code that decides to cancel, and the operation that gets cancelled.
The key behavior: calling abort() makes the fetch promise reject with an error whose name is "AbortError", which is why the code throughout this guide checks err.name === "AbortError" to tell "I cancelled this on purpose" apart from "this genuinely failed."
const controller = new AbortController();
fetch("/api/slow", { signal: controller.signal }) // 1. give fetch the signal
.then(res => res.json())
.catch(err => {
if (err.name === "AbortError") {
console.log("request cancelled on purpose"); // expected after abort()
} else {
throw err; // a real failure
}
});
controller.abort(); // 2. cancel it: the fetch above immediately rejects with AbortErrorA few things worth knowing: one controller can cancel many operations if you pass the same signal to several fetches (one abort() stops them all); a signal is single-use, so you create a fresh AbortController per request (which is why the React effect later makes a new one each run); and it is not fetch-only, since many APIs accept a signal. You will use it in this guide for three things: timeouts (next), cancelling a request when a React component unmounts, and cancelling when the user navigates away or starts a new search.
Timeouts
A request with no timeout can hang forever, tying up resources and leaving users staring at a spinner. fetch has no default timeout, so you add one with AbortSignal.timeout(), a shortcut that creates a signal which auto-aborts itself after the given time (the same abort machinery, just triggered by a timer instead of by you):
const res = await fetch(url, {
signal: AbortSignal.timeout(5000), // abort after 5 seconds
});When it fires, fetch rejects with an AbortError, which your try/catch handles like any transport failure. If you need to cancel for other reasons too (say, a user-cancel and a timeout), AbortSignal.any([...]) combines several signals so any one of them aborts the request.
Gotcha: a missing timeout is a production landmine. Without one, a single slow or hung upstream can pile up requests until your own service runs out of connections or memory. Always set a timeout on outbound calls. For streaming responses (like AI APIs) size the timeout to cover the full stream, not just the first byte, or you will cut off healthy long responses.
Rate limits and retries
When you hit a rate limit you get a 429, often with a Retry-After header telling you how long to wait. Respect it. For transient failures generally, retry with exponential backoff plus jitter: wait longer after each attempt, with a random component so many clients do not all retry in lockstep and create a "thundering herd."
async function requestWithRetry(url, options = {}, maxAttempts = 4) {
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
const res = await fetch(url, { signal: AbortSignal.timeout(5000), ...options });
// Retry only transient HTTP statuses
if ([429, 500, 502, 503, 504].includes(res.status) && attempt < maxAttempts) {
const retryAfter = res.headers.get("retry-after");
const wait = retryAfter
? Number(retryAfter) * 1000 // honor the server's hint
: Math.min(2 ** (attempt - 1) * 500, 8000) * (0.5 + Math.random()); // backoff + jitter
await new Promise(r => setTimeout(r, wait));
continue;
}
return res; // success, or a non-retryable error to handle by the caller
} catch (err) {
// transport error (network/timeout): retry if attempts remain
if (attempt === maxAttempts) throw err;
await new Promise(r => setTimeout(r, 2 ** (attempt - 1) * 500 * (0.5 + Math.random())));
}
}
}Interview answer: "How do you safely retry a failed request?" Only retry transient failures: network errors, timeouts, 429, and 5xx. Never retry 4xx like 400 or 404, because they will fail identically. Use exponential backoff with jitter so retrying clients spread out instead of synchronizing into a thundering herd, honor a Retry-After header when present, cap the attempts and total time, and for non-idempotent requests like POST, attach an idempotency key so a retry does not create a duplicate.Idempotency keys for safe POST retries
Retrying a POST is dangerous because it can create two orders or charge a card twice. The fix is an idempotency key: a unique id you generate per logical operation and send as a header. The server records it and, if it sees the same key again, returns the original result instead of acting twice.
const key = crypto.randomUUID();
await fetch("https://api.example.com/charges", {
method: "POST",
headers: {
"Content-Type": "application/json",
"Idempotency-Key": key, // same key on retry = no double charge
},
body: JSON.stringify({ amount: 5000, currency: "usd" }),
});This is exactly how payment APIs make POST retries safe, and mentioning it in an interview signals real-world experience.
Authentication
Most APIs require you to prove who you are. The common schemes:
- API key: a secret string sent in a header (sometimes a query param). Simple, common for server-to-server.
- Bearer token: an
Authorization: Bearer <token>header, the standard for OAuth2 and JWT-based APIs. - Basic auth: a base64-encoded
username:passwordin theAuthorizationheader. Older, used over HTTPS only. - OAuth2: a flow where you exchange credentials for a short-lived access token (and a refresh token to get new ones), then send the access token as a Bearer token.
const res = await fetch(url, {
headers: { "Authorization": "Bearer " + accessToken },
});Gotcha: never hardcode secrets. Do not paste API keys or tokens into source code; they leak through version control, logs, and client bundles. Keep them in environment variables (server side) and never ship a server secret to the browser, where anyone can read it. If a token can expire (OAuth2), handle a 401 by refreshing the token once and retrying, rather than failing the user.
Structuring Integration Code
A revealing thing interviewers watch for: do you scatter fetch calls all over your components and modules, or do you wrap the API behind a small client module? Wrapping it means one place owns the base URL, auth, error handling, and retries, so the rest of your code calls clean methods and is easy to test (you can swap the client for a fake).
// api-client.js: one place that owns base URL, auth, errors, retries
class ApiClient {
constructor(baseUrl, getToken) {
this.baseUrl = baseUrl;
this.getToken = getToken;
}
async #request(path, options = {}) {
const res = await requestWithRetry(this.baseUrl + path, {
...options,
headers: {
"Accept": "application/json",
"Authorization": "Bearer " + this.getToken(),
...options.headers,
},
});
if (!res.ok) throw new Error(`HTTP ${res.status} on ${path}`);
return res.status === 204 ? null : res.json();
}
getUser(id) { return this.#request(`/users/${id}`); }
createOrder(data) {
return this.#request("/orders", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(data) });
}
}Now the rest of your app calls client.getUser(1), with no fetch, no auth wiring, and no error boilerplate repeated everywhere. This separation is the difference between code that survives changes and code that does not.
Payment APIs and Webhooks: A Real Stripe Example
Payment integrations are where everything in this guide gets serious, because a bug does not just show wrong data, it can double-charge a customer. Stripe is the canonical example, and it shows two patterns the round may probe: making a safe payment request, and receiving webhooks.
Creating a payment with an idempotency key
In Stripe, a one-time payment is a PaymentIntent. You create it server-side (never expose your secret key to the browser), and because creating a charge is a non-idempotent POST, you attach an idempotency key so a retried request never charges twice. Stripe's SDK takes the key as an option:
import Stripe from "stripe";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY); // secret key: server only
async function createPayment(orderId, amountCents) {
// The key is unique per logical payment attempt. If the network drops and you
// retry with the SAME key, Stripe returns the original PaymentIntent instead of
// creating a second one, so the customer is never charged twice.
const intent = await stripe.paymentIntents.create(
{ amount: amountCents, currency: "usd", metadata: { orderId } },
{ idempotencyKey: `order-${orderId}-payment` }
);
return intent.client_secret; // sent to the browser to confirm the payment
}The client_secret goes to the frontend, where Stripe's own JS library collects card details and confirms the payment, so raw card numbers never touch your server. That is the division of labor: your server creates the intent with the secret key; the browser confirms it with the publishable key.
Why the idempotency key matters here. Without it, a timeout on the create call leaves you not knowing whether the charge happened. Retry blindly and you might double-charge. With a stable key, the retry is safe: Stripe deduplicates and returns the first result. This is the concrete, high-stakes version of the idempotency idea from the error-handling section.
Receiving webhooks: the server pattern
A webhook is the reverse direction: instead of you calling the API, the API calls you when something happens (a payment succeeded, a subscription renewed). Stripe sends an HTTP POST to an endpoint you register, with the event in the body. Two rules dominate a correct webhook handler, and both are favorite interview probes.
1. Verify the signature using the raw body. Anyone can POST to your public webhook URL, so you must prove the request really came from Stripe. Stripe signs each payload and puts the signature in a Stripe-Signature header; you verify it with stripe.webhooks.constructEvent using your endpoint's signing secret. The catch that trips up almost everyone: verification needs the exact raw bytes Stripe sent, so you must read the raw body, not parsed JSON. If your framework parses the body to JSON first, the signature check fails even for genuine events.
// Express: note express.raw, NOT express.json, for this route
app.post("/webhooks/stripe", express.raw({ type: "application/json" }), (req, res) => {
const signature = req.headers["stripe-signature"];
let event;
try {
// Verifies the signature AND the timestamp (rejecting replayed old events).
event = stripe.webhooks.constructEvent(
req.body, // the RAW body, not parsed JSON
signature,
process.env.STRIPE_WEBHOOK_SECRET
);
} catch (err) {
// Forged, tampered, or wrong-secret: reject before any business logic runs.
return res.status(400).send(`Webhook Error: ${err.message}`);
}
// ... handle the verified event (next step) ...
res.json({ received: true }); // acknowledge fast with a 2xx
});2. Handle events idempotently, and acknowledge fast. Stripe guarantees at-least-once delivery, meaning the same event can arrive more than once (on retries, or even in normal operation). A handler that is not idempotent will double-fulfill orders or send duplicate emails. So you record each event.id you have processed and skip duplicates. And because Stripe marks the delivery failed if you do not return a 2xx within its timeout (and then retries for up to three days), you should acknowledge quickly and do slow work in a background job.
async function handleStripeEvent(event) {
// Idempotency: skip if we have already handled this event id.
const seen = await db.webhookEvents.findById(event.id);
if (seen) return; // duplicate delivery, do nothing
await db.webhookEvents.insert({ id: event.id }); // unique constraint also guards races
switch (event.type) {
case "payment_intent.succeeded": {
const intent = event.data.object;
await fulfillOrder(intent.metadata.orderId); // grant access, ship, etc.
break;
}
case "payment_intent.payment_failed": {
const intent = event.data.object;
// intent.last_payment_error has a human reason like "Your card was declined"
await notifyCustomerOfFailure(intent.metadata.orderId, intent.last_payment_error);
break;
}
case "invoice.payment_succeeded":
await extendSubscription(event.data.object); // a renewal paid
break;
default:
// Stripe has 200+ event types; log and ignore the ones you do not handle.
console.log(`Unhandled event type: ${event.type}`);
}
}Interview answer: "How do you securely and reliably handle a Stripe webhook?" Three things. First, verify the signature withstripe.webhooks.constructEventusing the raw request body (not parsed JSON, which breaks the signature) and your endpoint signing secret, rejecting with a 400 if it fails, so forged requests never reach your logic. Second, make the handler idempotent by recording eachevent.idand skipping duplicates, because Stripe delivers at least once and retries, so the same event can arrive twice and must not double-fulfill. Third, acknowledge fast with a 2xx and push slow work (emails, ERP syncs) to a background queue, because Stripe times out the delivery and retries if you block. Locally you test it with the Stripe CLI'sstripe listen --forward-to.
Gotcha: parsing the body before verifying. The single most common Stripe webhook failure isNo signatures found matching the expected signature, caused by JSON middleware parsing and re-serializing the body beforeconstructEventsees it. The signature is computed over the exact raw bytes, so you must read the raw body for that route specifically (express.raw, orreq.text()in frameworks like Next.js), even though the rest of your app uses JSON parsing.
The Frontend Angle: React States and Cleanup
If the integration drives a React UI, the round often checks that you handle all the states of an async call, not just success. There are four: loading (request in flight, show a spinner or skeleton), error (it failed, show a message and a retry), empty (it succeeded but returned nothing, show an empty state rather than a blank screen), and success (render the data).
The idiomatic React pattern puts the fetch in a useEffect and tracks status in state. The detail interviewers watch for is cleanup: if the component unmounts (or the input changes) before the request resolves, you must not call setState on a gone component, so you cancel the request with an AbortController.
import { useState, useEffect } from "react";
function OrderList({ userId }) {
const [status, setStatus] = useState("loading"); // loading | error | empty | success
const [orders, setOrders] = useState([]);
const [error, setError] = useState(null);
useEffect(() => {
const controller = new AbortController();
async function load() {
setStatus("loading");
try {
const res = await fetch(`/api/users/${userId}/orders`, {
signal: controller.signal, // tie the request to this effect
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
setOrders(data);
setStatus(data.length === 0 ? "empty" : "success");
} catch (err) {
if (err.name === "AbortError") return; // unmounted or re-ran: ignore
setError(err);
setStatus("error");
}
}
load();
return () => controller.abort(); // cleanup: cancel if userId changes or unmounts
}, [userId]); // re-run when userId changes
if (status === "loading") return <Spinner />;
if (status === "error") return <ErrorBanner error={error} onRetry={() => location.reload()} />;
if (status === "empty") return <p>No orders yet.</p>;
return <ul>{orders.map(o => <li key={o.id}>{o.title}</li>)}</ul>;
}Gotcha: the missing cleanup and the abort. Two classic React integration misses. First, rendering only the success branch, so a failed request shows a blank screen or an infinite spinner; handle loading, error, and empty explicitly. Second, not aborting in the effect's cleanup, which causes the "Can't perform a React state update on an unmounted component" warning and, with a fast-changing input, a race where an older slower response overwrites a newer one. Returningcontroller.abort()from the effect fixes both: it cancels the in-flight request, and theAbortErrorcheck skips the stalesetState.
In real apps, most teams move this into a custom hook or use a data library (React Query / SWR) that handles loading, error, caching, deduplication, and cancellation for you. A minimal custom hook keeps components clean:
function useFetch(url) {
const [state, setState] = useState({ status: "loading", data: null, error: null });
useEffect(() => {
const controller = new AbortController();
setState({ status: "loading", data: null, error: null });
fetch(url, { signal: controller.signal })
.then(res => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
})
.then(data => setState({ status: "success", data, error: null }))
.catch(err => {
if (err.name !== "AbortError") setState({ status: "error", data: null, error: err });
});
return () => controller.abort();
}, [url]);
return state; // { status, data, error }
}
// Usage: const { status, data, error } = useFetch(`/api/users/${id}`);Interview answer: "How do you fetch data in a React component correctly?" Put the request in auseEffectkeyed on its inputs, track a status state across loading, error, empty, and success, and render each branch. Crucially, create anAbortControllerin the effect and returncontroller.abort()from the cleanup, so that when the component unmounts or the input changes, the in-flight request is cancelled and you skip the stalesetState(checking forAbortError). This prevents both the unmounted-update warning and out-of-order responses overwriting newer data. In production I would usually reach for React Query or SWR, which handle this plus caching and deduplication.
A Worked Integration, End to End
Pulling it together: fetch a paginated list, with status checking, the right body reader, retries, and shaping. This is close to what the round actually asks.
async function getAllOrders(client) {
const all = [];
let page = 1;
while (true) {
const res = await requestWithRetry(`https://api.example.com/orders?page=${page}`, {
signal: AbortSignal.timeout(5000),
headers: { "Accept": "application/json" },
});
if (!res.ok) {
throw new Error(`Failed to load orders page ${page}: HTTP ${res.status}`);
}
// Pick the reader by what the server actually sent
const type = res.headers.get("content-type") || "";
if (!type.includes("application/json")) {
throw new Error(`Expected JSON, got ${type}`);
}
const body = await res.json();
all.push(...body.data); // the actual records, under a "data" envelope
if (!body.meta?.hasNextPage) break; // stop when the API says there is no next page
page++;
}
return all;
}It checks the status, confirms the format before parsing, reads json() only after both checks, follows pagination via the response's own metadata, and lets the retry wrapper handle transient failures. That is a clean integration.
What the Interview Round Is Really Testing
When you are in this round, the interviewer is watching for a specific set of habits. Name them to yourself as you work:
- Do you check the status before trusting the body? Reading
res.json()without checkingres.okis the number-one tell of inexperience, becausefetchdoes not reject on 4xx/5xx. - Do you pick the right reader for the format? Calling
json()on a non-JSON response, or not reading theContent-Type, breaks integrations. - Do you handle errors at both levels? HTTP errors and transport errors are different and both need handling.
- Do you set a timeout? A missing timeout is a real production risk.
- Do you think about retries and idempotency? Especially not double-submitting POSTs.
- Do you keep secrets out of code? Hardcoded keys are an instant red flag.
- Do you isolate the integration? A client module beats scattered
fetch. - Do you handle the UI states? Loading, error, and empty, not just success.
- Do you talk through your choices? Saying why you check the status or add backoff matters as much as doing it.
Gotchas Interviewers Love
The one-read body. The response body is a one-time stream. Callingres.json()afterres.text()(or any two readers) throws. Clone the response if you need it twice.
fetchresolves on 4xx/5xx. Only network-level failures reject. Always checkres.okyourself; otherwise you parse error pages as data.
The "200 but it's actually an error" API. Some APIs return a 200 with an error encoded in the body (like{ "ok": false, "error": "..." }).res.okis true, so you must also check the body's own success indicator for those APIs.
Empty bodies andjson(). A 204 No Content (or any empty body) makesres.json()throw. Guard with a status check or read as text first.
ManualContent-Typeon multipart. Setting it yourself omits the boundary and silently breaks the upload. PassFormDataand let the runtime set the header.
No timeout.fetchwaits forever by default. AddAbortSignal.timeout()to every outbound call.
Swallowed errors. An empty catch {} hides failures so they resurface later as mysterious behavior. Always at least log, and surface a real error to the caller.Hardcoded secrets. API keys in source leak through git and client bundles. Use environment variables, and never send a server secret to the browser.
CORS confusion (browser only). A request blocked by the browser's cross-origin policy rejects fetch with a generic error, and it is the browser, not the server, refusing. CORS is configured on the server with response headers; you cannot fix it purely from client code.Related
React Fundamentals
A practical, example-driven React guide from fundamentals to React 19. Master hooks, the Virtual DOM, performance, and the gotchas interviewers actually test.
Node.js Memory, Garbage Collection & Production Failures
How Node.js memory really works: the V8 heap, garbage collection, memory leaks, OOM crashes, and diagnosing it all in production on EC2 and ECS.
Authentication and Authorization, Explained: The Interview-Ready Deep Dive
A beginner-to-advanced guide to authentication and authorization: password hashing, sessions vs JWTs, access and refresh tokens, OAuth 2.1, and passkeys.