Calling APIs from Python
Send requests, read JSON responses, and handle failures.
Why it matters
This is the lesson where your code starts talking to the outside world. Calling an LLM, pulling campaign data, posting results to Slack, giving an agent its tools: each one is a few lines of requests. The habits here are to check the status, read the error, and set a time limit. They're what separate a script that works once from one you can leave running.
What you'll be able to do
- Send GET and POST requests with the requests library
- Pass query parameters and JSON bodies without building strings by hand
- Read status codes and JSON responses in code
- Handle the three ways an API call fails: error status, no connection, too slow
What it covers
- The requests library
- Query parameters with params=
- Sending data with POST
- When things go wrong
- Worked example: spend on active search campaigns
- What requests won't do for you
The requests library
Python can make HTTP requests with its standard library. But almost everyone uses the requests package, because it's far simpler. Think of it as a courier: you hand it an address and a package, and it comes back with the reply. On your own machine you'd install it with pip install requests. In the academy it's already available.
import requests response = requests.get("https://api.gradient.test/v1/campaigns") print(response.status_code) print(response.headers["content-type"]) data = response.json() print(data["count"], "campaigns")
requests.get(url) sends a GET request and gives back a Response object. The parts you'll use most:
| Attribute | What it holds |
|---|---|
response.status_code | the status code, such as 200 |
response.ok | True for any status below 400 |
response.json() | the body parsed from JSON into Python dicts and lists |
response.text | the body as a plain string |
response.headers | the response headers, like a dictionary |
.json() does the json.loads step for you. If the body isn't JSON, such as an HTML error page, it raises an error. That's why it's worth checking the status first.
The shape of response.json() from the campaigns endpoint (trimmed to two campaigns). Click any value to see the Python that reaches it.
Query parameters with params=
You could paste ?status=active&channel=search onto the URL yourself. But then you have to handle the ? and &, and spaces and special characters. Pass a dictionary as params= instead, and requests builds the query string correctly:
import requests response = requests.get( "https://api.gradient.test/v1/campaigns", params={"status": "active", "channel": "search"}, ) print(response.url) for campaign in response.json()["data"]: print(campaign["name"], "-", campaign["spend"])
response.url shows the final URL requests built. Printing it is a quick way to check you're asking for what you think you're asking for.
params = {"q": "boots", "limit": 2}
query = "&".join(f"{k}={v}" for k, v in params.items())
print(query)Show the answer
q=boots&limit=2
The join goes through params one pair at a time, making q=boots and limit=2, and puts & between them: q=boots&limit=2. This is what params does for you when you hand it to requests.
Sending data with POST
To send JSON, pass a dictionary as json=. requests converts it with json.dumps, and sets the Content-Type: application/json header for you:
import requests new_campaign = {"name": "Summer Launch - Social", "budget": 1500, "channel": "social"} response = requests.post("https://api.gradient.test/v1/campaigns", json=new_campaign) print(response.status_code) created = response.json() print(created["id"], created["status"])
201 Created means it worked, and the body describes what was created. Leave out a required field, and the API tells you why it refused:
import requests response = requests.post("https://api.gradient.test/v1/campaigns", json={"name": "No budget"}) print(response.status_code) print(response.json()["error"]["message"])
Watch out:
json=anddata=are different.json=sends JSON, which is what most modern APIs (including LLM APIs) expect.data=sends form-encoded text, like an HTML form. Usingdata=with a JSON API is a common cause of confusing 400 errors.
json={"name": "Test", "budget": 100} to requests.post do?Show the answer
Converts the dictionary to JSON text, sends it as the body, and sets the Content-Type header
json= is for the request body. Query parameters use params=, and parsing the response is response.json().
When things go wrong
An API call can fail in three different ways, and each needs different handling.
1. The server answers with an error status. This is the most common, and the one that surprises people: requests does not raise an error for a 404 or a 500. You get a normal Response with that status code. If you don't check it, your code carries on with an error message where it expected data. So either check response.status_code or response.ok yourself, or call response.raise_for_status(). That raises a requests.exceptions.HTTPError for any 4xx or 5xx status, and does nothing otherwise.
2. The server can't be reached. There's no internet, a typo in the host name, or the service is down. requests raises requests.exceptions.ConnectionError.
3. The server is too slow. By default, requests will wait forever for an answer. In real code, always pass a time limit in seconds, such as timeout=10. If it runs out, requests raises requests.exceptions.Timeout. (The academy accepts timeout= but doesn't enforce it.)
All of these errors share a parent, requests.exceptions.RequestException. So one except can catch any of them:
import requests try: response = requests.get("https://api.gradient.test/v1/campaigns/cmp_999", timeout=10) response.raise_for_status() print(response.json()["name"]) except requests.exceptions.HTTPError: print("The API refused:", response.status_code, response.json()["error"]["message"]) except requests.exceptions.RequestException as error: print("Couldn't complete the request:", error)
Order matters. Python checks except clauses top to bottom, so the specific HTTPError goes before the general RequestException. And when you do catch an error, print the status and the body. Hiding the message is how a five-minute fix becomes an hour of guessing.
timeout=10 on every request in real code?Show the answer
Without it, requests can wait forever if the server never answers, and your script hangs
A timeout doesn't speed anything up. It just stops your code waiting forever, by raising requests.exceptions.Timeout when the time runs out.
Worked example: spend on active search campaigns
Ask the API for active search campaigns only, add up their spend, and handle failure properly.
import requests def active_channel_spend(channel): response = requests.get( "https://api.gradient.test/v1/campaigns", params={"status": "active", "channel": channel}, timeout=10, ) response.raise_for_status() total = 0 for campaign in response.json()["data"]: total += campaign["spend"] return total try: print("Search spend:", active_channel_spend("search")) print("Email spend:", active_channel_spend("email")) except requests.exceptions.RequestException as error: print("Request failed:", error)
It prints Search spend: 5511.25 and Email spend: 120.
The function does one job: fetch, check, add up. raise_for_status() sits right after the request. So a failure stops the function before it tries to read data that isn't there.
The try lives where the function is used, because that's where you know what to do about a failure. Here it prints it. In a scheduled report, you might send yourself an alert instead.
requests.get(...) on a URL that returns 404, and you don't call raise_for_status(). What happens?Show the answer
No exception: you get a Response whose status_code is 404
requests only raises for error statuses when you ask it to, with raise_for_status(). Otherwise you have to check the status yourself.
What requests won't do for you
Beginners tend to assume requests will complain when something goes wrong. Mostly it doesn't.
A 404 or 500 isn't an exception. The call succeeds in the sense that the server replied. Your code carries on unless you check. Call response.raise_for_status(), or look at response.status_code yourself, before you trust the body. Calling .json() on an error page often fails with a confusing message about decoding, when the real problem is the status.
json= and data= are different. json=payload sends JSON and sets the right header, which is what most APIs want. data=payload sends form data. The server may answer with a puzzling 400 or 415 error.
Without a timeout, a call can wait forever. If a server stops answering, your program sits there with no error at all. Always pass one, as in requests.get(url, timeout=10).
Let params= build your query strings. Pasting values into a URL by hand breaks as soon as a value contains a space or an &. params= encodes them properly.
Build these four habits now, and most mysterious API bugs never happen.
Practice what you just read
- 6 code examples you can edit and run
- a practice exercise that checks your code
- a 5-question quiz
- 5 review cards for spaced repetition
Try a quiz question
Which call asks for paused campaigns on the video channel?