Everything you clicked on the previous pages is an HTTP call, and NetBox is only worth it once other systems read and write it. This page runs on your machine, not the server: a user made for scripts, pynetbox or curl to read and write, a CSV import that survives a bad row, webhooks when a device changes, and a script that runs inside NetBox on demand.
Before you start
$python3 -m venv ~/.venvs/netbox$source ~/.venvs/netbox/bin/activate$pip install pynetbox$pip show pynetbox | head -1Name: pynetbox
An automation user and its token
$api=https:///api$auth="Authorization: Token "$curl -sk -H "$auth" -H 'Content-Type: application/json' "$api/users/users/" \$ -d "{\"username\": \"\", \"password\": \"$(openssl rand -base64 24)\"}"Note the id in the answer; it goes into the users list of the permissions. Three of them:
$curl -sk -H "$auth" -H 'Content-Type: application/json' "$api/users/permissions/" -d '{$ "name": "automation: view", "actions": ["view"], "users": [ID],$ "object_types": ["dcim.site", "dcim.manufacturer", "dcim.devicerole", "dcim.devicetype",$ "dcim.device", "dcim.interface", "ipam.ipaddress"]$}'Now the token, with an expiry 90 days out:
$key=$(openssl rand -hex 20)$exp=$(python3 -c 'import datetime as d; print((d.datetime.now(d.timezone.utc) + d.timedelta(days=90)).strftime("%Y-%m-%dT%H:%M:%SZ"))')$curl -sk -H "$auth" -H 'Content-Type: application/json' "$api/users/tokens/" \$ -d "{\"user\": ID, \"key\": \"$key\", \"expires\": \"$exp\", \"write_enabled\": true, \"description\": \"automation\"}"$echo "$key"Put the printed key in your values as AUTOMATION_TOKEN. From here on, data calls use it.
$curl -sk -o /dev/null -w '%{http_code}' -H 'Authorization: Token ' https:///api/dcim/sites/200
$curl -sk -o /dev/null -w '%{http_code}' -H 'Authorization: Token ' https:///api/users/users/403
Read and write
import reimport pynetboxnb = pynetbox.api("https://", token="")for site in nb.dcim.sites.all(): print(site.id, site.name, site.status)slug = re.sub(r"[^a-z0-9]+", "-", "".lower()).strip("-")site = nb.dcim.sites.get(slug=slug)if site is None: site = nb.dcim.sites.create(name="", slug=slug, status="active") print("created", site.name)elif site.update({"description": "managed by "}): print("updated", site.name)else: print("unchanged", site.name)$python sites.py && python sites.py$python -c 'import pynetbox; nb = pynetbox.api("https://", token=""); print(nb.dcim.sites.get(name="").status.value)'active
A device end to end
import reimport pynetboxnb = pynetbox.api("https://", token="")slug = lambda s: re.sub(r"[^a-z0-9]+", "-", s.lower()).strip("-")def ensure(endpoint, lookup, **fields): """Get by unique fields, else create. Safe to run twice.""" obj = endpoint.get(**lookup) return obj if obj is not None else endpoint.create(**fields)site = ensure(nb.dcim.sites, {"slug": slug("")}, name="", slug=slug(""), status="active")mfr = ensure(nb.dcim.manufacturers, {"slug": "generic"}, name="Generic", slug="generic")dtype = ensure(nb.dcim.device_types, {"slug": "generic-48p"}, manufacturer=mfr.id, model="Generic 48-port switch", slug="generic-48p")role = ensure(nb.dcim.device_roles, {"slug": "access-switch"}, name="Access switch", slug="access-switch", color="2196f3")dev = ensure(nb.dcim.devices, {"name": "sw-01", "site_id": site.id}, name="sw-01", site=site.id, role=role.id, device_type=dtype.id, status="active")iface = ensure(nb.dcim.interfaces, {"device_id": dev.id, "name": "Ethernet1"}, device=dev.id, name="Ethernet1", type="1000base-t")ip = ensure(nb.ipam.ip_addresses, {"address": "192.0.2.11/24"}, address="192.0.2.11/24", status="active", assigned_object_type="dcim.interface", assigned_object_id=iface.id)if dev.primary_ip4 is None or dev.primary_ip4.id != ip.id: dev.update({"primary_ip4": ip.id})print("ok:", dev.name, "at", site.name, "primary", ip.address)$python device.py$python -c 'import pynetbox; nb = pynetbox.api("https://", token=""); print(nb.dcim.devices.get(name="sw-01").primary_ip4.address)'192.0.2.11/24
If a create answers 400 or 403
A 400 comes with a JSON body naming the field: read it, it is precise ("slug": ["This field is required."], "status": ["\"Active\" is not a valid choice."]). A 403 on a create you expected to work is the constraint of the permission: the device was saved, checked against site__name, and rolled back. Check the site name in the permission against , spelling and case.
Bulk import from CSV
name,site,role,device_type,statussw-02,paris-dc1,access-switch,generic-48p,plannedsw-03,paris-dc1,access-switch,generic-48p,plannedsw-04,paris-dc1,no-such-role,generic-48p,plannedimport csvimport pynetboxnb = pynetbox.api("https://", token="")created = skipped = failed = 0with open("devices.csv", newline="") as f: for row in csv.DictReader(f): if nb.dcim.devices.get(name=row["name"], site=row["site"]) is not None: skipped += 1 continue try: nb.dcim.devices.create( name=row["name"], status=row["status"], site={"slug": row["site"]}, role={"slug": row["role"]}, device_type={"slug": row["device_type"]}, ) created += 1 except pynetbox.RequestError as e: failed += 1 print(f"FAILED {row['name']}: {e.error}")print(f"created {created}, skipped {skipped}, failed {failed}")$python import_devices.py$python import_devices.py | tail -1created 0, skipped 2, failed 1
Event rules and webhooks
$api=https:///api$auth="Authorization: Token "$curl -sk -H "$auth" -H 'Content-Type: application/json' "$api/extras/webhooks/" -d '{$ "name": "device-changes", "payload_url": "",$ "http_method": "POST", "http_content_type": "application/json"$}'With the webhook's id as action_object_id:
$curl -sk -H "$auth" -H 'Content-Type: application/json' "$api/extras/event-rules/" -d '{$ "name": "device created or updated",$ "object_types": ["dcim.device"], "event_types": ["object_created", "object_updated"],$ "action_type": "webhook", "action_object_type": "extras.webhook", "action_object_id": ID,$ "enabled": true$}'A receiver to see the payloads. python -m http.server will not do, it never shows request bodies; ten lines of Flask do:
from urllib.parse import urlparsefrom flask import Flask, requesttarget = urlparse("")app = Flask(__name__)@app.post(target.path or "/")def hook(): body = request.get_json(force=True) print(body.get("event"), body.get("model"), body.get("data", {}).get("name"), flush=True) return "", 204app.run(host="0.0.0.0", port=target.port or 80)$pip install flask && python receiver.pyFrom the NetBox server, prove it can reach the receiver, then change a device and watch the terminal:
$curl -s -o /dev/null -w '%{http_code}' -X POST -H 'Content-Type: application/json' -d '{"event": "test", "model": "none", "data": {}}' 204
$python -c 'import pynetbox; nb = pynetbox.api("https://", token=""); nb.dcim.devices.get(name="sw-01").update({"description": "hello webhook"})'$curl -sk -H 'Authorization: Token ' "https:///api/extras/event-rules/?name=device%20created%20or%20updated" | python3 -c 'import sys,json; print(json.load(sys.stdin)["count"])'1
If nothing arrives
In order: systemctl is-active netbox-rq on the server; the curl check above from the server, not from your machine (a firewall between the two is the usual cause); the event rule is enabled and lists dcim.device; Operations → Background Tasks shows the job and its error. With an https:// receiver and a private CA, set ssl_verification to false on the webhook for the test, then fix the CA.
Custom scripts
from dcim.choices import DeviceStatusChoicesfrom dcim.models import Device, DeviceRole, DeviceType, Sitefrom extras.scripts import IntegerVar, ObjectVar, Script, StringVarclass CreateDevices(Script): class Meta: name = "Create N devices" description = "Numbered devices at one site, planned status" commit_default = False site = ObjectVar(model=Site) role = ObjectVar(model=DeviceRole) device_type = ObjectVar(model=DeviceType) prefix = StringVar(default="sw-", description="Name prefix") count = IntegerVar(default=2, min_value=1, max_value=50) def run(self, data, commit): for i in range(1, data["count"] + 1): name = f"{data['prefix']}{i:02d}" device, created = Device.objects.get_or_create( name=name, site=data["site"], defaults={"role": data["role"], "device_type": data["device_type"], "status": DeviceStatusChoices.STATUS_PLANNED}, ) self.log_success(f"created {name}" if created else f"exists {name}") return f"{data['count']} devices checked at {data['site']}"Upload it in the UI: Customization → Scripts → Add, pick the file. Run it once from the form with Commit unticked to see the log, then from the API:
$api=https:///api$auth="Authorization: Token "$curl -sk -H "$auth" "$api/extras/scripts/" | python3 -m json.tool | grep -E '"(id|name)"'$curl -sk -H "$auth" -H 'Content-Type: application/json' "$api/extras/scripts/ID/" -d '{$ "data": {"site": SITE_ID, "role": ROLE_ID, "device_type": TYPE_ID, "prefix": "sw-", "count": 3},$ "commit": true$}'$curl -sk -H 'Authorization: Token ' https:///api/extras/scripts/ | grep -q CreateDevices && echo foundfound
Done
NetBox now has a user made for scripts with rights bounded to , you can create a device with its interface and IP in one idempotent run, import a CSV without fearing a bad row, other systems hear about device changes, and a script inside NetBox does the repetitive work from a form or from the API. The series ends here; the next ones put this to use in a NetDevOps lab.