APIs
After this: you'll read any API's docs, call it from the terminal, and script against one without leaking credentials.
The UI Is the Demo. The API Is the Product.
Open your browser's dev tools, click the Network tab, then click any button in any web app. Watch what happens: the page fires off an HTTP request, gets some JSON back, and re-renders. That request is the product. The buttons, the pretty dashboard, the loading spinner — that's a skin stretched over a set of endpoints.
An API (application programming interface) is just the set of HTTP requests a service is willing to answer, documented. When you click "start VM" in a Proxmox dashboard, the browser calls something like POST /api2/json/nodes/pve/qemu/101/status/start. The API doesn't care that you clicked a button. It would answer the exact same request coming from a Python script at 3 AM, a cron job, or another company's integration.
This is why APIs matter to you: every automation you've ever wanted — "email me when the backup finishes," "pull the VM list into a spreadsheet," "restart the thing when the thing breaks" — is just calling the same endpoints the UI calls, without the UI in the middle. Learn to speak HTTP directly and the demo becomes optional.
- The web UI is a skin over HTTP endpoints; the endpoints are the real product.
- An API is the documented set of requests a service answers.
- Anything the UI can do, a script can do — usually faster, at 3 AM, on a schedule.
REST and HTTP: The 20% That Matters
HTTP APIs come in flavors (REST, GraphQL, gRPC), but REST over HTTP is the default language of the internet, and 20% of it covers 95% of what you'll ever call. Here's the whole mental model: resources (nouns, in the URL) plus verbs (what you're doing to them).
The verbs
There are five you need. GET reads something and changes nothing. POST creates something new. PUT replaces a resource wholesale. PATCH updates just a few fields. DELETE removes it. A "RESTful" endpoint reads like a sentence: GET /v1/servers lists servers, GET /v1/servers/42 fetches server 42, POST /v1/servers creates one, DELETE /v1/servers/42 kills it. Nouns in the path, verbs in the method — if an API names its endpoints /getServerData, it's not RESTful, but it'll still work fine. Don't be a purist about other people's APIs.
The status codes that matter
Every response carries a three-digit status code. Memorize seven: 200 OK, 201 created (your POST worked), 400 your request was malformed, 401 you're not authenticated, 404 that resource doesn't exist, 429 slow down (rate limited), 500 their server broke. The first digit is the category: 2xx success, 4xx you messed up, 5xx they messed up. When something fails, the status code tells you whose problem it is before you read a single line of the error body.
JSON: the envelope
Request and response bodies are almost always JSON — curly braces, quoted keys, values that are strings, numbers, booleans, arrays, or nested objects:
{
"id": 42,
"hostname": "lab-nuc-01",
"cpus": 16,
"tags": ["proxmox", "lab"],
"online": true
}
Query parameters
The part of a URL after the ? is a set of key-value filters: GET /v1/servers?online=true&sort=-cpus&limit=10 asks for the ten online servers with the most CPUs. Query params are how APIs expose filtering, sorting, and searching without inventing new endpoints. Two conventions to know: limit/offset (or page) for slicing big lists — more on that in chapter 5 — and comma-separated or repeated keys for multi-value filters (?tag=proxmox&tag=lab). When docs say a parameter is "required," a missing one gets you a 400; when it's optional, the API applies its default.
That's it. Verbs, noun-URLs, status codes, JSON. You now know enough HTTP to read any REST API's documentation without flinching. (MDN's HTTP status reference is the one bookmark worth keeping.)
PUT replaces the entire resource — fields you omit may get wiped to defaults. When you only mean to change one field, reach for PATCH.- Five verbs: GET reads, POST creates, PUT replaces, PATCH tweaks, DELETE removes.
- URLs are nouns (
/v1/servers/42); the verb lives in the HTTP method. - Seven status codes (200, 201, 400, 401, 404, 429, 500) tell you whose problem a failure is.
curl and jq: Reading APIs From the Terminal
curl makes HTTP requests from the command line. No browser, no app, no clicking — just you and the endpoint. Let's call a real public API (GitHub's, no login needed) and fetch a repo:
curl -s https://api.github.com/repos/python/cpython
The -s flag means silent (no progress meter). You'll get a wall of JSON. That's where jq comes in — it's a JSON filter that pretty-prints and lets you reach into the structure:
curl -s https://api.github.com/repos/python/cpython | jq .
curl -s https://api.github.com/repos/python/cpython | jq '{name: .name, stars: .stargazers_count}'
{
"name": "cpython",
"stars": 68214
}
The -r flag gives you raw output (no quotes) — perfect for lists:
curl -s "https://api.github.com/repos/python/cpython/issues?per_page=5" | jq -r '.[].title'
jq is a whole language, but one more filter earns its keep: select() filters arrays the way a WHERE clause filters rows. Grab only the open pull requests authored by a bot:
curl -s "https://api.github.com/repos/python/cpython/pulls?per_page=20" \
| jq -r '.[] | select(.user.type == "Bot") | .title'
Sending data
Reading is GET; writing needs a method, headers, and a body. -X POST sets the verb, -H adds a header, -d sends the body. httpbin.org is a free service that echoes back whatever you send it — ideal for practice:
curl -s -X POST https://httpbin.org/post \
-H "Content-Type: application/json" \
-d '{"hostname":"lab-nuc-01","cpus":16}' | jq .json
And -i shows response headers, which is how you inspect rate limits and content types:
curl -si https://api.github.com/rate_limit | head -8
curl to Invoke-WebRequest — a different tool that will mangle your flags. Type curl.exe explicitly, and use double quotes around JSON (PowerShell doesn't do single-quoted strings the way bash does).jq's manual is dense but worth skimming once. The three filters you'll use daily: .field, .[] (iterate arrays), and select(...).curl -sfetches; pipe tojqto pretty-print and filter.-X,-H,-dlet you POST JSON bodies.- Windows: it's
curl.exe, and quote JSON with double quotes.
Auth Without Tears
Public APIs (like the GitHub calls above) let anyone in. Most useful APIs don't. Authentication answers "who are you," and there are four patterns you'll meet, in order of how often you'll see them.
API keys are the simplest: a long random string the service hands you, sent as a header (X-API-Key: ...) or query parameter. Easy to use, hard to revoke granularly — anyone holding the key has all of its powers.
Bearer tokens ride in the Authorization header and say "whoever holds this token may act." Same idea as an API key, dressed up in a standard format:
curl -s https://api.example.com/v1/messages \
-H "Authorization: Bearer $MAIL_API_TOKEN" | jq .
Basic auth is username + password, base64-encoded, sent on every request (curl -u labuser:$API_PASSWORD ...). It's only safe over HTTPS — over plain HTTP it's a postcard with your password on it. You'll still see it on routers, cameras, and older gear.
OAuth 2.0 is the valet key for your data. You hand an app a token that starts the car but doesn't open the trunk: it gets scoped permission ("read your inbox, not send as you") and it expires. The dance has two tokens — a short-lived access token that does the work and a refresh token that mints new ones — which is why "Sign in with Google" redirects you through a consent screen instead of asking for your password. The flow, from the app's side: it sends you to the provider ("this app wants to read your inbox — allow?"), you approve, the provider hands the app an authorization code, and the app trades that code — server-to-server, with its own client secret — for the token pair. You never give the app your password at all. That's the whole point, and it's why phishing for OAuth consent screens is a thing: always check which app is asking and which scopes it wants before you click allow.
export MAIL_API_TOKEN="paste-it-here", then reference $MAIL_API_TOKEN. That's the entire system.- API keys and bearer tokens: a secret string in a header. Simple, powerful, guard it.
- Basic auth: username + password per request — HTTPS only, always.
- OAuth 2.0 is a valet key: scoped, expiring access — the app never sees your password.
- Secrets live in environment variables. Never in code, never in git.
Playing Nice: Limits, Pages, and Webhooks
Calling an API is easy. Calling one well — without getting throttled, missing half your data, or hammering someone's server — is the part that separates scripts that work once from scripts that work for years.
Rate limits and the 429
Every serious API caps how fast you can call it. Blow past the limit and you get a 429 Too Many Requests, often with a Retry-After header telling you how long to wait. Good APIs also advertise their budget up front in headers:
x-ratelimit-limit: 60
x-ratelimit-remaining: 58
x-ratelimit-reset: 1759216800
The correct response to a 429 is never "retry immediately in a loop" — that's how you turn a 60-second timeout into an hour-long ban. Sleep for the Retry-After value (or back off exponentially: 1s, 2s, 4s, 8s...), then continue. Write this into every script from day one; it's three lines and it saves you from the most embarrassing class of bug.
Pagination: the thing that breaks every first script
No API returns 40,000 records in one response. List endpoints paginate — typically ?page=2&per_page=100 or a cursor token — and your first script will fetch page one, declare victory, and silently drop the other 39 pages. Always loop until the response says there's no next page (an empty list, a missing next cursor, or a Link header without a rel="next"). If your script works on a test account and breaks in production, pagination is suspect number one.
Webhooks vs polling
Polling is knocking on the door every minute asking "anything new?" Webhooks flip it around: you give the service a URL, and it calls you when something happens. Polling is simpler and works everywhere; webhooks are efficient and near-instant but require you to run something reachable. Rule of thumb: poll when freshness measured in minutes is fine; webhooks when seconds matter or the API begs you to stop polling.
Error handling that isn't wishful thinking
Check the status code on every response. Parse the error body — APIs usually tell you exactly what's wrong ({"error": "limit must be <= 100"}). Retry 429s and 5xx with backoff; don't blindly retry 4xx — a 400 will fail the same way forever, and retrying a 401 just burns your rate budget while the real problem (bad token) sits there untouched. The backoff pattern in Python is short enough to memorize:
import time
import requests
def get_with_backoff(url, headers, tries=5):
delay = 1
for attempt in range(tries):
resp = requests.get(url, headers=headers, timeout=15)
if resp.status_code == 429 or resp.status_code >= 500:
time.sleep(delay)
delay *= 2 # 1s, 2s, 4s, 8s...
continue
resp.raise_for_status()
return resp.json()
raise RuntimeError(f"gave up after {tries} tries on {url}")
- 429 means slow down: honor
Retry-After, back off exponentially, never hammer. - Every list paginates — loop until there's no next page or you're silently missing data.
- Poll for minutes-fresh, webhooks for seconds-fresh.
- Retry 429/5xx with backoff; 4xx (except 429) is a bug in your request, not a retry candidate.
Build One: An Inbox Lister in Python
Time to put it together: a small script that lists recent inbox messages from a REST API. This uses a placeholder API (api.example.com doesn't exist) — the point is the pattern, which is identical for every real API: authenticate, GET, check the status, parse the JSON, print the fields. To aim it at a real service, change BASE_URL, the endpoint path, and the field names to match that service's docs.
#!/usr/bin/env python3
"""inbox.py - list recent messages from any inbox-style REST API.
PLACEHOLDER: api.example.com is not real. Adapt BASE_URL, the endpoint,
and the JSON field names to a real API's docs. The pattern stays the same.
"""
import os
import sys
import requests
BASE_URL = "https://api.example.com/v1" # <-- point this at the real API
TOKEN = os.environ.get("MAIL_API_TOKEN") # <-- never hardcode the token
if not TOKEN:
sys.exit("error: set the MAIL_API_TOKEN environment variable first")
def list_messages(limit=10):
resp = requests.get(
f"{BASE_URL}/messages",
headers={"Authorization": f"Bearer {TOKEN}"},
params={"limit": limit},
timeout=15,
)
resp.raise_for_status() # 4xx/5xx -> raise instead of failing silently
return resp.json().get("messages", [])
for m in list_messages(10):
date = m.get("date", "")[:10]
sender = m.get("from", "")[:28]
print(f"{date} {sender:<28} {m.get('subject', '')}")
Run it:
pip install requests
export MAIL_API_TOKEN="paste-your-token-here"
# PowerShell: $env:MAIL_API_TOKEN = "paste-your-token-here"
python3 inbox.py
2026-09-29 homelab-alerts@example.com UPS on battery - runtime 42 min
2026-09-29 backup@example.com Nightly backup: OK (212 GB)
2026-09-28 pve@example.com Proxmox: all guests running
Notice what the script doesn't do: no token in the file, no silent failures (raise_for_status() turns a 401 into an exception with the status code attached instead of an empty list you'd misread as "no mail"), and a timeout so a hung server can't hang your script forever. Those three habits — env-var secrets, loud failures, timeouts — are 80% of "production-ready" for small scripts.
To aim it at a real API, the adaptation is mechanical: read that API's docs for the base URL and auth scheme (bearer token? API key header? OAuth?), swap BASE_URL and the headers dict, then fix the field names in the print loop to match the real JSON shape — dump one response with jq . first and read it. Everything else — env-var token, status check, timeout, loop over the list — transfers verbatim. That portability is the payoff of learning the pattern instead of one vendor's SDK.
The dangerous checklist
- Read any API's docs and make your first successful
curlcall in under five minutes. - Carve a JSON response apart with
jqand hand someone exactly the field they asked for. - Authenticate with a bearer token pulled from an environment variable — nothing secret in code or git history.
- Write a script that walks every page of a paginated list and backs off politely when it hits a 429.
- Adapt the inbox script to a real API — your homelab, your monitoring, your inbox — and schedule it.