Your first API call, compared: curl, fetch and Python requests
The same request in three tools, with the parts that differ explained. Once you can read one, you can read all of them.
Every HTTP request is the same four things: a method, a URL, some headers, and sometimes a body. Every tool below expresses those four things differently, and once you see them side by side the syntax stops mattering.
We will use a real API for all of it. Open-Meteo needs no key, supports HTTPS and CORS, and answered our last check in 282 ms.
Short answer
Learn to read curl first, because it is the lowest-friction way to isolate a problem. Then fetch for JavaScript and requests for Python. All three send identical HTTP; the differences are ergonomics, not capability.
The simplest possible request
curl
curl "https://api.open-meteo.com/v1/forecast?latitude=51.5&longitude=-0.13¤t=temperature_2m"No flags needed. curl defaults to GET and prints the body to your terminal.
JavaScript
const response = await fetch(
'https://api.open-meteo.com/v1/forecast?latitude=51.5&longitude=-0.13¤t=temperature_2m',
);
const data = await response.json();
console.log(data.current.temperature_2m);Two awaits, and this is the part that confuses people. The first resolves when the headers arrive; the body may still be streaming. The second reads the body to completion and parses it.
Python
import requests
response = requests.get(
"https://api.open-meteo.com/v1/forecast",
params={"latitude": 51.5, "longitude": -0.13, "current": "temperature_2m"},
timeout=10,
)
print(response.json()["current"]["temperature_2m"])requests is synchronous, so there is no await. Note params as a dictionary rather than a hand-built query string, which handles encoding for you.
Building URLs without breaking them
String concatenation is where most beginner bugs live. A space, an ampersand or a non-ASCII character in a value silently produces a wrong request.
// Fragile: breaks on spaces, &, ?, or any non-ASCII character.
const url = `https://api.example.com/search?q=${query}`;
// Correct: encoding handled for you.
const url = new URL('https://api.example.com/search');
url.searchParams.set('q', query);# requests encodes params automatically.
requests.get("https://api.example.com/search", params={"q": query})# curl needs --data-urlencode with -G for the same safety.
curl -G "https://api.example.com/search" --data-urlencode "q=São Paulo"Reading what came back
The body is only part of the response. The status and headers carry information you need.
# -D - prints headers, -o /dev/null discards the body
curl -sS -D - -o /dev/null "https://api.open-meteo.com/v1/forecast?latitude=51.5&longitude=-0.13"HTTP/2 200
content-type: application/json; charset=utf-8
access-control-allow-origin: *That access-control-allow-origin line is what tells you the API will work from browser JavaScript. It is worth checking before you build anything client-side.
console.log(response.status); // 200
console.log(response.ok); // true for 200-299
console.log(response.headers.get('content-type')); // application/jsonprint(response.status_code) # 200
print(response.ok) # True
print(response.headers["content-type"])const response = await fetch(url);
if (!response.ok) {
throw new Error(`${response.status} ${response.statusText}`);
}Python's requests has the same behaviour, with a helper:
response.raise_for_status() # raises on 4xx and 5xxSending headers
curl -H "Accept: application/json" -H "User-Agent: my-app/1.0" https://api.example.com/dataawait fetch(url, {
headers: { Accept: 'application/json', 'User-Agent': 'my-app/1.0' },
});requests.get(url, headers={"Accept": "application/json", "User-Agent": "my-app/1.0"})One browser caveat: User-Agent is a forbidden header name in browser fetch and will be ignored. It works in Node and curl. This matters because some APIs, including the US National Weather Service, require a descriptive User-Agent and return 403 without one, which makes them server-side only.
Sending data
curl -X POST https://api.example.com/items \
-H "Content-Type: application/json" \
-d '{"name":"example","quantity":2}'await fetch('https://api.example.com/items', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'example', quantity: 2 }),
});requests.post(
"https://api.example.com/items",
json={"name": "example", "quantity": 2}, # sets Content-Type for you
timeout=10,
)Note that JSON.stringify is mandatory in JavaScript. Passing an object directly sends the string [object Object], which produces a confusing 400.
A complete, correct example
Everything above, combined into something you would actually ship:
export async function getWeather(latitude, longitude) {
const url = new URL('https://api.open-meteo.com/v1/forecast');
url.searchParams.set('latitude', latitude);
url.searchParams.set('longitude', longitude);
url.searchParams.set('current', 'temperature_2m,wind_speed_10m');
const response = await fetch(url, {
headers: { Accept: 'application/json' },
signal: AbortSignal.timeout(8000),
});
if (!response.ok) {
throw new Error(`Weather API returned ${response.status}`);
}
const data = await response.json();
return {
temperature: data.current.temperature_2m,
windSpeed: data.current.wind_speed_10m,
units: data.current_units.temperature_2m,
};
}Four things make this production-ready rather than tutorial code: new URL for safe encoding, an explicit Accept header, a timeout so it cannot hang forever, and a status check before parsing.
import requests
def get_weather(latitude: float, longitude: float) -> dict:
response = requests.get(
"https://api.open-meteo.com/v1/forecast",
params={
"latitude": latitude,
"longitude": longitude,
"current": "temperature_2m,wind_speed_10m",
},
headers={"Accept": "application/json"},
timeout=8,
)
response.raise_for_status()
data = response.json()
return {
"temperature": data["current"]["temperature_2m"],
"wind_speed": data["current"]["wind_speed_10m"],
}Translating between them
| Concept | curl | fetch | requests |
|---|---|---|---|
| Method | -X POST | method: 'POST' | requests.post(...) |
| Header | -H "K: V" | headers: { K: 'V' } | headers={"K": "V"} |
| Query params | -G --data-urlencode | url.searchParams.set | params={...} |
| JSON body | -d '{...}' | body: JSON.stringify({}) | json={...} |
| Show headers | -D - | response.headers | response.headers |
| Timeout | --max-time 8 | AbortSignal.timeout(8000) | timeout=8 |
| Fail on 4xx | --fail | check response.ok | raise_for_status() |
The mental model worth forming early
Everything above is mechanics. The idea underneath them is simpler than the tooling suggests, and holding it clearly makes every later API easier.
An HTTP request is a short text message sent to a computer somewhere else, and the response is a short text message back. That is genuinely all it is. The first line of the request says what you want and where. After it come headers, which are metadata about the request. Then, optionally, a body containing data you are sending. The response has the same shape: a status line, headers, and usually a body.
Every tool in this article — curl, fetch, requests, Postman — is a different way of composing those same few lines. They differ in syntax and in what they do for you automatically, and not at all in what actually travels over the network. That is why a request that works in one can always be made to work in another, and why "it works in curl but not in my code" is always a difference in what you sent, never a difference in capability.
Four parts do all the work, and naming them makes debugging much faster.
The method states your intent: GET to read, POST to create, PUT and PATCH to change, DELETE to remove. The server decides whether to honour it, and a method the endpoint does not support gives you a 405 rather than a 404 — a useful distinction, because 405 confirms the path exists.
The URL says where, and carries parameters that shape the result. The headers carry everything that is about the request rather than in it: what format you can accept, who you are, what type the body is. And the body carries the data itself, on the methods that take one.
Once those four are separate in your mind, an unfamiliar API stops being a wall of documentation and becomes four questions with short answers.
What actually happens when you press enter
A brief detour, because understanding the sequence turns several confusing errors into obvious ones.
First the hostname is resolved to an IP address through DNS. If this fails you get an error that mentions the host rather than the request — which is why a typo in the domain produces a very different message from a typo in the path.
Then a TCP connection opens, and for HTTPS a TLS handshake follows, in which the server presents a certificate and the two sides agree on encryption. Certificate problems surface here, before your request is ever sent, which is why they look nothing like API errors.
Only then is the request transmitted. The server processes it and responds. The time between sending and the first byte arriving is the server thinking; the time after that is the body downloading. Those are separate numbers, and curl -w shows both — which is how you tell a slow API from a large response.
This sequence also explains why the first request to a host is slower than the next ten. DNS and TLS happen once and are reused. A benchmark that measures a single cold request is measuring mostly connection setup.
Reading a status code properly
The number the server returns is the first thing to look at, and its first digit tells you who has the problem.
Anything in the 200s succeeded. 201 specifically means something was created, which is what a successful POST usually returns rather than 200.
The 300s are redirects. curl follows them only with -L, fetch follows them silently, and Python's requests follows them by default. That difference explains a surprising number of "works in one tool, not the other" reports.
The 400s mean the problem is in your request, and the specific code tells you which part. 400 means the request itself was malformed. 401 means you are not authenticated. 403 means you are authenticated and still not allowed. 404 means the path does not exist. 422 usually means the shape was right and a value was invalid. 429 means you are sending too fast.
The 500s mean the server failed. There is usually nothing to fix on your side beyond retrying sensibly, and a 500 that appears only for your specific input is worth reporting to the provider.
The one thing that catches everyone out is that fetch does not treat a 404 or a 500 as an error. The promise resolves, your code carries on, and .json() then fails while parsing an HTML error page. Checking response.ok before touching the body is the single habit that prevents the most confusion, and it is covered in why fetch does not throw.
Practising without obstacles
Learning HTTP is much easier when nothing stands between you and a response. For that you want an API with no key, HTTPS, CORS, and a current pulse.
The browser-ready collection is that exact filter. For a first request, Zippopotam.us for postcodes and Newton for maths both return small, readable JSON that is easy to reason about.
Common questions
Which tool should I learn first?
curl, because it has no project setup and no framework in the way. When something fails in your application, reproducing it in curl is the fastest way to tell whether the problem is the API or your code.
Why does my fetch call work in Node but not in the browser?
The browser enforces CORS and mixed-content rules that Node does not. A request that works in Node or curl can still be blocked in a browser, which is a policy decision rather than a server failure.
Do I need to install anything to use fetch?
No. fetch is built into every modern browser and into Node 18 and later. Libraries like axios remain popular but are no longer necessary for basic requests.
What does response.json() actually do?
It reads the response body to completion and parses it as JSON, returning a promise. It does not check whether the request succeeded, so a 404 whose body is an HTML page will throw a parsing error rather than a helpful one.
How do I pick a good API to practise with?
One that needs no key, supports HTTPS and is currently responding, so nothing stands between you and a result. Our no-key collection lists them, health-checked daily.
Sources
Written by
SandyI build and run this site on my own: the crawler that assembles the catalogue, the checker that probes every listing, and the writing. Before this I built SaveFromInternet and GrabReels, which meant living with other people’s APIs full time — parsers breaking when a platform shipped a change, rate limits arriving without warning, endpoints disappearing overnight. This directory exists because I got tired of free API lists that had never been checked.