Index Ai Academy
Phase 0 · Tools · Talking to APIs

Calling APIs from Python

Send requests, read JSON responses, and handle failures.

About 30 minutes · Free lesson

Start this lesson

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

  1. The requests library
  2. Query parameters with params=
  3. Sending data with POST
  4. When things go wrong
  5. Worked example: spend on active search campaigns
  6. 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")
It runs right here in your browser, with nothing to install.

requests.get(url) sends a GET request and gives back a Response object. The parts you'll use most:

AttributeWhat it holds
response.status_codethe status code, such as 200
response.okTrue for any status below 400
response.json()the body parsed from JSON into Python dicts and lists
response.textthe body as a plain string
response.headersthe 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.

Try it now

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.

Quick check
What does this print?
params = {"q": "boots", "limit": 2}
query = "&".join(f"{k}={v}" for k, v in params.items())
print(query)
  1. boots&2
  2. q=boots
  3. q=boots&limit=2
  4. q:boots,limit:2
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= and data= are different. json= sends JSON, which is what most modern APIs (including LLM APIs) expect. data= sends form-encoded text, like an HTML form. Using data= with a JSON API is a common cause of confusing 400 errors.

Quick check
What does passing json={"name": "Test", "budget": 100} to requests.post do?
  1. Converts the dictionary to JSON text, sends it as the body, and sets the Content-Type header
  2. Saves the dictionary to a file called json
  3. Adds name and budget to the URL's query string
  4. Parses the response as JSON
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.

Quick check
Why put timeout=10 on every request in real code?
  1. It's required, or requests raises an error
  2. It retries the request 10 times
  3. It makes the server answer within 10 seconds
  4. Without it, requests can wait forever if the server never answers, and your script hangs
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

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)
In the lesson you can change this and run it.

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.

Quick check
You call requests.get(...) on a URL that returns 404, and you don't call raise_for_status(). What happens?
  1. No exception: you get a Response whose status_code is 404
  2. Python crashes with a ConnectionError
  3. requests raises an HTTPError immediately
  4. requests retries until it gets a 200
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

Start this lesson

Try a quiz question

Which call asks for paused campaigns on the video channel?

  1. requests.get(url, params={"status": "paused", "channel": "video"})
  2. requests.get(url, json={"status": "paused", "channel": "video"})
  3. requests.post(url, params={"status": "paused", "channel": "video"})
  4. requests.get(url, data="status=paused,channel=video")

Answer it at the end of the lesson to see if you're right, and why.

Before this lesson