Almost every modern piece of network gear has some form of REST API these days. Cisco DNAC, Meraki, Palo Alto Panorama, Juniper Mist, Arista CloudVision, NetBox, and countless others. If you want to automate anything beyond SSH, you will end up talking to an API sooner or later. In this part, we will look at how to do that with Python.
We will use the requests library, which is the most popular HTTP library in the Python world. It is not built into Python, but it is so widely used that most Python developers install it by default. requests handles all the messy details of HTTP for you, headers, cookies, authentication, encoding, so you can focus on the actual work.
To keep things easy at the start, we will use a service called https://mockhttp.org/, which is a testing sandbox for HTTP requests. It has endpoints that do exactly what their name suggests, /get returns a GET response, /post returns a POST response, /status/404 returns a 404, and so on. The responses are simple JSON that echo back the details of the request you sent, which is really helpful when you are learning what is happening under the hood.
Once we are comfortable with requests on mockhttp, we will move on to a real network automation API, NetBox. NetBox is one of the most widely used tools in network automation, an open-source source-of-truth for your network devices, sites, VLANs, IPs, and more. There is a public demo instance at demo.netbox.dev that anyone can hit, so you can follow along without needing to install NetBox yourself.
By the end of this post, you will know how to make GET and POST requests, handle authentication, parse JSON responses, filter results with query parameters, and deal with errors. That is enough to talk to almost any REST API you will run into.
What is a REST API
Before we jump into code, let's cover the basics quickly. An API (Application Programming Interface) is a way for one program to talk to another. A REST API is a specific style of API that uses HTTP, the same protocol your browser uses to load web pages.
When you make a request to a REST API, you are asking for or sending data to a specific URL, called an endpoint. Each endpoint represents some kind of resource, like a list of devices or a single interface. You use different HTTP methods to say what you want to do with that resource.
The main HTTP methods you will use are:
- GET - fetch data from the API
- POST - create something new
- PUT or PATCH - update something that already exists
- DELETE - remove something
The API responds with a status code (a three-digit number that tells you if the request worked or not) and usually a body (the actual data, typically in JSON format).
Status codes are grouped by their first digit.
- 2xx - the request worked (200 OK, 201 Created)
- 3xx - redirection
- 4xx - you did something wrong (400 Bad Request, 401 Unauthorized, 404 Not Found)
- 5xx - the server did something wrong (500 Internal Server Error, 503 Service Unavailable)
If you have ever seen "404 Not Found" in a browser, that is a status code from an HTTP response. APIs use the same set of codes to communicate what happened.
Works. Let's write the section around this real output.
The requests Library
requests is not built into Python, so we need to install it. If you are using uv, add it to your project.
uv add requests
Or with pip if you are not using uv yet.
pip install requests
Once installed, import it and you are ready to go.
import requests
Let's make our first call. We will send a GET request to mockhttp.org, a testing service designed for playing with HTTP requests. The /get endpoint responds with details about the request you sent, which makes it useful for seeing what is happening under the hood.
import requests
response = requests.get("https://mockhttp.org/get")
print(response.status_code)
print(response.text)
Output
200
{"method":"GET","headers":{"host":"mockhttp.org","x-forwarded-for":"149.34.167.217,172.69.224.124,34.111.9.76","user-agent":"python-requests/2.34.2","accept":"*/*","cf-ray":"a1977447bf8a7a09-LHR","cdn-loop":"cloudflare; loops=1","cf-connecting-ip":"149.34.167.217","cf-ipcountry":"GB","cf-visitor":"{\"scheme\":\"https\"}","x-cloud-trace-context":"6d5912386c0054ac9ddf552c270f6f98/10879776173766388515","x-forwarded-proto":"https","via":"1.1 google","traceparent":"00-6d5912386c0054ac9ddf552c270f6f98-96fcbac9591aab23-00","forwarded":"for=\"172.69.224.124\";proto=https","accept-encoding":"gzip, br"},"queryParams":{}}
requests.get(url) sends an HTTP GET request to the URL and returns a Response object. This object holds everything about the response, the status code, the headers, the body, and more.
response.status_code gives you the HTTP status code as a number. In this case, 200 means everything worked. If we had asked for a non-existent endpoint, we would see 404. If the server was having issues, we might see 500 or 503.
response.text gives you the response body as a string. mockhttp.org sent us a JSON string describing our own request, the method we used (GET), the headers we sent, and any query parameters (empty in this case since we did not send any).
The response body is JSON, but at the moment it is just a string. To actually work with it as Python data, we need to parse it. That is what we will do next.
Parsing JSON Responses
The response body from an API is almost always JSON. response.text gives it to us as a string, which is fine for looking at, but not very useful for actually working with the data. To turn it into a proper Python dictionary, we can either use json.loads() or we can just call .json() on the response object. requests has this built in as a convenience.
import requests
response = requests.get("https://mockhttp.org/get")
data = response.json()
print(type(data))
print(data["method"])
print(data["queryParams"])
Output
<class 'dict'>
GET
{}
response.json() parses the response body and returns it as a Python dictionary (or list, depending on what the API sent back). Now we can access fields by key, loop through them, or pass them around like any other dictionary.
If we want to see the full structure nicely formatted, we can use json.dumps()
import json
import requests
response = requests.get("https://mockhttp.org/get")
data = response.json()
print(json.dumps(data, indent=2))
Output
{
"method": "GET",
"headers": {
"host": "mockhttp.org",
"x-forwarded-for": "149.34.167.217,172.69.224.124,34.111.9.76",
"user-agent": "python-requests/2.34.2",
"accept": "*/*",
"cf-ray": "a19778676e407d93-LHR",
"cdn-loop": "cloudflare; loops=1",
"cf-connecting-ip": "155.24.16.21",
"cf-ipcountry": "GB",
"cf-visitor": "{\"scheme\":\"https\"}",
"x-cloud-trace-context": "02c28e73ece73f196577cd55ccce237b/15912661239454080753",
"x-forwarded-proto": "https",
"via": "1.1 google",
"traceparent": "00-02c28e73ece73f196577cd55ccce237b-dcd52116a0c99ef1-00",
"forwarded": "for=\"172.69.224.124\";proto=https",
"accept-encoding": "gzip, br"
},
"queryParams": {}
}
Much easier to read than the wall of text we saw earlier. This is a good habit while you are figuring out what an API returns, print it pretty so you can actually see the structure.
Query Parameters
Query parameters are the bits in a URL that come after a ?, like ?name=cisco&role=core. Most APIs use them for filtering, searching, or pagination. For example, if you want to fetch only devices with the role core, you would add ?role=core to the URL.
You could build the URL yourself with string concatenation or f-strings, but that quickly gets messy, especially when values contain special characters that need URL encoding. requests manages this for you if you pass the parameters as a dictionary through the params= argument.
import json
import requests
params = {
"device": "r1",
"role": "core"
}
response = requests.get("https://mockhttp.org/get", params=params)
data = response.json()
print(json.dumps(data, indent=2))
Output
{
"method": "GET",
"headers": {
"host": "mockhttp.org",
...
},
"queryParams": {
"device": "r1",
"role": "core"
}
}
Two things worth noting. First, mockhttp echoes back the query parameters we sent, so we can confirm they made it through.
Second, we did not build the URL manually. requests took the dictionary and appended it as ?device=r1&role=core for us. If you print the final URL, you can see it.
print(response.url)
Output
https://mockhttp.org/get?device=r1&role=core
This is much cleaner than doing something like f"https://mockhttp.org/get?device={device}&role={role}", especially once you get to values with spaces, special characters, or lists.
Introducing NetBox
We have covered enough of the mechanics that we can move on to a real API. For that, we will use NetBox.
NetBox is an open source tool designed as a source of truth for your network. It is used by companies of every size, from small operators to some of the biggest networks in the world.
The real value comes from its API. NetBox exposes every piece of data through a REST API, which means you can build automation around it. Instead of asking a colleague "which VLAN is used for management at site A?" your script can just ask NetBox. Instead of hardcoding a list of devices at the top of your script, your script can fetch the list from NetBox and always have the latest.
For this part, we will use the public NetBox demo instance at https://demo.netbox.dev. It is a live NetBox running the latest version, and anyone can use it. The demo has sample data pre-populated (a few sites, devices, VLANs, IPs), so we can play with real-looking data without needing to install NetBox ourselves.
Reading the API Docs
Before we write more code, it is worth talking about API documentation. Every good API has docs that describe what endpoints exist, what data they return, what parameters they accept, and how to authenticate. Learning to read API docs is honestly half the battle when working with any new API.
NetBox has excellent documentation at https://netboxlabs.com/docs/netbox/integrations/rest-api/, and the API section explains the structure of every endpoint. The important thing to understand for NetBox is how the URL structure is organised.
Every API request goes to https://<hostname>/api/, and everything under /api/ is grouped by application (also called a module). The main ones you will use are:
dcim- Data Center Infrastructure Management. Sites, racks, devices, interfaces, cables. This is where physical infrastructure lives.ipam- IP Address Management. Prefixes, IP addresses, VLANs, VRFs, ASNs. Everything network-layer.circuits- Circuits, providers, circuit terminations.tenancy- Tenants and tenant groups.virtualization- Clusters, virtual machines, virtual interfaces.
Inside each application, there are separate paths for each type of object. So a full endpoint URL might look like this.
https://demo.netbox.dev/api/dcim/sites/
https://demo.netbox.dev/api/dcim/devices/
https://demo.netbox.dev/api/ipam/prefixes/
https://demo.netbox.dev/api/ipam/vlans/
Each endpoint typically has two views.
- A list view at
/api/dcim/devices/returns all devices (or a filtered set) - A detail view at
/api/dcim/devices/123/returns just the device with ID123
You can also GET the list view and create new objects there with POST, or fetch, update, and delete a specific object through the detail view. The same URL pattern works for every object type in NetBox.
You do not need to memorise any of this. When you want to work with something new (say, IP prefixes), open the docs, find the endpoint, and go from there. Every API you work with will have its own quirks, but the docs are always the source of truth.
For the rest of this post, we will focus on a couple of endpoints under dcim, mostly sites and devices, since they are the easiest to relate to.
Getting an API Token
Most of NetBox's API requires authentication (in fact almost all systems require authentication to use their API). It uses API tokens for this, a long random string that identifies you. Instead of sending your username and password with every request, you send the token in a header.
On the demo instance, you can log in with your credentials and grab a token from the profile menu at the top right (Profile > API Tokens). Copy the token and keep it handy for the next section.
Never share your token or commit it to Git. Treat it like a password.
Fetching Sites
Let's put it all together and fetch the list of sites from the NetBox demo.
import json
import requests
token = "nbt_8fGqbt22ALfX.V9cXGNlnYkFTOSiHDPBimacILdNlApK5kSuWatnL"
headers = {
"Authorization": f"Token {token}",
"Accept": "application/json"
}
response = requests.get("https://demo.netbox.dev/api/dcim/sites/", headers=headers)
data = response.json()
for site in data["results"]:
print(site["name"])
Output
Butler Communications
D. S. Weaver Labs
DM-Akron
DM-Albany
DM-Binghamton
DM-Buffalo
DM-Camden
...
A few things worth pointing out. The headers dictionary is where the authentication happens. NetBox expects the token in the Authorization header, prefixed with the word Token and a space. The Accept: application/json header tells the server that we want JSON back (which it does by default, but it is good practice to be explicit).
The response body is a dictionary with a results key that contains the list of sites. This is the standard NetBox pagination format, which we will look at in the next section. For now, we just loop through data["results"] and print each site's name.
About the Token in the Script
You will notice the token is hardcoded in the script for this example. That is fine while we are learning, but you should never do this in a real script.
Treat your API token like a password. If it ends up in a Git repo, a chat message, or a shared file, anyone who finds it can act as you against the API. On the NetBox demo it does not matter much, but on a real NetBox instance connected to your production network, an exposed token could cause serious damage.
Use one of the methods we covered earlier in the series. Load the token from an environment variable, prompt for it with getpass, or read it from a .env file that is excluded from Git. For anything running in production, use a proper secret manager.
For the rest of this post, I will leave the token hardcoded to keep the examples short, but in your own scripts, treat it like a real credential from day one.
Pagination
If you look at the raw response from an endpoint, you will notice a few extra fields alongside results. Let's fetch IP addresses this time, since there are enough of them in the demo to actually see pagination in action.
import requests
token = "nbt_8fGqbt22ALfX.V9cXGNlnYkFTOSiHDPBimacILdNlApK5kSuWatnL"
headers = {
"Authorization": f"Token {token}",
"Accept": "application/json"
}
response = requests.get("https://demo.netbox.dev/api/ipam/ip-addresses/", headers=headers)
data = response.json()
print(f"Total: {data['count']}")
print(f"Results on this page: {len(data['results'])}")
print(f"Next page: {data['next']}")
Output
Total: 180
Results on this page: 50
Next page: https://demo.netbox.dev/api/ipam/ip-addresses/?limit=50&offset=50
There are 180 IP addresses in total, but our script only got 50 of them. The rest are spread across the next pages, and the URL for the next page is in data["next"].
APIs paginate their responses because returning thousands of records in one response would be slow and use a lot of memory on both ends. Instead, the API sends a fixed number of results per page (NetBox uses 50 by default) and gives you a link to the next page. You keep following that link until there is no more next (it comes back as None), which means you have reached the end.
If you do not handle pagination in your script, you will silently miss data whenever a response has more than one page of results. This is one of the most common bugs when working with APIs.
Here is a version that fetches all pages by following the next link.
import requests
token = "nbt_8fGqbt22ALfX.V9cXGNlnYkFTOSiHDPBimacILdNlApK5kSuWatnL"
headers = {
"Authorization": f"Token {token}",
"Accept": "application/json"
}
url = "https://demo.netbox.dev/api/ipam/ip-addresses/"
all_ips = []
while url:
response = requests.get(url, headers=headers)
data = response.json()
all_ips.extend(data["results"])
url = data["next"]
print(f"Fetched {len(all_ips)} IP addresses in total")
Output
Fetched 180 IP addresses in total
Let's break down what is happening. We start with the first page's URL. Inside the while loop, we fetch that URL, add its results to our all_ips list, and then set url to data["next"]. On the last page, data["next"] is None, which is falsy, so the loop exits.
all_ips.extend(data["results"]) adds all the items from the current page to our list. If you used .append() instead, you would end up with a list of lists, which is not what you want here.
This pattern (loop until next is None) works for any NetBox endpoint. And a similar pattern works for most other APIs too, they just use different field names. Some use next_page, some use has_more, some use cursor tokens.
If you want to bump up the page size to reduce the number of requests, most APIs let you pass a limit parameter.
response = requests.get(url, headers=headers, params={"limit": 200})
NetBox allows up to 1000 per page. Increasing the limit is a good habit when you know you will fetch a lot of records, fewer requests means less time waiting on the network.
import requests
token = "nbt_8fGqbt22ALfX.V9cXGNlnYkFTOSiHDPBimacILdNlApK5kSuWatnL"
headers = {
"Authorization": f"Token {token}",
"Accept": "application/json"
}
response = requests.get(
"https://demo.netbox.dev/api/ipam/ip-addresses/",
headers=headers,
params={"limit": 200}
)
data = response.json()
print(f"Total: {data['count']}")
print(f"Results on this page: {len(data['results'])}")
print(f"Next page: {data['next']}")
Total: 180
Results on this page: 180
Next page: None
Filtering with Query Parameters
Fetching every record and then filtering in Python works, but it is wasteful. If NetBox has 10,000 devices and you only want the ones at one site, downloading all 10,000 first is slow, uses more memory, and puts unnecessary load on the API server. Most APIs let you filter server-side by passing query parameters, so you only fetch what you actually need.
We already saw how to pass query parameters with the params= argument. NetBox uses field names for filtering, so params={"name": "DM-Akron"} becomes ?name=DM-Akron on the URL.
import json
import requests
token = "nbt_8fGqbt22ALfX.V9cXGNlnYkFTOSiHDPBimacILdNlApK5kSuWatnL"
headers = {
"Authorization": f"Token {token}",
"Accept": "application/json"
}
response = requests.get(
"https://demo.netbox.dev/api/dcim/sites/",
headers=headers,
params={"name": "DM-Akron"}
)
data = response.json()
print(f"Total: {data['count']}")
print(json.dumps(data["results"], indent=2))
Output
Total: 1
[
{
"id": 2,
"url": "https://demo.netbox.dev/api/dcim/sites/2/",
"display": "DM-Akron",
"name": "DM-Akron",
"slug": "dm-akron",
"status": {
"value": "active",
"label": "Active"
},
"region": {
"id": 51,
"name": "Ohio",
"slug": "us-oh"
},
"group": {
"id": 2,
"name": "Branch Offices"
},
"tenant": {
"id": 5,
"name": "Dunder-Mifflin, Inc."
},
"device_count": 4,
"prefix_count": 5,
...
}
]
Combining Multiple Filters
You can pass multiple filters at the same time by adding more keys to the params dictionary. They combine with AND, so all filters must match.
response = requests.get(
"https://demo.netbox.dev/api/dcim/sites/",
headers=headers,
params={"status": "active", "region": "us-oh"}
)
That would fetch all active sites in the Ohio region (Just one as of writing this)
Search With q
Most NetBox endpoints also support a special q parameter, which does a fuzzy search across common fields.
response = requests.get(
"https://demo.netbox.dev/api/dcim/sites/",
headers=headers,
params={"q": "akron"}
)
This returns any site whose name or slug contains "akron", regardless of case. Useful when you know part of the name but not the exact spelling.
Creating Data with POST
Everything so far has been GET requests, we have only been reading data. To create something new, you use a POST request. The pattern is similar, but instead of just a URL and headers, you also send a body that describes what you want to create. requests handles JSON bodies for you if you pass them through the json= argument.
Let's create a new site in NetBox called London-Office, marked as active, and assigned to the Europe region. (Europe region already exists in NetBox)
import json
import requests
token = "nbt_8fGqbt22ALfX.V9cXGNlnYkFTOSiHDPBimacILdNlApK5kSuWatnL"
headers = {
"Authorization": f"Token {token}",
"Accept": "application/json",
"Content-Type": "application/json"
}
payload = {
"name": "London-Office",
"slug": "london-office",
"status": "active",
"region": {"name": "Europe"}
}
response = requests.post(
"https://demo.netbox.dev/api/dcim/sites/",
headers=headers,
json=payload
)
print(response.status_code)
print(json.dumps(response.json(), indent=2))
Output
201
{
"id": 25,
"url": "https://demo.netbox.dev/api/dcim/sites/25/",
"display": "London-Office",
"name": "London-Office",
"slug": "london-office",
"status": {
"value": "active",
"label": "Active"
},
"region": {
"id": 3,
"name": "Europe",
"slug": "europe"
},
"created": "2026-07-11T15:42:59.529551Z",
"last_updated": "2026-07-11T15:42:59.529566Z",
...
}
The status code is 201 (Created), not 200 (OK). That is standard for successful POST requests.
We added a new header, Content-Type: application/json, which tells the server that we are sending JSON in the body. Combined with json=payload, this is enough for requests to serialise our dictionary into a JSON string and send it correctly.
The body (payload) is just a Python dictionary describing the site. NetBox needs at minimum the name and slug fields. The slug is a URL-safe short name used in the web UI and API URLs. We also passed status and a region reference.
The response body is the full record of the site we just created, including its assigned id. This is important because you will often want to use that ID in a follow-up request, for example, to add devices to this site or to update one of its fields.
PUT, PATCH, and DELETE work the same way as POST, but for different operations. PUT and PATCH update existing objects (PUT replaces the whole thing, PATCH updates just the fields you send). DELETE removes an object. You use requests.put(), requests.patch(), and requests.delete(), and the pattern is identical to what we just did.


