Working with APIs
An API (Application Programming Interface) is a contract that lets two programs talk to each other. When people say "calling an API" in a web context, they usually mean a REST API: a server exposes a set of URLs (endpoints), and a client sends HTTP requests to those URLs to read or change data. The server responds with structured data — almost always JSON these days — that the client parses and uses.
The building blocks
Client and server — your Python script is the client; the API is the server. The client initiates every request.
Endpoints — URLs that represent a resource, e.g.
/users/42or/orders.HTTP verbs —
GETreads data,POSTcreates something new,PUT/PATCHupdate,DELETEremoves.JSON payloads — the request body (for POST/PUT) and the response body are usually JSON: text that maps directly onto Python dicts and lists.
Status codes — the response tells you what happened: 200 OK, 201 Created, 401 Unauthorized, 404 Not Found, 429 Too Many Requests, 500 Server Error.
Authentication patterns
Most real APIs require you to prove who you are before they hand back data. The exact mechanism varies, but you will run into these three patterns constantly.
Method | How it works | Example |
|---|---|---|
API key | A static token issued when you sign up, sent as a query param or header on every request. |
|
Bearer token | A token (often short-lived) sent in the |
|
OAuth 2.0 | A multi-step handshake: the user grants your app permission, your app exchanges a code for an access token, then uses that token like a bearer token. | Used by "Sign in with Google/GitHub" flows |
A full worked example
Here is a complete example calling a public joke API, checking the response, and turning the JSON into a plain Python object.
joke_client.py
import os
import requests
API_KEY = os.getenv("JOKE_API_KEY") # loaded from the environment, never hardcoded
def fetch_joke():
response = requests.get(
"https://official-joke-api.appspot.com/random_joke",
headers={"Authorization": f"Bearer {API_KEY}"} if API_KEY else {},
timeout=5,
)
response.raise_for_status()
return response.json()
if __name__ == "__main__":
joke = fetch_joke()
print(joke["setup"])
print(joke["punchline"])The response comes back as a JSON object like {"setup": "...", "punchline": "...", "id": 1}. Calling .json() turns that straight into a Python dict, so joke["setup"] is just normal dictionary access — no manual parsing required.
Handling rate limits and transient errors
APIs protect themselves from abuse with rate limits — if you call too often, you get back a 429 Too Many Requests instead of your data. Well-behaved clients check for this and back off rather than hammering the server in a tight loop.
retry_with_backoff.py
import time
import requests
def get_with_retry(url, max_attempts=5, timeout=5):
for attempt in range(1, max_attempts + 1):
response = requests.get(url, timeout=timeout)
if response.status_code == 429:
# respect Retry-After if the server sends one, else back off
wait = int(response.headers.get("Retry-After", 2 ** attempt))
print(f"Rate limited, waiting {wait}s (attempt {attempt})")
time.sleep(wait)
continue
response.raise_for_status()
return response.json()
raise RuntimeError("Exceeded retry attempts")Graceful error handling
401 Unauthorized/403 Forbidden— your credentials are missing, wrong, or lack permission. Do not silently retry these.404 Not Found— the endpoint or resource id does not exist. Usually a bug in the URL you built.429 Too Many Requests— back off and retry, ideally honoring aRetry-Afterheader.5xx— the problem is on the server. A short retry with backoff is reasonable; if it keeps failing, surface the error.
Keeping keys out of your code
The JOKE_API_KEY in the example above is read with os.getenv(), which returns None if the variable is not set rather than crashing. In development you would put real values in a local .env file (never committed to git) and load them with the python-dotenv package; in production the same variable is set by your hosting platform. This is covered in depth on the Environment Variables page.