Outbound HTTP calls sit on the boundary between application code and another service. In Node.js, the built-in fetch() API lets a script request an endpoint without adding an HTTP client package, but the client still needs to separate transport success, HTTP status, and response-body parsing.
Current Node.js releases expose fetch() as a global API, along with browser-compatible Request, Response, and Headers objects. A request resolves to a Response first, so response.ok, response.status, and the response Content-Type should be checked before application code trusts parsed data.
A local JSON endpoint keeps the request, status code, content type, parsed body, and error branch testable without depending on a public API. Real bearer tokens should come from environment variables or another secret store, and saved transcripts should mask tokens, account IDs, private hostnames, and customer data.
import http from "node:http"; const profile = { id: 42, name: "Ada Lovelace", plan: "pro", }; const server = http.createServer((request, response) => { response.setHeader("content-type", "application/json; charset=utf-8"); if (request.method === "GET" && request.url === "/v1/profile") { response.writeHead(200); response.end(JSON.stringify(profile)); return; } response.writeHead(404); response.end(JSON.stringify({ error: "not_found" })); }); server.listen(3040, "127.0.0.1", () => { console.log("Demo API listening on http://127.0.0.1:3040"); });
The local endpoint keeps the response repeatable while the client code is being built. Replace it with the real API host after the status and parsing behavior is clear.
Related: How to create an HTTP server in Node.js
const baseUrl = process.env.API_BASE_URL || "http://127.0.0.1:3040"; const apiPath = process.env.API_PATH || "/v1/profile"; const token = process.env.API_TOKEN; async function fetchJson(path) { const url = new URL(path, baseUrl); const headers = { Accept: "application/json" }; if (token) { headers.Authorization = `Bearer ${token}`; } const response = await fetch(url, { headers, signal: AbortSignal.timeout(5000), }); const contentType = response.headers.get("content-type") || ""; const body = contentType.includes("application/json") ? await response.json() : await response.text(); if (!response.ok) { const detail = typeof body === "string" ? body : JSON.stringify(body); throw new Error(`Request failed with ${response.status}: ${detail}`); } return { status: response.status, body }; } try { const { status, body } = await fetchJson(apiPath); console.log(`status=${status}`); console.log(`name=${body.name}`); console.log(`plan=${body.plan}`); } catch (error) { console.error(error.message); process.exitCode = 1; }
AbortSignal.timeout(5000) aborts the request after five seconds. response.json() reads the response body once, so use response.text() in branches that need the raw body instead.
$ node demo-api.mjs Demo API listening on http://127.0.0.1:3040
$ node call-api.mjs status=200 name=Ada Lovelace plan=pro
$ API_PATH="/v1/missing" node call-api.mjs
Request failed with 404: {"error":"not_found"}
response.ok is true only for HTTP 2xx statuses. A reachable API can still return a status that should stop the application path.
$ ^C