# Source of "Automate NetBox with the API"

The files of this tutorial, as they are in the repository. The authoring brief that defines the format follows them.

````yaml title="content/netbox/series.yaml"
title:
  en: NetBox
  fr: NetBox
summary:
  en: >-
    NetBox is the source of truth for your network: devices, IPs, cables, circuits.
    This series takes it from a blank server to something you maintain and automate against.
  fr: >-
    NetBox est la source de vérité de ton réseau : équipements, IP, câbles, circuits.
    Cette série part d'un serveur vierge et va jusqu'au maintien et à l'automatisation.
order:
  - install-from-scratch
  - configure-for-your-team
  - maintain-and-upgrade
  - automate-with-the-api

# Shared by every page: the reader fills these once for the whole series.
groups:
  - id: host
    label: { en: Server, fr: Serveur }
    desc: { en: Where NetBox runs and how it is reached., fr: Où tourne NetBox et comment on l'atteint. }
  - id: auth
    label: { en: Directory, fr: Annuaire }
    desc: { en: Your LDAP or Active Directory., fr: Ton LDAP ou Active Directory. }
    when: { flag: LDAP }

vars:
  - key: NETBOX_HOST
    kind: hostname
    group: host
    default: netbox.example.com
    label: { en: NetBox hostname, fr: Nom d'hôte de NetBox }
    hint:
      en: The address users will type in their browser to reach NetBox. It must already point to this server in your DNS.
      fr: L'adresse que les utilisateurs taperont dans le navigateur pour atteindre NetBox. Elle doit déjà pointer vers ce serveur dans ton DNS.
    impact:
      en: Written into ALLOWED_HOSTS (Django refuses any other name with a 400 error), into the web server configuration and into the TLS certificate. Several names are possible, separated by commas.
      fr: Écrit dans ALLOWED_HOSTS (Django refuse tout autre nom avec une erreur 400), dans la configuration du serveur web et dans le certificat TLS. Plusieurs noms sont possibles, séparés par des virgules.
  - key: INSTALL_DIR
    kind: path
    group: host
    default: /opt/netbox
    label: { en: Install directory, fr: Répertoire d'installation }
    hint:
      en: The folder where NetBox's code and Python environment live. Keep the default unless your organisation has a rule about it.
      fr: Le dossier où vivent le code de NetBox et son environnement Python. Garde la valeur par défaut sauf règle interne.
    impact:
      en: The systemd units, the upgrade script and the web server configuration shipped with NetBox all assume /opt/netbox. Changing it means editing those files too.
      fr: Les unités systemd, le script d'upgrade et la configuration du serveur web livrés avec NetBox supposent tous /opt/netbox. Le changer oblige à éditer ces fichiers aussi.
  - key: LDAP_URI
    kind: text
    group: auth
    default: ldaps://ad.example.com:636
    label: { en: LDAP server URI, fr: URI du serveur LDAP }
    hint:
      en: Address of your directory server, with the protocol. Use ldaps:// (port 636) so credentials travel encrypted.
      fr: Adresse de ton serveur d'annuaire, avec le protocole. Utilise ldaps:// (port 636) pour que les identifiants circulent chiffrés.
    impact:
      en: Every login makes NetBox contact this address. If it is unreachable, nobody can log in except local accounts such as the first administrator.
      fr: Chaque connexion fait contacter cette adresse par NetBox. Si elle est injoignable, plus personne ne peut se connecter sauf les comptes locaux comme le premier administrateur.
  - key: LDAP_BIND_DN
    kind: text
    group: auth
    default: CN=netbox,OU=Service Accounts,DC=example,DC=com
    label: { en: Bind DN, fr: DN de bind }
    hint:
      en: The directory account NetBox uses to search for users before checking their password. A dedicated read-only service account, written as a full distinguished name.
      fr: Le compte d'annuaire que NetBox utilise pour chercher les utilisateurs avant de vérifier leur mot de passe. Un compte de service dédié en lecture seule, écrit sous forme de DN complet.
    impact:
      en: Its password goes into ldap_config.py. Give it read access to the users and groups subtrees only.
      fr: Son mot de passe va dans ldap_config.py. Donne-lui un accès en lecture aux sous-arbres utilisateurs et groupes uniquement.
  - key: LDAP_BASE_DN
    kind: text
    group: auth
    default: DC=example,DC=com
    label: { en: User search base, fr: Base de recherche utilisateurs }
    hint:
      en: Where in the directory tree NetBox looks for people. Usually the domain root, or a narrower OU if only some staff should log in.
      fr: Où, dans l'arbre de l'annuaire, NetBox cherche les personnes. En général la racine du domaine, ou une OU plus étroite si seule une partie du personnel doit se connecter.
    impact:
      en: A user outside this subtree simply cannot log in, with no explicit error. Combine with a group filter for finer control.
      fr: Un utilisateur hors de ce sous-arbre ne peut simplement pas se connecter, sans erreur explicite. Combine avec un filtre de groupe pour un contrôle plus fin.

choices:
  - key: OS
    type: select
    label: { en: Distribution, fr: Distribution }
    default: ubuntu
    options:
      - { value: ubuntu, label: { en: Ubuntu 24.04, fr: Ubuntu 24.04 } }
      - { value: rhel, label: { en: RHEL 9 / Rocky / Alma, fr: RHEL 9 / Rocky / Alma } }
  - key: WEB
    type: select
    label: { en: Web server, fr: Serveur web }
    default: nginx
    options:
      - { value: nginx, label: { en: nginx, fr: nginx } }
      - { value: apache, label: { en: Apache, fr: Apache } }
  - key: LDAP
    type: boolean
    label: { en: Authenticate against LDAP / Active Directory, fr: Authentifier via LDAP / Active Directory }
    default: false
````

````yaml title="content/netbox/automate-with-the-api/tuto.yaml"
# Inherits from ../series.yaml: NETBOX_HOST, INSTALL_DIR, OS, WEB, LDAP and the host/auth groups.
# NETBOX_TOKEN is also declared by configure-for-your-team: same key, the reader fills it once per series.

title:
  en: Automate NetBox with the API
  fr: Automatiser NetBox par l'API
summary:
  en: >-
    A scoped automation user, pynetbox or curl to read and write, a CSV import that survives
    errors, webhooks on device changes, and a custom script run from the API.
  fr: >-
    Un utilisateur d'automatisation aux droits bornés, pynetbox ou curl pour lire et écrire, un import
    CSV qui survit aux erreurs, des webhooks sur les changements d'équipements, et un script maison lancé par l'API.
difficulty: intermediate
tags: [netbox, api, pynetbox, python, webhooks, automation]
authors: [thudal]
created: 2026-09-25
minutes: 45
validated: NetBox 4.4 · Ubuntu 24.04 · pynetbox 7.x
status: draft             # not yet run end to end by its author

groups:
  - id: api
    label: { en: API access, fr: Accès API }
  - id: data
    label: { en: Sample data, fr: Données d'exemple }
    desc: { en: What the page creates in NetBox., fr: Ce que la page crée dans NetBox. }
  - id: events
    label: { en: Events, fr: Événements }
    desc: { en: Where NetBox sends webhooks., fr: Où NetBox envoie les webhooks. }
    when: { flag: EVENTS }

vars:
  - key: NETBOX_TOKEN
    kind: secret
    group: api
    default: ""
    label: { en: API token, fr: Jeton d'API }
    hint:
      en: Your administrator token, as on the previous pages. It creates the automation user, its permissions, the event rules and runs the custom script.
      fr: Ton jeton administrateur, comme sur les pages précédentes. Il crée l'utilisateur d'automatisation, ses permissions, les règles d'événements et lance le script maison.
    impact:
      en: A token carries the permissions of its user. Every command here sends it in the Authorization header over HTTPS; it never goes anywhere else.
      fr: Un jeton porte les permissions de son utilisateur. Chaque commande ici l'envoie dans l'en-tête Authorization en HTTPS ; il ne va nulle part ailleurs.
  - key: AUTOMATION_TOKEN
    kind: secret
    group: api
    default: ""
    label: { en: Automation token, fr: Jeton d'automatisation }
    hint:
      en: The token created for the automation user in the first step. Every read and write of data on this page uses it; the admin token only creates users, permissions, event rules and scripts.
      fr: Le jeton créé pour l'utilisateur d'automatisation à la première étape. Toutes les lectures et écritures de données de cette page l'utilisent ; le jeton admin ne sert qu'à créer utilisateurs, permissions, règles d'événements et scripts.
    impact:
      en: Scoped by the permissions of the first step, with an expiry date. If a call answers 403, the permission is missing, not the token.
      fr: Borné par les permissions de la première étape, avec une date d'expiration. Si un appel répond 403, c'est une permission qui manque, pas le jeton.
  - key: AUTOMATION_USER
    kind: user
    group: api
    default: netbox-automation
    label: { en: Automation user, fr: Utilisateur d'automatisation }
    hint:
      en: A NetBox account used only by scripts. Its name shows up in the change log next to every automated change.
      fr: Un compte NetBox utilisé uniquement par des scripts. Son nom apparaît dans le journal des changements à côté de chaque modification automatisée.
    impact:
      en: One user per system that writes (CI, IPAM sync, provisioning) keeps the change log readable and lets you revoke one without touching the others.
      fr: Un utilisateur par système qui écrit (CI, synchro IPAM, provisionnement) garde le journal lisible et permet d'en révoquer un sans toucher aux autres.
  - key: SITE_NAME
    kind: text
    group: data
    default: Paris DC1
    label: { en: Site name, fr: Nom du site }
    hint:
      en: The site the page creates and fills with devices. Its slug is derived from it (lowercase, dashes).
      fr: Le site que la page crée et remplit d'équipements. Son slug en est dérivé (minuscules, tirets).
    impact:
      en: Also used as the constraint on the automation user's write permission, so it may only touch devices of this site.
      fr: Sert aussi de contrainte à la permission d'écriture de l'utilisateur d'automatisation, qui ne peut toucher que les équipements de ce site.
  - key: WEBHOOK_URL
    kind: url
    group: events
    default: http://203.0.113.50:9000/netbox
    when: { flag: EVENTS }
    label: { en: Webhook receiver URL, fr: URL du récepteur de webhooks }
    hint:
      en: Where NetBox POSTs when a device changes. For the test, your own machine, on a port the NetBox server can reach.
      fr: Là où NetBox fait un POST quand un équipement change. Pour le test, ta propre machine, sur un port que le serveur NetBox peut joindre.
    impact:
      en: The RQ worker on the server makes the call; a firewall between the two shows up as failed jobs in the queue, not as an error in the UI.
      fr: C'est le worker RQ du serveur qui fait l'appel ; un pare-feu entre les deux se voit comme des tâches en échec dans la file, pas comme une erreur dans l'interface.

choices:
  - key: CLIENT
    type: select
    label: { en: Client, fr: Client }
    hint: { en: pynetbox for anything beyond two calls; curl to see the raw HTTP., fr: pynetbox au-delà de deux appels ; curl pour voir le HTTP brut. }
    default: pynetbox
    options:
      - { value: pynetbox, label: { en: pynetbox (Python), fr: pynetbox (Python) } }
      - { value: curl, label: { en: curl, fr: curl } }
  - key: EVENTS
    type: boolean
    label: { en: Event rules and webhooks, fr: Règles d'événements et webhooks }
    hint: { en: NetBox calls your systems when a device changes., fr: NetBox appelle tes systèmes quand un équipement change. }
    default: true
````

````mdx title="content/netbox/automate-with-the-api/page-en.mdx"
{/* First pass — to be validated against docs.netbox.dev (REST API, event rules & webhooks, custom scripts, GraphQL) and pynetbox.readthedocs.io before publishing. */}

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.

<Run>

The script creates the automation user and its token with your administrator token, then, as that user, a site with a device, an interface and a primary IP<When flag="EVENTS">, and finally a webhook and an event rule on devices</When>. Run it on your machine; the custom script step needs the UI and is left out.

```bash
#!/usr/bin/env bash
set -euo pipefail
# NetBox — automation against https://${NETBOX_HOST}, from your machine
api=https://${NETBOX_HOST}/api
tok=${NETBOX_TOKEN}
call() { curl -skf -H "Authorization: Token $tok" -H 'Content-Type: application/json' "$api$1" "${@:2}"; }
pk() { python3 -c 'import sys,json; print(json.load(sys.stdin)["id"])'; }

uid=$(call /users/users/ -d "{\"username\": \"${AUTOMATION_USER}\", \"password\": \"$(openssl rand -base64 24)\"}" | pk)
call /users/permissions/ -d @- >/dev/null <<EOF
{"name": "automation: view", "actions": ["view"], "users": [$uid],
 "object_types": ["dcim.site", "dcim.manufacturer", "dcim.devicerole", "dcim.devicetype", "dcim.device", "dcim.interface", "ipam.ipaddress"]}
EOF
call /users/permissions/ -d @- >/dev/null <<EOF
{"name": "automation: catalogue", "actions": ["add", "change"], "users": [$uid],
 "object_types": ["dcim.site", "dcim.manufacturer", "dcim.devicerole", "dcim.devicetype", "dcim.interface", "ipam.ipaddress"]}
EOF
call /users/permissions/ -d @- >/dev/null <<EOF
{"name": "automation: devices of ${SITE_NAME}", "actions": ["add", "change", "delete"], "users": [$uid],
 "object_types": ["dcim.device"], "constraints": {"site__name": "${SITE_NAME}"}}
EOF
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"))')
call /users/tokens/ -d "{\"user\": $uid, \"key\": \"$key\", \"expires\": \"$exp\", \"write_enabled\": true, \"description\": \"automation\"}" >/dev/null
echo "Automation token, store it as AUTOMATION_TOKEN: $key"
```

<When flag="EVENTS">

```bash
wid=$(call /extras/webhooks/ -d "{\"name\": \"device-changes\", \"payload_url\": \"${WEBHOOK_URL}\", \"http_method\": \"POST\", \"http_content_type\": \"application/json\"}" | pk)
call /extras/event-rules/ -d @- >/dev/null <<EOF
{"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": $wid, "enabled": true}
EOF
```

</When>

```bash
python3 -m venv ~/.venvs/netbox && ~/.venvs/netbox/bin/pip install -q pynetbox
NB_TOKEN=$key ~/.venvs/netbox/bin/python - <<'EOF'
import os, re, pynetbox
nb = pynetbox.api("https://${NETBOX_HOST}", token=os.environ["NB_TOKEN"])
slug = lambda s: re.sub(r"[^a-z0-9]+", "-", s.lower()).strip("-")
def ensure(endpoint, lookup, **fields):
    obj = endpoint.get(**lookup)
    return obj if obj is not None else endpoint.create(**fields)
site = ensure(nb.dcim.sites, {"slug": slug("${SITE_NAME}")}, name="${SITE_NAME}", slug=slug("${SITE_NAME}"), 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)
EOF
```

<Warn>The token is printed once. Store it in your values (AUTOMATION_TOKEN) or in your secret manager before closing the terminal; NetBox will not show it again.</Warn>

</Run>

## Before you start

<Guided>You need the administrator token from the *Configure* page (NETBOX_TOKEN), Python 3.10 or newer on your machine, and network access to **https://<V name="NETBOX_HOST" />/**. The pynetbox library goes in a virtual environment on your machine, never on the server: automation talks to NetBox the way any other system does, over HTTPS.</Guided>

```bash
python3 -m venv ~/.venvs/netbox
source ~/.venvs/netbox/bin/activate
pip install pynetbox
```

<Check cmd="pip show pynetbox | head -1" expect="Name: pynetbox" />

<Note>With the self-signed certificate of the install page, `curl` needs `-k` and pynetbox needs `nb.http_session.verify = False`. Better: copy `/etc/ssl/certs/netbox.crt` from the server and point `REQUESTS_CA_BUNDLE` (and `curl --cacert`) at it. Even with curl as your client, one step below uses Python, since it chains six objects by id.</Note>

<Deep>The REST API is Django REST Framework under `/api/`, one endpoint per model, browsable in a browser at **https://<V name="NETBOX_HOST" />/api/** and documented at `/api/schema/swagger-ui/`. Every object you see in the UI is a JSON document there, with the same permissions. pynetbox is a thin client: `nb.dcim.devices` is `/api/dcim/devices/`, `.get()` is a GET with filters, `.create()` a POST, `.update()` a PATCH. When something is unclear, read the raw JSON: `curl` never lies.</Deep>

## An automation user and its token

<Guided>Scripts must not run as you: the change log would say you did everything, and revoking your token would stop them. We create a user, give it exactly the rights it needs, and issue it a token with an expiry date. All three are API objects, so this is three `curl` calls with your administrator token.</Guided>

```bash
api=https://${NETBOX_HOST}/api
auth="Authorization: Token ${NETBOX_TOKEN}"
curl -sk -H "$auth" -H 'Content-Type: application/json' "$api/users/users/" \
  -d "{\"username\": \"${AUTOMATION_USER}\", \"password\": \"$(openssl rand -base64 24)\"}"
```

Note the `id` in the answer; it goes into the `users` list of the permissions. Three of them:

<Tabs group="permissions">

<Tab label="view">

```bash
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"]
}'
```

</Tab>

<Tab label="catalogue">

```bash
curl -sk -H "$auth" -H 'Content-Type: application/json' "$api/users/permissions/" -d '{
  "name": "automation: catalogue", "actions": ["add", "change"], "users": [ID],
  "object_types": ["dcim.site", "dcim.manufacturer", "dcim.devicerole", "dcim.devicetype",
                   "dcim.interface", "ipam.ipaddress"]
}'
```

</Tab>

<Tab label="devices, one site">

```bash
curl -sk -H "$auth" -H 'Content-Type: application/json' "$api/users/permissions/" -d '{
  "name": "automation: devices of ${SITE_NAME}", "actions": ["add", "change", "delete"], "users": [ID],
  "object_types": ["dcim.device"],
  "constraints": {"site__name": "${SITE_NAME}"}
}'
```

</Tab>

</Tabs>

<Deep>`constraints` is a Django filter evaluated against the object. On *view*, *change* and *delete* it restricts which rows the user sees; on *add* NetBox saves the object, checks it matches the constraint, and rolls back with a 403 if it does not. So the automation user can create devices, but only at <V name="SITE_NAME" />: a bug in a script cannot touch another site. A list of dicts is an OR of constraints. Interfaces would take `device__site__name`; IP addresses have no site, hence the unconstrained catalogue permission on them.</Deep>

Now the token, with an expiry 90 days out:

```bash
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.

<Guided>Tokens live in NetBox's database and are listed under **Admin → API tokens**; each one belongs to one user, can be read-only (`write_enabled: false`), can be limited to source IPs (`allowed_ips`), and can expire. A token without expiry is a password that never rotates. If you prefer clicking: **Admin → Users → Add**, then **API tokens → Add** with that user selected.</Guided>

<Deep>We generate the 40-character key ourselves so the script can print it; leave `key` out and NetBox generates one and returns it in the response. Whether a key can be viewed again later depends on `ALLOW_TOKEN_RETRIEVAL` in `configuration.py`; the safe default is that it cannot. Also note `/api/users/tokens/provision/`: POST a username and password and get a token back, which is how a system with a directory account bootstraps itself without an administrator. Confirm the field names on `/api/schema/swagger-ui/` for your version, tokens changed between 3.x and 4.x.</Deep>

<Check cmd={"curl -sk -o /dev/null -w '%{http_code}' -H 'Authorization: Token ${AUTOMATION_TOKEN}' https://${NETBOX_HOST}/api/dcim/sites/"} expect="200" />

<Check cmd={"curl -sk -o /dev/null -w '%{http_code}' -H 'Authorization: Token ${AUTOMATION_TOKEN}' https://${NETBOX_HOST}/api/users/users/"} expect="403" />

## Read and write

<Guided>First a read, then a write, then the same write again to see that nothing happens: the pattern that makes a script safe to re-run is *get, then create or update*, never a blind create.</Guided>

<When is="CLIENT" equals="pynetbox">

```python title="sites.py"
import re
import pynetbox

nb = pynetbox.api("https://${NETBOX_HOST}", token="${AUTOMATION_TOKEN}")

for site in nb.dcim.sites.all():
    print(site.id, site.name, site.status)

slug = re.sub(r"[^a-z0-9]+", "-", "${SITE_NAME}".lower()).strip("-")
site = nb.dcim.sites.get(slug=slug)
if site is None:
    site = nb.dcim.sites.create(name="${SITE_NAME}", slug=slug, status="active")
    print("created", site.name)
elif site.update({"description": "managed by ${AUTOMATION_USER}"}):
    print("updated", site.name)
else:
    print("unchanged", site.name)
```

```bash
python sites.py && python sites.py
```

<Deep>`get()` returns one object or `None`, and raises if the filter matches several: use it with unique fields (slug, name plus site, id). `filter()` returns a lazy list and follows the API's pagination for you, page by page; `count()` asks the server without fetching. `update()` sends a PATCH with only the fields you pass and returns `True` if something changed, which is what makes the second run print "unchanged". Related objects are passed by id (`site=site.id`) or by a dict of unique fields (`site={"slug": slug}`): NetBox resolves both.</Deep>

<Check cmd={"python -c 'import pynetbox; nb = pynetbox.api(\"https://${NETBOX_HOST}\", token=\"${AUTOMATION_TOKEN}\"); print(nb.dcim.sites.get(name=\"${SITE_NAME}\").status.value)'"} expect="active" />

</When>

<When is="CLIENT" equals="curl">

```bash
api=https://${NETBOX_HOST}/api
auth="Authorization: Token ${AUTOMATION_TOKEN}"
curl -sk -H "$auth" "$api/dcim/sites/?limit=50&brief=1" | python3 -m json.tool
```

```bash
slug=$(echo "${SITE_NAME}" | tr 'A-Z ' 'a-z-')
curl -sk -H "$auth" "$api/dcim/sites/?slug=$slug" | grep -q '"count": 0' && \
curl -sk -H "$auth" -H 'Content-Type: application/json' "$api/dcim/sites/" \
  -d "{\"name\": \"${SITE_NAME}\", \"slug\": \"$slug\", \"status\": \"active\"}"
```

<Deep>Every list answer is an envelope: `count`, `next`, `previous`, `results`. `?limit=50` is the page size (default `PAGINATE_COUNT`, 50; `limit=0` means "the maximum", `MAX_PAGE_SIZE`, 1000 by default); `next` is the URL of the following page, follow it until it is `null`. `?brief=1` returns only id, url, display and the identifying fields, ten times less JSON when you only need ids. NetBox 4 also has `?fields=id,name` to pick exactly what you want. A write is a POST with `Content-Type: application/json`; the answer is the created object, with its `id`. Two calls is where curl stops being pleasant: the device chain below is in Python.</Deep>

<Check cmd={"curl -skG -H 'Authorization: Token ${AUTOMATION_TOKEN}' --data-urlencode 'name=${SITE_NAME}' https://${NETBOX_HOST}/api/dcim/sites/ | python3 -c 'import sys,json; print(json.load(sys.stdin)[\"results\"][0][\"status\"][\"value\"])'"} expect="active" />

</When>

## A device end to end

<Guided>A device needs a site, a role and a device type (itself under a manufacturer); an IP needs an interface to sit on; the device then points at that IP as primary. Six objects, in dependency order, each with the get-or-create pattern. In your environment, run:</Guided>

```python title="device.py"
import re
import pynetbox

nb = pynetbox.api("https://${NETBOX_HOST}", token="${AUTOMATION_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("${SITE_NAME}")},
              name="${SITE_NAME}", slug=slug("${SITE_NAME}"), 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)
```

```bash
python device.py
```

<Deep>Lookups use the *filter* names (`site_id`, `device_id`), creates use the *field* names (`site`, `device`): the API filters on ids, but a POST body names the relation. `assigned_object_type` is the generic relation that lets an IP sit on a device interface, a VM interface or nothing. `primary_ip4` is set last because NetBox refuses an IP that is not assigned to one of the device's interfaces. Choice fields (`status`, `type`) take their value, not their label: `active`, not `Active`; the list of interface types is in the browsable API under `OPTIONS`. Filtering: `nb.dcim.devices.filter(site=slug, role="access-switch", status="active")` takes slugs for related objects and returns a generator; `tag="x"`, `q="sw-"` (search) and `name__isw="sw-"` (lookup expressions) work too.</Deep>

<Check cmd={"python -c 'import pynetbox; nb = pynetbox.api(\"https://${NETBOX_HOST}\", token=\"${AUTOMATION_TOKEN}\"); print(nb.dcim.devices.get(name=\"sw-01\").primary_ip4.address)'"} expect="192.0.2.11/24" />

<Details summary="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 <V name="SITE_NAME" />, spelling and case.
</Details>

## Bulk import from CSV

<Guided>The real work is usually a spreadsheet from somewhere. One loop, one `try` per row, a summary at the end: a bad row is printed and skipped, the others go in. Related objects are given by slug in a dict; NetBox resolves them itself.</Guided>

```csv title="devices.csv"
name,site,role,device_type,status
sw-02,paris-dc1,access-switch,generic-48p,planned
sw-03,paris-dc1,access-switch,generic-48p,planned
sw-04,paris-dc1,no-such-role,generic-48p,planned
```

```python title="import_devices.py"
import csv
import pynetbox

nb = pynetbox.api("https://${NETBOX_HOST}", token="${AUTOMATION_TOKEN}")
created = skipped = failed = 0
with 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}")
```

```bash
python import_devices.py
```

<Check cmd="python import_devices.py | tail -1" expect="created 0, skipped 2, failed 1" />

<Deep>Three tricks for volume. One: `?limit=0` on the pre-flight reads (`nb.dcim.devices.filter(site=slug, limit=0)`) fetches one big page instead of twenty; for thousands of rows, load the existing names once into a `set` and stop calling `get()` per row. Two: `?brief=1` when all you need is ids, as in `{d.slug: d.id for d in nb.dcim.device_types.filter(brief=1)}`. Three: the API accepts a *list* of objects in one POST, which is one request instead of a hundred, but it is atomic: one bad row and nothing is created, with the errors indexed by position. Use it once the loop above has proven the data. `RequestError.error` is the server's JSON, the same body curl would show you. The UI's own **Import** button takes the same CSV, which is the right tool for a one-off.</Deep>

<When flag="EVENTS">

## Event rules and webhooks

<Guided>An event rule says *when* (which object types, which events); a webhook says *where* (a URL, a method, headers). NetBox queues the call and the RQ worker on the server delivers it, so a slow receiver never slows the UI. Config objects: these two calls use your administrator token.</Guided>

```bash
api=https://${NETBOX_HOST}/api
auth="Authorization: Token ${NETBOX_TOKEN}"
curl -sk -H "$auth" -H 'Content-Type: application/json' "$api/extras/webhooks/" -d '{
  "name": "device-changes", "payload_url": "${WEBHOOK_URL}",
  "http_method": "POST", "http_content_type": "application/json"
}'
```

With the webhook's `id` as `action_object_id`:

```bash
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
}'
```

<Note>Event rules gained the `event_types` list in NetBox 4.1; earlier versions used `type_create` / `type_update` booleans. Check **Operations → Event Rules** in the UI: the objects are there, and the form shows the exact field names of your version.</Note>

A receiver to see the payloads. `python -m http.server` will not do, it never shows request bodies; ten lines of Flask do:

```python title="receiver.py"
from urllib.parse import urlparse
from flask import Flask, request

target = urlparse("${WEBHOOK_URL}")
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 "", 204

app.run(host="0.0.0.0", port=target.port or 80)
```

```bash
pip install flask && python receiver.py
```

From the NetBox server, prove it can reach the receiver, then change a device and watch the terminal:

<Check cmd={"curl -s -o /dev/null -w '%{http_code}' -X POST -H 'Content-Type: application/json' -d '{\"event\": \"test\", \"model\": \"none\", \"data\": {}}' ${WEBHOOK_URL}"} expect="204" />

```bash
python -c 'import pynetbox; nb = pynetbox.api("https://${NETBOX_HOST}", token="${AUTOMATION_TOKEN}"); nb.dcim.devices.get(name="sw-01").update({"description": "hello webhook"})'
```

<Check cmd={"curl -sk -H 'Authorization: Token ${NETBOX_TOKEN}' \"https://${NETBOX_HOST}/api/extras/event-rules/?name=device%20created%20or%20updated\" | python3 -c 'import sys,json; print(json.load(sys.stdin)[\"count\"])'"} expect="1" />

<Deep>The payload carries `event`, `model`, `timestamp`, `username`, `request_id`, `data` (the object as the API shows it) and, on updates, `snapshots` with `prechange` and `postchange`: diff them to know what changed. `netbox-rq` must be running, or events queue in Redis and nothing is delivered; `rq-workers-running` in `/api/status/` is the check. Failed deliveries are visible under **Operations → Background Tasks**; the worker retries nothing by itself, so a receiver must answer 2xx quickly and do its work afterwards. Give the webhook a `secret` and check the `X-Hook-Signature` header (HMAC-SHA512 of the body) in the receiver before trusting anything. `conditions` on the event rule filters events server-side, `{"attr": "status.value", "value": "active"}` for example. For a quick look without Flask, `manage.py webhook_receiver` on the server itself prints every request it gets on port 9000.</Deep>

<Details summary="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.
</Details>

</When>

## Custom scripts

<Guided>Some automation belongs inside NetBox: a form users fill in, a job that runs with NetBox's own models, a log they can read. A custom script is a Python class with variables (the form) and a `run()` method. This one creates N numbered devices at a site, and refuses to do anything when the name exists.</Guided>

```python title="create_devices.py"
from dcim.choices import DeviceStatusChoices
from dcim.models import Device, DeviceRole, DeviceType, Site
from extras.scripts import IntegerVar, ObjectVar, Script, StringVar


class 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:

```bash
api=https://${NETBOX_HOST}/api
auth="Authorization: Token ${NETBOX_TOKEN}"
curl -sk -H "$auth" "$api/extras/scripts/" | python3 -m json.tool | grep -E '"(id|name)"'
```

```bash
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
}'
```

<Check cmd={"curl -sk -H 'Authorization: Token ${NETBOX_TOKEN}' https://${NETBOX_HOST}/api/extras/scripts/ | grep -q CreateDevices && echo found"} expect="found" />

<Deep>Scripts run as background jobs on the RQ worker, under the user who launched them, with their permissions; the answer to the POST is the job (`result.id`), poll `/api/core/jobs/ID/` for `status` and the log. `commit_default = False` makes the UI form dry-run unless the user ticks the box; the API caller decides with `commit`. Since NetBox 4.0 scripts are registered through the UI or synced from a **data source** (a git repository under **Operations → Data Sources**), which is how you version them: a push to the repo, a sync, and the new version is live. `self.log_*` lines are the job's log; a raised exception marks the job failed and rolls back the transaction. Keep scripts small and put logic that other systems need in a library both can import.</Deep>

<Deep>GraphQL: NetBox exposes `/graphql/` (`GRAPHQL_ENABLED`, on by default) with an in-browser explorer at that URL. One query fetches a device tree that would take four REST calls: `curl -sk -H "$auth" -H 'Content-Type: application/json' https://${NETBOX_HOST}/graphql/ -d '{"query": "{ device_list { name site { name } primary_ip4 { address } } }"}'`. Filters exist (`device_list(filters: …)`) and their syntax changed in 4.3: write them in the explorer, which autocompletes against your version's schema, then paste into code. Reads only; writes stay on REST.</Deep>

## Done

NetBox now has a user made for scripts with rights bounded to <V name="SITE_NAME" />, you can create a device with its interface and IP in one idempotent run, import a CSV without fearing a bad row<When flag="EVENTS">, other systems hear about device changes</When>, 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.
````

````mdx title="content/netbox/automate-with-the-api/page-fr.mdx"
{/* Première passe — à valider contre docs.netbox.dev (REST API, event rules & webhooks, custom scripts, GraphQL) et pynetbox.readthedocs.io avant publication. */}

Tout ce que tu as cliqué dans les pages précédentes est un appel HTTP, et NetBox ne vaut le coup que quand d'autres systèmes le lisent et l'écrivent. Cette page tourne sur **ta** machine, pas sur le serveur : un utilisateur fait pour les scripts, pynetbox ou curl pour lire et écrire, un import CSV qui survit à une mauvaise ligne, des webhooks quand un équipement change, et un script qui tourne dans NetBox à la demande.

<Run>

Le script crée l'utilisateur d'automatisation et son jeton avec ton jeton administrateur, puis, sous cet utilisateur, un site avec un équipement, une interface et une IP primaire<When flag="EVENTS">, et enfin un webhook et une règle d'événement sur les équipements</When>. Lance-le sur ta machine ; l'étape du script maison passe par l'interface et n'en fait pas partie.

```bash
#!/usr/bin/env bash
set -euo pipefail
# NetBox — automatisation de https://${NETBOX_HOST}, depuis ta machine
api=https://${NETBOX_HOST}/api
tok=${NETBOX_TOKEN}
call() { curl -skf -H "Authorization: Token $tok" -H 'Content-Type: application/json' "$api$1" "${@:2}"; }
pk() { python3 -c 'import sys,json; print(json.load(sys.stdin)["id"])'; }

uid=$(call /users/users/ -d "{\"username\": \"${AUTOMATION_USER}\", \"password\": \"$(openssl rand -base64 24)\"}" | pk)
call /users/permissions/ -d @- >/dev/null <<EOF
{"name": "automation: view", "actions": ["view"], "users": [$uid],
 "object_types": ["dcim.site", "dcim.manufacturer", "dcim.devicerole", "dcim.devicetype", "dcim.device", "dcim.interface", "ipam.ipaddress"]}
EOF
call /users/permissions/ -d @- >/dev/null <<EOF
{"name": "automation: catalogue", "actions": ["add", "change"], "users": [$uid],
 "object_types": ["dcim.site", "dcim.manufacturer", "dcim.devicerole", "dcim.devicetype", "dcim.interface", "ipam.ipaddress"]}
EOF
call /users/permissions/ -d @- >/dev/null <<EOF
{"name": "automation: devices of ${SITE_NAME}", "actions": ["add", "change", "delete"], "users": [$uid],
 "object_types": ["dcim.device"], "constraints": {"site__name": "${SITE_NAME}"}}
EOF
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"))')
call /users/tokens/ -d "{\"user\": $uid, \"key\": \"$key\", \"expires\": \"$exp\", \"write_enabled\": true, \"description\": \"automation\"}" >/dev/null
echo "Jeton d'automatisation, à garder comme AUTOMATION_TOKEN : $key"
```

<When flag="EVENTS">

```bash
wid=$(call /extras/webhooks/ -d "{\"name\": \"device-changes\", \"payload_url\": \"${WEBHOOK_URL}\", \"http_method\": \"POST\", \"http_content_type\": \"application/json\"}" | pk)
call /extras/event-rules/ -d @- >/dev/null <<EOF
{"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": $wid, "enabled": true}
EOF
```

</When>

```bash
python3 -m venv ~/.venvs/netbox && ~/.venvs/netbox/bin/pip install -q pynetbox
NB_TOKEN=$key ~/.venvs/netbox/bin/python - <<'EOF'
import os, re, pynetbox
nb = pynetbox.api("https://${NETBOX_HOST}", token=os.environ["NB_TOKEN"])
slug = lambda s: re.sub(r"[^a-z0-9]+", "-", s.lower()).strip("-")
def ensure(endpoint, lookup, **fields):
    obj = endpoint.get(**lookup)
    return obj if obj is not None else endpoint.create(**fields)
site = ensure(nb.dcim.sites, {"slug": slug("${SITE_NAME}")}, name="${SITE_NAME}", slug=slug("${SITE_NAME}"), 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)
EOF
```

<Warn>Le jeton n'est affiché qu'une fois. Range-le dans tes valeurs (AUTOMATION_TOKEN) ou dans ton gestionnaire de secrets avant de fermer le terminal ; NetBox ne le remontrera pas.</Warn>

</Run>

## Avant de commencer

<Guided>Il te faut le jeton administrateur de la page *Configurer* (NETBOX_TOKEN), Python 3.10 ou plus récent sur ta machine, et un accès réseau à **https://<V name="NETBOX_HOST" />/**. La bibliothèque pynetbox va dans un environnement virtuel sur ta machine, jamais sur le serveur : l'automatisation parle à NetBox comme n'importe quel autre système, en HTTPS.</Guided>

```bash
python3 -m venv ~/.venvs/netbox
source ~/.venvs/netbox/bin/activate
pip install pynetbox
```

<Check cmd="pip show pynetbox | head -1" expect="Name: pynetbox" />

<Note>Avec le certificat auto-signé de la page d'installation, `curl` a besoin de `-k` et pynetbox de `nb.http_session.verify = False`. Mieux : copie `/etc/ssl/certs/netbox.crt` depuis le serveur et pointe `REQUESTS_CA_BUNDLE` (et `curl --cacert`) dessus. Même avec curl comme client, une étape plus bas utilise Python, parce qu'elle enchaîne six objets par id.</Note>

<Deep>L'API REST, c'est Django REST Framework sous `/api/`, un endpoint par modèle, navigable dans un navigateur à **https://<V name="NETBOX_HOST" />/api/** et documentée sur `/api/schema/swagger-ui/`. Chaque objet visible dans l'interface y est un document JSON, avec les mêmes permissions. pynetbox est un client mince : `nb.dcim.devices` est `/api/dcim/devices/`, `.get()` un GET avec des filtres, `.create()` un POST, `.update()` un PATCH. Quand quelque chose n'est pas clair, lis le JSON brut : `curl` ne ment jamais.</Deep>

## Un utilisateur d'automatisation et son jeton

<Guided>Les scripts ne doivent pas tourner sous ton nom : le journal des changements dirait que tu as tout fait, et révoquer ton jeton les arrêterait. On crée un utilisateur, on lui donne exactement les droits qu'il lui faut, et on lui délivre un jeton avec une date d'expiration. Les trois sont des objets de l'API, donc trois appels `curl` avec ton jeton administrateur.</Guided>

```bash
api=https://${NETBOX_HOST}/api
auth="Authorization: Token ${NETBOX_TOKEN}"
curl -sk -H "$auth" -H 'Content-Type: application/json' "$api/users/users/" \
  -d "{\"username\": \"${AUTOMATION_USER}\", \"password\": \"$(openssl rand -base64 24)\"}"
```

Note l'`id` dans la réponse ; il va dans la liste `users` des permissions. Trois permissions :

<Tabs group="permissions">

<Tab label="lecture">

```bash
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"]
}'
```

</Tab>

<Tab label="catalogue">

```bash
curl -sk -H "$auth" -H 'Content-Type: application/json' "$api/users/permissions/" -d '{
  "name": "automation: catalogue", "actions": ["add", "change"], "users": [ID],
  "object_types": ["dcim.site", "dcim.manufacturer", "dcim.devicerole", "dcim.devicetype",
                   "dcim.interface", "ipam.ipaddress"]
}'
```

</Tab>

<Tab label="équipements, un site">

```bash
curl -sk -H "$auth" -H 'Content-Type: application/json' "$api/users/permissions/" -d '{
  "name": "automation: devices of ${SITE_NAME}", "actions": ["add", "change", "delete"], "users": [ID],
  "object_types": ["dcim.device"],
  "constraints": {"site__name": "${SITE_NAME}"}
}'
```

</Tab>

</Tabs>

<Deep>`constraints` est un filtre Django évalué sur l'objet. Sur *view*, *change* et *delete*, il restreint les lignes que l'utilisateur voit ; sur *add*, NetBox enregistre l'objet, vérifie qu'il correspond à la contrainte, et annule avec un 403 sinon. L'utilisateur d'automatisation peut donc créer des équipements, mais seulement à <V name="SITE_NAME" /> : un bug dans un script ne peut pas toucher un autre site. Une liste de dictionnaires fait un OU de contraintes. Les interfaces prendraient `device__site__name` ; les adresses IP n'ont pas de site, d'où la permission catalogue sans contrainte sur elles.</Deep>

Maintenant le jeton, avec une expiration à 90 jours :

```bash
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"
```

Mets la clé affichée dans tes valeurs comme **AUTOMATION_TOKEN**. À partir d'ici, les appels sur les données l'utilisent.

<Guided>Les jetons vivent dans la base de NetBox et sont listés sous **Admin → Jetons d'API** ; chacun appartient à un utilisateur, peut être en lecture seule (`write_enabled: false`), limité à des IP sources (`allowed_ips`), et peut expirer. Un jeton sans expiration est un mot de passe qui ne tourne jamais. Si tu préfères cliquer : **Admin → Utilisateurs → Ajouter**, puis **Jetons d'API → Ajouter** avec cet utilisateur sélectionné.</Guided>

<Deep>On génère nous-mêmes la clé de 40 caractères pour que le script puisse l'afficher ; omets `key` et NetBox en génère une qu'il renvoie dans la réponse. Qu'une clé puisse être revue plus tard dépend de `ALLOW_TOKEN_RETRIEVAL` dans `configuration.py` ; le défaut sûr est que non. Note aussi `/api/users/tokens/provision/` : un POST avec un nom d'utilisateur et un mot de passe renvoie un jeton, c'est ainsi qu'un système avec un compte d'annuaire s'amorce sans administrateur. Confirme les noms des champs sur `/api/schema/swagger-ui/` pour ta version, les jetons ont changé entre 3.x et 4.x.</Deep>

<Check cmd={"curl -sk -o /dev/null -w '%{http_code}' -H 'Authorization: Token ${AUTOMATION_TOKEN}' https://${NETBOX_HOST}/api/dcim/sites/"} expect="200" />

<Check cmd={"curl -sk -o /dev/null -w '%{http_code}' -H 'Authorization: Token ${AUTOMATION_TOKEN}' https://${NETBOX_HOST}/api/users/users/"} expect="403" />

## Lire et écrire

<Guided>D'abord une lecture, puis une écriture, puis la même écriture une seconde fois pour voir qu'il ne se passe rien : le motif qui rend un script relançable sans risque est *chercher, puis créer ou modifier*, jamais une création à l'aveugle.</Guided>

<When is="CLIENT" equals="pynetbox">

```python title="sites.py"
import re
import pynetbox

nb = pynetbox.api("https://${NETBOX_HOST}", token="${AUTOMATION_TOKEN}")

for site in nb.dcim.sites.all():
    print(site.id, site.name, site.status)

slug = re.sub(r"[^a-z0-9]+", "-", "${SITE_NAME}".lower()).strip("-")
site = nb.dcim.sites.get(slug=slug)
if site is None:
    site = nb.dcim.sites.create(name="${SITE_NAME}", slug=slug, status="active")
    print("created", site.name)
elif site.update({"description": "managed by ${AUTOMATION_USER}"}):
    print("updated", site.name)
else:
    print("unchanged", site.name)
```

```bash
python sites.py && python sites.py
```

<Deep>`get()` renvoie un objet ou `None`, et lève une exception si le filtre en trouve plusieurs : utilise-le avec des champs uniques (slug, nom plus site, id). `filter()` renvoie une liste paresseuse et suit la pagination de l'API pour toi, page par page ; `count()` demande au serveur sans rien rapatrier. `update()` envoie un PATCH avec seulement les champs passés et renvoie `True` si quelque chose a changé, ce qui fait afficher « unchanged » au second passage. Les objets liés se passent par id (`site=site.id`) ou par un dictionnaire de champs uniques (`site={"slug": slug}`) : NetBox résout les deux.</Deep>

<Check cmd={"python -c 'import pynetbox; nb = pynetbox.api(\"https://${NETBOX_HOST}\", token=\"${AUTOMATION_TOKEN}\"); print(nb.dcim.sites.get(name=\"${SITE_NAME}\").status.value)'"} expect="active" />

</When>

<When is="CLIENT" equals="curl">

```bash
api=https://${NETBOX_HOST}/api
auth="Authorization: Token ${AUTOMATION_TOKEN}"
curl -sk -H "$auth" "$api/dcim/sites/?limit=50&brief=1" | python3 -m json.tool
```

```bash
slug=$(echo "${SITE_NAME}" | tr 'A-Z ' 'a-z-')
curl -sk -H "$auth" "$api/dcim/sites/?slug=$slug" | grep -q '"count": 0' && \
curl -sk -H "$auth" -H 'Content-Type: application/json' "$api/dcim/sites/" \
  -d "{\"name\": \"${SITE_NAME}\", \"slug\": \"$slug\", \"status\": \"active\"}"
```

<Deep>Chaque réponse de liste est une enveloppe : `count`, `next`, `previous`, `results`. `?limit=50` est la taille de page (par défaut `PAGINATE_COUNT`, 50 ; `limit=0` veut dire « le maximum », `MAX_PAGE_SIZE`, 1000 par défaut) ; `next` est l'URL de la page suivante, suis-la jusqu'à ce qu'elle soit `null`. `?brief=1` ne renvoie que id, url, display et les champs identifiants, dix fois moins de JSON quand tu n'as besoin que des id. NetBox 4 a aussi `?fields=id,name` pour choisir exactement ce que tu veux. Une écriture est un POST avec `Content-Type: application/json` ; la réponse est l'objet créé, avec son `id`. Deux appels, c'est là où curl cesse d'être agréable : la chaîne d'équipement ci-dessous est en Python.</Deep>

<Check cmd={"curl -skG -H 'Authorization: Token ${AUTOMATION_TOKEN}' --data-urlencode 'name=${SITE_NAME}' https://${NETBOX_HOST}/api/dcim/sites/ | python3 -c 'import sys,json; print(json.load(sys.stdin)[\"results\"][0][\"status\"][\"value\"])'"} expect="active" />

</When>

## Un équipement de bout en bout

<Guided>Un équipement a besoin d'un site, d'un rôle et d'un type (lui-même sous un fabricant) ; une IP a besoin d'une interface pour s'y poser ; l'équipement pointe ensuite cette IP comme primaire. Six objets, dans l'ordre des dépendances, chacun avec le motif chercher-ou-créer. Dans ton environnement, lance :</Guided>

```python title="device.py"
import re
import pynetbox

nb = pynetbox.api("https://${NETBOX_HOST}", token="${AUTOMATION_TOKEN}")
slug = lambda s: re.sub(r"[^a-z0-9]+", "-", s.lower()).strip("-")

def ensure(endpoint, lookup, **fields):
    """Cherche par champs uniques, sinon crée. Relançable sans risque."""
    obj = endpoint.get(**lookup)
    return obj if obj is not None else endpoint.create(**fields)

site = ensure(nb.dcim.sites, {"slug": slug("${SITE_NAME}")},
              name="${SITE_NAME}", slug=slug("${SITE_NAME}"), 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)
```

```bash
python device.py
```

<Deep>Les recherches utilisent les noms de *filtres* (`site_id`, `device_id`), les créations les noms de *champs* (`site`, `device`) : l'API filtre sur des id, mais un corps de POST nomme la relation. `assigned_object_type` est la relation générique qui permet à une IP de se poser sur une interface d'équipement, une interface de VM ou rien. `primary_ip4` est posée en dernier parce que NetBox refuse une IP qui n'est pas assignée à une interface de l'équipement. Les champs à choix (`status`, `type`) prennent leur valeur, pas leur libellé : `active`, pas `Active` ; la liste des types d'interface est dans l'API navigable sous `OPTIONS`. Filtrage : `nb.dcim.devices.filter(site=slug, role="access-switch", status="active")` prend des slugs pour les objets liés et renvoie un générateur ; `tag="x"`, `q="sw-"` (recherche) et `name__isw="sw-"` (expressions de recherche) marchent aussi.</Deep>

<Check cmd={"python -c 'import pynetbox; nb = pynetbox.api(\"https://${NETBOX_HOST}\", token=\"${AUTOMATION_TOKEN}\"); print(nb.dcim.devices.get(name=\"sw-01\").primary_ip4.address)'"} expect="192.0.2.11/24" />

<Details summary="Si une création répond 400 ou 403">
Un 400 vient avec un corps JSON qui nomme le champ : lis-le, il est précis (`"slug": ["This field is required."]`, `"status": ["\"Active\" is not a valid choice."]`). Un 403 sur une création que tu attendais valide, c'est la contrainte de la permission : l'équipement a été enregistré, vérifié contre `site__name`, et annulé. Compare le nom du site dans la permission avec <V name="SITE_NAME" />, orthographe et casse.
</Details>

## Import en masse depuis un CSV

<Guided>Le vrai travail, c'est en général un tableur venu de quelque part. Une boucle, un `try` par ligne, un résumé à la fin : une mauvaise ligne est affichée et sautée, les autres passent. Les objets liés sont donnés par slug dans un dictionnaire ; NetBox les résout lui-même.</Guided>

```csv title="devices.csv"
name,site,role,device_type,status
sw-02,paris-dc1,access-switch,generic-48p,planned
sw-03,paris-dc1,access-switch,generic-48p,planned
sw-04,paris-dc1,no-such-role,generic-48p,planned
```

```python title="import_devices.py"
import csv
import pynetbox

nb = pynetbox.api("https://${NETBOX_HOST}", token="${AUTOMATION_TOKEN}")
created = skipped = failed = 0
with 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}")
```

```bash
python import_devices.py
```

<Check cmd="python import_devices.py | tail -1" expect="created 0, skipped 2, failed 1" />

<Deep>Trois astuces pour le volume. Un : `?limit=0` sur les lectures préalables (`nb.dcim.devices.filter(site=slug, limit=0)`) ramène une grande page au lieu de vingt ; pour des milliers de lignes, charge une fois les noms existants dans un `set` et arrête d'appeler `get()` par ligne. Deux : `?brief=1` quand tu n'as besoin que des id, comme dans `{d.slug: d.id for d in nb.dcim.device_types.filter(brief=1)}`. Trois : l'API accepte une *liste* d'objets dans un seul POST, soit une requête au lieu de cent, mais c'est atomique : une mauvaise ligne et rien n'est créé, avec les erreurs indexées par position. Utilise-le une fois que la boucle ci-dessus a validé les données. `RequestError.error` est le JSON du serveur, le même corps que curl te montrerait. Le bouton **Importer** de l'interface prend le même CSV, c'est le bon outil pour un import unique.</Deep>

<When flag="EVENTS">

## Règles d'événements et webhooks

<Guided>Une règle d'événement dit *quand* (quels types d'objets, quels événements) ; un webhook dit *où* (une URL, une méthode, des en-têtes). NetBox met l'appel en file et le worker RQ du serveur le livre, donc un récepteur lent ne ralentit jamais l'interface. Objets de configuration : ces deux appels utilisent ton jeton administrateur.</Guided>

```bash
api=https://${NETBOX_HOST}/api
auth="Authorization: Token ${NETBOX_TOKEN}"
curl -sk -H "$auth" -H 'Content-Type: application/json' "$api/extras/webhooks/" -d '{
  "name": "device-changes", "payload_url": "${WEBHOOK_URL}",
  "http_method": "POST", "http_content_type": "application/json"
}'
```

Avec l'`id` du webhook comme `action_object_id` :

```bash
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
}'
```

<Note>Les règles d'événements ont gagné la liste `event_types` dans NetBox 4.1 ; les versions antérieures utilisaient des booléens `type_create` / `type_update`. Regarde **Opérations → Règles d'événements** dans l'interface : les objets y sont, et le formulaire montre les noms exacts des champs de ta version.</Note>

Un récepteur pour voir les charges utiles. `python -m http.server` ne convient pas, il n'affiche jamais le corps des requêtes ; dix lignes de Flask le font :

```python title="receiver.py"
from urllib.parse import urlparse
from flask import Flask, request

target = urlparse("${WEBHOOK_URL}")
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 "", 204

app.run(host="0.0.0.0", port=target.port or 80)
```

```bash
pip install flask && python receiver.py
```

Depuis le serveur NetBox, prouve qu'il joint le récepteur, puis modifie un équipement et regarde le terminal :

<Check cmd={"curl -s -o /dev/null -w '%{http_code}' -X POST -H 'Content-Type: application/json' -d '{\"event\": \"test\", \"model\": \"none\", \"data\": {}}' ${WEBHOOK_URL}"} expect="204" />

```bash
python -c 'import pynetbox; nb = pynetbox.api("https://${NETBOX_HOST}", token="${AUTOMATION_TOKEN}"); nb.dcim.devices.get(name="sw-01").update({"description": "hello webhook"})'
```

<Check cmd={"curl -sk -H 'Authorization: Token ${NETBOX_TOKEN}' \"https://${NETBOX_HOST}/api/extras/event-rules/?name=device%20created%20or%20updated\" | python3 -c 'import sys,json; print(json.load(sys.stdin)[\"count\"])'"} expect="1" />

<Deep>La charge utile porte `event`, `model`, `timestamp`, `username`, `request_id`, `data` (l'objet tel que l'API le montre) et, sur les modifications, `snapshots` avec `prechange` et `postchange` : compare-les pour savoir ce qui a changé. `netbox-rq` doit tourner, sinon les événements s'empilent dans Redis et rien n'est livré ; `rq-workers-running` dans `/api/status/` est la vérification. Les livraisons en échec sont visibles sous **Opérations → Tâches de fond** ; le worker ne réessaie rien de lui-même, donc un récepteur doit répondre 2xx vite et travailler ensuite. Donne un `secret` au webhook et vérifie l'en-tête `X-Hook-Signature` (HMAC-SHA512 du corps) dans le récepteur avant de faire confiance à quoi que ce soit. `conditions` sur la règle filtre les événements côté serveur, `{"attr": "status.value", "value": "active"}` par exemple. Pour un coup d'œil sans Flask, `manage.py webhook_receiver` sur le serveur lui-même affiche chaque requête reçue sur le port 9000.</Deep>

<Details summary="Si rien n'arrive">
Dans l'ordre : `systemctl is-active netbox-rq` sur le serveur ; la vérification `curl` ci-dessus depuis le serveur, pas depuis ta machine (un pare-feu entre les deux est la cause habituelle) ; la règle est *activée* et liste `dcim.device` ; **Opérations → Tâches de fond** montre la tâche et son erreur. Avec un récepteur en `https://` et une CA privée, mets `ssl_verification` à `false` sur le webhook pour le test, puis corrige la CA.
</Details>

</When>

## Scripts personnalisés

<Guided>Une partie de l'automatisation a sa place dans NetBox : un formulaire que les utilisateurs remplissent, une tâche qui tourne avec les modèles de NetBox, un journal qu'ils peuvent lire. Un script personnalisé est une classe Python avec des variables (le formulaire) et une méthode `run()`. Celui-ci crée N équipements numérotés sur un site, et ne fait rien quand le nom existe.</Guided>

```python title="create_devices.py"
from dcim.choices import DeviceStatusChoices
from dcim.models import Device, DeviceRole, DeviceType, Site
from extras.scripts import IntegerVar, ObjectVar, Script, StringVar


class 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']}"
```

Téléverse-le dans l'interface : **Personnalisation → Scripts → Ajouter**, choisis le fichier. Lance-le une fois depuis le formulaire avec *Commit* décoché pour voir le journal, puis depuis l'API :

```bash
api=https://${NETBOX_HOST}/api
auth="Authorization: Token ${NETBOX_TOKEN}"
curl -sk -H "$auth" "$api/extras/scripts/" | python3 -m json.tool | grep -E '"(id|name)"'
```

```bash
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
}'
```

<Check cmd={"curl -sk -H 'Authorization: Token ${NETBOX_TOKEN}' https://${NETBOX_HOST}/api/extras/scripts/ | grep -q CreateDevices && echo found"} expect="found" />

<Deep>Les scripts tournent comme tâches de fond sur le worker RQ, sous l'utilisateur qui les a lancés, avec ses permissions ; la réponse au POST est la tâche (`result.id`), interroge `/api/core/jobs/ID/` pour le `status` et le journal. `commit_default = False` fait du formulaire un essai à blanc sauf si l'utilisateur coche la case ; l'appelant de l'API décide avec `commit`. Depuis NetBox 4.0, les scripts s'enregistrent par l'interface ou se synchronisent depuis une **source de données** (un dépôt git sous **Opérations → Sources de données**), c'est ainsi qu'on les versionne : un push sur le dépôt, une synchro, et la nouvelle version est en ligne. Les lignes `self.log_*` sont le journal de la tâche ; une exception levée marque la tâche en échec et annule la transaction. Garde les scripts petits et mets la logique dont d'autres systèmes ont besoin dans une bibliothèque que les deux peuvent importer.</Deep>

<Deep>GraphQL : NetBox expose `/graphql/` (`GRAPHQL_ENABLED`, actif par défaut) avec un explorateur dans le navigateur à cette URL. Une seule requête ramène un arbre d'équipement qui prendrait quatre appels REST : `curl -sk -H "$auth" -H 'Content-Type: application/json' https://${NETBOX_HOST}/graphql/ -d '{"query": "{ device_list { name site { name } primary_ip4 { address } } }"}'`. Les filtres existent (`device_list(filters: …)`) et leur syntaxe a changé en 4.3 : écris-les dans l'explorateur, qui autocomplète sur le schéma de ta version, puis colle-les dans le code. Lecture seule ; les écritures restent en REST.</Deep>

## Terminé

NetBox a maintenant un utilisateur fait pour les scripts, aux droits bornés à <V name="SITE_NAME" />, tu sais créer un équipement avec son interface et son IP en un passage relançable, importer un CSV sans craindre une mauvaise ligne<When flag="EVENTS">, d'autres systèmes apprennent les changements d'équipements</When>, et un script dans NetBox fait le travail répétitif depuis un formulaire ou depuis l'API. La série s'arrête ici ; les suivantes mettent tout ça à profit dans un lab NetDevOps.
````

````yaml title="content/netbox/automate-with-the-api/diagram.yaml"
# The path of an automated change: your script → the REST API → NetBox → the
# queue → the RQ worker → your webhook receiver. Quick shows that chain; Guided
# adds the database, the token and the queue; Deep adds scripts, GraphQL and
# every header, port and unit.
# `desc` is what the reader gets when hovering a box or an arrow.
title: { en: "From your script to your receiver", fr: "De ton script à ton récepteur" }
caption:
  en: "Your script talks to the REST API with the automation user's token. NetBox writes the objects, queues an event, and the RQ worker on the server delivers the webhook to your receiver."
  fr: "Ton script parle à l'API REST avec le jeton de l'utilisateur d'automatisation. NetBox écrit les objets, met un événement en file, et le worker RQ du serveur livre le webhook à ton récepteur."

groups:
  - id: server
    label: { en: "NetBox server", fr: "Serveur NetBox" }
    desc:
      en: "The instance from the first pages. The API is the same Django application as the UI, with the same permissions."
      fr: "L'instance des premières pages. L'API est la même application Django que l'interface, avec les mêmes permissions."

nodes:
  - id: operator
    kind: client
    label: { en: "Your script", fr: "Ton script" }
    sub: "pynetbox · curl"
    when: { is: CLIENT, equals: pynetbox }
    desc:
      en: "pynetbox in a virtual environment on your machine, nothing on the server: get, then create or update, so every script can be run twice."
      fr: "pynetbox dans un environnement virtuel sur ta machine, rien sur le serveur : chercher, puis créer ou modifier, pour que chaque script puisse tourner deux fois."
    deep:
      sub: "~/.venvs/netbox · pynetbox.api(\"https://${NETBOX_HOST}\", token=…) · get → create / update"
  - id: operator-curl
    kind: client
    label: { en: "Your shell", fr: "Ton shell" }
    sub: "curl · JSON"
    when: { is: CLIENT, equals: curl }
    desc:
      en: "curl with the token in a header and JSON bodies: the raw HTTP that every client, pynetbox included, sends."
      fr: "curl avec le jeton dans un en-tête et des corps JSON : le HTTP brut que tout client, pynetbox compris, envoie."
    deep:
      sub: "curl -H 'Authorization: Token …' -H 'Content-Type: application/json' · ?limit= · ?brief=1"
  - id: token
    kind: file
    label: { en: "Automation token", fr: "Jeton d'automatisation" }
    sub: "${AUTOMATION_USER}"
    in: server
    level: guided
    desc:
      en: "A token of ${AUTOMATION_USER}, write-enabled, with an expiry date. Its permissions are what the script may do: devices of ${SITE_NAME} only."
      fr: "Un jeton de ${AUTOMATION_USER}, en écriture, avec une date d'expiration. Ses permissions sont ce que le script peut faire : les équipements de ${SITE_NAME} seulement."
    deep:
      sub: "users.token · user ${AUTOMATION_USER} · expires · constraints site__name = ${SITE_NAME}"
  - id: api
    kind: net
    label: { en: "REST API", fr: "API REST" }
    sub: "https://${NETBOX_HOST}/api"
    in: server
    desc:
      en: "One endpoint per model. A GET lists or filters, a POST creates, a PATCH changes; the answer is always the object as JSON."
      fr: "Un endpoint par modèle. Un GET liste ou filtre, un POST crée, un PATCH modifie ; la réponse est toujours l'objet en JSON."
    deep:
      sub: "https://${NETBOX_HOST}/api · DRF · /dcim/devices/ · /ipam/ip-addresses/ · /extras/event-rules/ · /graphql/"
  - id: netbox
    kind: server
    label: { en: "NetBox", fr: "NetBox" }
    sub: "permissions · validation"
    in: server
    focus: true
    desc:
      en: "Checks the token, applies the permission constraints, validates the object, writes it, records the change, and queues an event if a rule matches."
      fr: "Vérifie le jeton, applique les contraintes de permission, valide l'objet, l'écrit, enregistre le changement, et met un événement en file si une règle correspond."
    deep:
      sub: "gunicorn :8001 · token → user → ObjectPermission · serializer · changelog · event rules"
  - id: postgres
    kind: store
    label: { en: "PostgreSQL", fr: "PostgreSQL" }
    sub: "objects · change log"
    in: server
    desc:
      en: "Every object written by the script lands here, with a change record signed ${AUTOMATION_USER}."
      fr: "Chaque objet écrit par le script atterrit ici, avec un enregistrement de changement signé ${AUTOMATION_USER}."
    deep:
      sub: "db netbox · dcim_device · ipam_ipaddress · core_objectchange (user = ${AUTOMATION_USER})"
  - id: redis
    kind: store
    label: { en: "Redis queue", fr: "File Redis" }
    sub: "events · jobs"
    in: server
    level: guided
    when: { flag: EVENTS }
    desc:
      en: "Events and script jobs wait here for a worker. If nothing consumes them, they pile up and nothing is delivered."
      fr: "Événements et tâches de scripts attendent ici un worker. Si rien ne les consomme, ils s'empilent et rien n'est livré."
    deep:
      sub: "redis db 0 · rq queues default / high / low"
  - id: rq
    kind: service
    label: { en: "RQ worker", fr: "Worker RQ" }
    sub: "netbox-rq"
    in: server
    when: { flag: EVENTS }
    desc:
      en: "The netbox-rq unit. Delivers webhooks and runs custom scripts; rq-workers-running in /api/status/ says whether it is alive."
      fr: "L'unité netbox-rq. Livre les webhooks et exécute les scripts personnalisés ; rq-workers-running dans /api/status/ dit s'il est vivant."
    deep:
      sub: "netbox-rq.service · manage.py rqworker · webhooks · scripts · retries: none"
  - id: scripts
    kind: file
    label: { en: "Custom scripts", fr: "Scripts personnalisés" }
    sub: "Customization → Scripts"
    in: server
    level: deep
    desc:
      en: "Python classes uploaded in the UI or synced from a data source. Run from a form or from POST /api/extras/scripts/ID/, as a job on the worker."
      fr: "Des classes Python téléversées dans l'interface ou synchronisées depuis une source de données. Lancées depuis un formulaire ou par POST /api/extras/scripts/ID/, comme tâche sur le worker."
    deep:
      sub: "${INSTALL_DIR}/netbox/scripts/ · extras.scripts.Script · ObjectVar · run(data, commit)"
  - id: receiver
    kind: client
    label: { en: "Webhook receiver", fr: "Récepteur de webhooks" }
    sub: "${WEBHOOK_URL}"
    when: { flag: EVENTS }
    desc:
      en: "Ten lines of Flask on your machine that print the payload. In real life: your CI, your monitoring, your configuration generator."
      fr: "Dix lignes de Flask sur ta machine qui affichent la charge utile. Dans la vraie vie : ta CI, ta supervision, ton générateur de configuration."
    deep:
      sub: "${WEBHOOK_URL} · POST JSON · event · model · data · snapshots · X-Hook-Signature"

edges:
  - from: operator
    to: api
    label: "HTTPS · token"
    when: { is: CLIENT, equals: pynetbox }
    deep: { label: "L7 HTTPS · Authorization: Token · GET / POST / PATCH" }
    desc: { en: "Every call carries the automation token; the answer is JSON that pynetbox turns into objects.", fr: "Chaque appel porte le jeton d'automatisation ; la réponse est du JSON que pynetbox transforme en objets." }
  - from: operator-curl
    to: api
    label: "HTTPS · token"
    when: { is: CLIENT, equals: curl }
    deep: { label: "L7 HTTPS · Authorization: Token · GET / POST" }
    desc: { en: "Every call carries the automation token; the answer is raw JSON, paginated in count / next / results.", fr: "Chaque appel porte le jeton d'automatisation ; la réponse est du JSON brut, paginé en count / next / results." }
  - from: api
    to: netbox
    deep: { label: "serializer · permissions" }
    desc: { en: "The API layer validates the JSON and hands a checked object to the application.", fr: "La couche API valide le JSON et passe un objet vérifié à l'application." }
  - from: token
    to: netbox
    label: "authorises"
    dashed: true
    level: guided
    deep: { label: "user → permissions → constraints" }
    desc: { en: "The token resolves to ${AUTOMATION_USER}, whose permissions decide the call: a device outside ${SITE_NAME} is refused with a 403.", fr: "Le jeton mène à ${AUTOMATION_USER}, dont les permissions décident de l'appel : un équipement hors de ${SITE_NAME} est refusé avec un 403." }
  - from: netbox
    to: postgres
    label: "writes"
    dashed: true
    deep: { label: "INSERT / UPDATE · change record" }
    desc: { en: "The object and its change record, in one transaction: a constraint failure rolls both back.", fr: "L'objet et son enregistrement de changement, dans une transaction : un échec de contrainte annule les deux." }
  - from: netbox
    to: redis
    label: "event"
    level: guided
    when: { flag: EVENTS }
    deep: { label: "enqueue · object_created / object_updated" }
    desc: { en: "When an event rule matches, NetBox queues the delivery instead of calling the receiver itself.", fr: "Quand une règle d'événement correspond, NetBox met la livraison en file au lieu d'appeler le récepteur lui-même." }
  - from: netbox
    to: rq
    label: "event"
    max: quick
    when: { flag: EVENTS }
    desc: { en: "A device created or updated becomes a job for the worker.", fr: "Un équipement créé ou modifié devient une tâche pour le worker." }
  - from: redis
    to: rq
    label: "dequeue"
    level: guided
    when: { flag: EVENTS }
    desc: { en: "The worker takes jobs as they come; several workers can share the queue.", fr: "Le worker prend les tâches au fil de l'eau ; plusieurs workers peuvent se partager la file." }
  - from: rq
    to: receiver
    label: "POST ${WEBHOOK_URL}"
    when: { flag: EVENTS }
    deep: { label: "POST JSON · X-Hook-Signature · expects 2xx" }
    desc: { en: "The worker on the server calls your receiver: the server must reach your machine, not the other way round.", fr: "Le worker du serveur appelle ton récepteur : c'est le serveur qui doit joindre ta machine, pas l'inverse." }
  - from: scripts
    to: rq
    label: "job"
    dashed: true
    level: deep
    when: { flag: EVENTS }
    desc: { en: "A custom script launched from the UI or the API runs on the worker as a job, under the launching user's permissions.", fr: "Un script personnalisé lancé depuis l'interface ou l'API tourne sur le worker comme tâche, avec les permissions de l'utilisateur qui l'a lancé." }
  - from: scripts
    to: netbox
    label: "job"
    dashed: true
    level: deep
    when: { notFlag: EVENTS }
    desc: { en: "A custom script launched from the UI or the API runs as a background job on the netbox-rq worker, under the launching user's permissions.", fr: "Un script personnalisé lancé depuis l'interface ou l'API tourne comme tâche de fond sur le worker netbox-rq, avec les permissions de l'utilisateur qui l'a lancé." }
````

---

# Writing a page for Runfold

A self-contained brief. Hand it to a person or a model with a subject ("set up a VPS to host a Next.js site behind nginx with TLS") and you get back the files a page is made of. Nothing else is needed to write; the site's test suite then checks the result.

## 1. What the reader gets, and why it shapes the writing

A page is one tutorial. The reader fills in **their context once** — hostnames, ports, paths, and a few **choices** of stack (Debian or RHEL, nginx or Apache, TLS from Let's Encrypt or self-signed) — and every command on the page is rewritten with their values. Sections that do not apply to their choices disappear.

The reader also picks a **reading level**, once for the whole site:

| Level | EN / FR | What it shows |
|---|---|---|
| Run | Run / Automatique | Only the `<Run>` block: a script that does the whole page. "Too lazy to read? Run it." |
| Quick | Quick / Express | The plain paragraphs and the commands. "It worked? Fine." |
| Guided | Guided / Détaillé | Quick + the `<Guided>` explanations: why, and how, for someone who has never done it. |
| Deep | Deep / Exhaustif | Everything, plus `<Deep>`: mechanism, alternatives, gotchas, what happens under the hood. |

So a page is **written once, in layers**, not four times. Every paragraph you write belongs to a layer. Plain text is Quick; wrap the rest.

Each page also carries a **gist**: a small diagram at the top (boxes, containers, arrows, no coordinates) that follows the reader's choices and level. And each page exists in **English and French**, each with its own file, same structure.

## 2. The files

```
content/<series>/series.yaml                 what all pages of the series share (title, order, shared variables/choices)
content/<series>/<page>/tuto.yaml            this page's contract: metadata, its own variables/choices
content/<series>/<page>/page-en.mdx          the prose, English
content/<series>/<page>/page-fr.mdx          the prose, French (same heading skeleton)
content/<series>/<page>/diagram.yaml         the gist (optional but expected)
```

Slugs are kebab-case (`secure-ssh-on-a-fresh-server`). Variable and choice keys are `UPPER_SNAKE_CASE`.

A page **inherits** the series' groups, variables and choices and may add its own; it must **never redeclare** a key the series declares. Two different pages may declare the same key (the reader's value is shared across the series).

Deliver every file in full, each in its own fenced block with the path as title.

## 3. `series.yaml` (only when creating a series)

```yaml
title:   { en: NetBox, fr: NetBox }
summary:
  en: >-
    One or two sentences: what the series takes the reader from and to.
  fr: >-
    Une ou deux phrases : d'où part la série et où elle mène.
order: [install-from-scratch, configure-for-your-team]   # page slugs, reading order

groups:                        # sections of the "Your values" panel
  - id: host
    label: { en: Server, fr: Serveur }
    desc:  { en: Where it runs and how it is reached., fr: Où ça tourne et comment on l'atteint. }
  - id: auth
    label: { en: Directory, fr: Annuaire }
    when:  { flag: LDAP }      # the whole group disappears when the choice is off

vars:                          # same shape as in tuto.yaml, see below
  - key: NETBOX_HOST
    kind: hostname
    group: host
    default: netbox.example.com
    label:  { en: NetBox hostname, fr: Nom d'hôte de NetBox }
    hint:   { en: …, fr: … }
    impact: { en: …, fr: … }

choices:
  - key: OS
    type: select
    label: { en: Distribution, fr: Distribution }
    default: ubuntu
    options:
      - { value: ubuntu, label: { en: Ubuntu 24.04, fr: Ubuntu 24.04 } }
      - { value: rhel,   label: { en: RHEL 9 / Rocky / Alma, fr: RHEL 9 / Rocky / Alma } }
  - key: LDAP
    type: boolean
    label: { en: Authenticate against LDAP, fr: Authentifier via LDAP }
    default: false
```

## 4. `tuto.yaml`

```yaml
# Inherits from ../series.yaml: <list the inherited keys here as a comment>.
title:   { en: Secure SSH on a fresh server, fr: Sécuriser l'accès SSH d'un serveur neuf }
summary:
  en: >-
    Two or three lines. What the reader has at the end. Concrete.
  fr: >-
    Deux ou trois lignes. Ce que le lecteur a à la fin. Concret.
difficulty: beginner           # beginner | intermediate | advanced
tags: [ssh, linux, security]   # lowercase, used by search filters
authors: [thudal]
created: 2026-09-26            # YYYY-MM-DD
minutes: 10                    # hands-on time
validated: Debian 12 · Rocky 9 # upstream version / platform it was written for

groups:
  - id: access
    label: { en: Access, fr: Accès }

vars:
  - key: SSH_PORT
    kind: port                 # text | ip | cidr | port | hostname | domain | user | email | secret | path | url | sshkey
    group: access
    default: "1234"            # always a string; "" for none (secrets)
    label: { en: SSH port, fr: Port SSH }
    hint:                      # what it is, one or two sentences — shown in the ? tip
      en: The port SSH listens on after hardening. Anything but 22 removes most automated noise.
      fr: Le port sur lequel SSH écoutera après durcissement. Tout sauf 22 supprime l'essentiel du bruit automatisé.
    impact:                    # consequences of getting it wrong, where it is reused — also in the tip
      en: Reused by the firewall and fail2ban. Open it in the firewall before restarting SSH, or you lock yourself out.
      fr: Réutilisé par le pare-feu et fail2ban. Ouvre-le dans le pare-feu avant de redémarrer SSH, sinon tu te verrouilles dehors.
    when: { flag: FAIL2BAN }   # optional: only relevant when a choice holds

choices:
  - key: FAIL2BAN
    type: boolean
    label: { en: Ban brute-force attempts (fail2ban), fr: Bannir le brute force (fail2ban) }
    hint:  { en: Recommended on any server reachable from the internet., fr: Recommandé sur tout serveur joignable depuis internet. }
    default: true
```

Rules:
- `kind: secret` for passwords and tokens: masked in the panel, never printed in the print view, never carried by share links. `default: ""`.
- Every value a reader could reasonably change is a variable. Hard-code only true constants (a well-known port of a protocol, a package name).
- `hint` and `impact` are the **whole explanation** of a variable; the page prose should not repeat them.
- Anything the reader **must** provide (their public key, their token) is a variable with `required: true` and no example default (marked in the panel). A block that uses an empty or invalid value is marked red and cannot be copied. Never write an example that looks like code in a block (`ssh-ed25519 AAAA… user@host`, `YOUR.IP`, `<user>`): readers paste it as is (the test refuses these).
- `kind: sshkey` checks a whole OpenSSH public key line (`ssh-ed25519 AAAAC3…`).
- `${EDITOR}` is reserved: the app fills it with the reader's editor (vim by default, nano in the panel). Write `sudo ${EDITOR} /etc/x` whenever the reader edits a file by hand. Never declare it.
- Conditions (`when`, and `<When>` in prose): `{ flag: KEY }`, `{ notFlag: KEY }`, `{ is: KEY, equals: value }`, `{ is: KEY, oneOf: [a, b] }`. They may only reference choices.

## 5. `page-en.mdx` / `page-fr.mdx`

### Skeleton

```mdx
{/* First pass — to be validated against <official docs URL> before publishing. */}

One paragraph (Quick): what this page does, in what order, for whom.

<Run>

One sentence saying what the script does, on which machine, as which user, and what must be in place first.

```bash
#!/usr/bin/env bash
set -euo pipefail
# <Title> — ${MAIN_VAR}
… the whole page as a script, using ${VARS} …
```

</Run>

## Before you start

<Guided>What you need on hand: accounts, access, prerequisites, and where the commands run.</Guided>

<Check cmd="…" expect="…" />

## First step

Plain sentence saying what to do.

```bash
command with ${VARS}
```

<Guided>Why this step, what the command does, what to look at.</Guided>

<Deep>The mechanism, the alternative, the gotcha, the thing that bites in production.</Deep>

<Check cmd="…" expect="…" />

## Second step
…

## Done

What the reader now has. What the next page of the series does.
```

The **French file has the same headings, in the same order, the same count** (the test checks it). It is written in natural French with "tu" (tutoiement), not a word-for-word translation.

### The syntax

| Syntax | Meaning |
|---|---|
| `${SSH_PORT}` inside a code fence | replaced by the reader's value; the copy button copies the filled command |
| `<V name="SSH_PORT" />` | the same, inline in prose |
| plain markdown | visible from **Quick** |
| `<Guided>…</Guided>` | visible from **Guided** |
| `<Deep>…</Deep>` | visible at **Deep** only |
| `<Note>…</Note>` | an aside, from Guided |
| `<Warn>…</Warn>` | always visible: anything that can lock you out, lose data or cost money |
| `<When is="OS" equals="debian">…</When>` | conditional on a select choice; also `oneOf="a,b"` |
| `<When flag="FAIL2BAN">…</When>` / `<When notFlag="…">` | conditional on a boolean choice |
| `<Run>…</Run>` | the only thing shown at Run: a script or a single file that does the whole page |
| `<Check cmd="…" expect="…" />` | a verification with a checkbox, tracked per page. Also `id="…"` to name it. |
| `<Details summary="If it fails">…</Details>` | collapsible, closed by default |
| `<Tabs group="x"><Tab label="/etc/a.conf">…</Tab><Tab label="/etc/b.conf">…</Tab></Tabs>` | several files of one feature, side by side |
| ` ```ini title="/etc/x.conf" {1,3-5} ` | caption, highlighted lines |
| ` ```bash on="mac" ` · ` ```bash on="server" as="debian" ` · `as="${USERNAME}"` | where the block is typed and as whom: a coloured badge and edge, one colour per terminal |
| ` ```caddy file="/etc/caddy/Caddyfile" on="server" as="${USERNAME}" ` | **the whole content** of a file: shown with its path, copied as a ready `sudo tee … <<'EOF'` command (or as content only, for an editor). `sudo` is added outside /home and /tmp; force with `sudo` / `sudo="false"` |
| ` ```bash interactive ` | the command asks something (password, confirmation): badge, and it must be alone in its block |
| `$${X}` | a literal `${X}` (GitHub Actions `$${{ … }}`, JS template literals) |
| `<Annotated>` + `# (1)` markers at line ends + a numbered list after the fence | annotations: stripped at Quick, badges at Guided, inline at Deep |
| `## Heading` | a step: numbered automatically, listed in the outline. `###` for sub-steps. |

Details that matter:
- A `<Check>` is a real command whose expected output is **stable**: a version line, `active (running)`, an HTTP `200`. Two to six per page. `expect` may contain `${VARS}` but never a secret. If the command mixes `'` and `"`, write `cmd={"…"}`.
- Fences: ` ```bash ` for shell (a `$` prompt is drawn in front of what the reader types, not in front of comments or continuation lines), ` ```yaml `, ` ```python `, ` ```ini `, ` ```nginx `. One fence per command group; keep them short enough to read.
- Inside `<Run>`, the script must work with the variables and the choices: branch it with `<When>` blocks around separate fences when the stack differs. It runs unattended, so `set -euo pipefail`, no prompts, no `sudo` password expectations that the intro does not state.
- Do not put MDX components inside a fence, and do not put `\"` inside a component attribute (use `cmd={"…"}`).
- Blank line before and after every component, and around fences inside components.
- A component whose content spans several paragraphs or holds a list must be written as a block: the opening tag alone on its line, a blank line, the content, a blank line, the closing tag alone on its line. `<Guided>One line.</Guided>` is fine; `<Guided>Intro:\n\n1. item</Guided>` does not compile.
- New pages carry `status: draft` in `tuto.yaml` until their author has run them end to end.

Rules that come from a real run (the test suite checks the first four):
- **Where and as whom.** When a page uses more than one terminal (the laptop, the server as `debian`, the server as yourself), every block says so with `on=` / `as=`. A tired reader follows the badges, not the sentences.
- **One file, one block, complete.** A file is shown once, whole, with `file="…"`. Never "now add this line before `log {`". When a choice changes the file, `<When>` picks between complete versions of it. The Run script writes exactly the file the page shows.
- **Interactive commands alone.** `adduser`, `passwd`, `ssh-keygen` without `-N`, `ssh-copy-id`: alone in their block, marked `interactive`, or the rest of a paste lands in the password prompt.
- **No fake examples** in blocks (see `required` above).
- **A check proves the right thing.** A `<Check>` that tests an SSH login forces the method under test: `ssh -i KEY -o IdentitiesOnly=yes -o PasswordAuthentication=no -o BatchMode=yes …`. A check that passes thanks to a password before the step that forbids passwords is how people lock themselves out.
- **No unexplained jargon** in the Quick text: "the bare domain, `example.com`, nothing in front" rather than "the apex".

### Voice

Direct, concrete, an experienced engineer talking to a colleague. Short sentences. No marketing, no "simply", no "just". Say what a command does before showing it when it is not obvious. Say when a step is dangerous before the reader runs it (`<Warn>`). Name the file being edited. When a fact is from memory and must be confirmed, keep the common well-known form and add a `<Note>` saying where to confirm it.

Length: 200–400 lines per language file. Accuracy over volume.

## 6. `diagram.yaml` — the gist

```yaml
title:   { en: "One server, four pieces", fr: "Un serveur, quatre briques" }
caption:
  en: "One or two sentences read under the drawing."
  fr: "Une ou deux phrases lues sous le schéma."

groups:                                       # dashed containers
  - id: server
    label: { en: "Your server", fr: "Ton serveur" }
    desc:  { en: "…", fr: "…" }               # shown on hover

nodes:
  - id: users                                 # kebab-case
    kind: user                                # user | client | server | service | store | file | net | cloud (the icon)
    label: { en: "Your team", fr: "Ton équipe" }
    sub: "https://${NETBOX_HOST}"             # second line, may use ${VARS}
    desc:  { en: "What this piece does.", fr: "Ce que fait cette brique." }
  - id: nginx
    kind: net
    label: { en: "nginx", fr: "nginx" }
    sub: "443 → 8001"
    in: server                                # container
    when: { is: WEB, equals: nginx }          # follows the reader's choices
    desc:  { en: "…", fr: "…" }
    guided: { sub: ":443 → 127.0.0.1:8001" }  # overrides from Guided up
    deep:   { sub: "TLS ends here · :443 → 127.0.0.1:8001 · HTTP/1.1" }
  - id: firewall
    kind: net
    label: { en: "Firewall", fr: "Pare-feu" }
    in: server
    level: guided                             # appears from Guided up (level: deep for Deep only)
    desc:  { en: "…", fr: "…" }
  - id: netbox
    kind: server
    label: { en: "NetBox", fr: "NetBox" }
    in: server
    focus: true                               # the thing the page is about, drawn in the accent
    desc:  { en: "…", fr: "…" }

edges:
  - { from: users, to: nginx, label: "HTTPS", max: quick, desc: { en: "…", fr: "…" } }          # a simplification replaced from Guided
  - { from: users, to: firewall, label: "HTTPS · TCP 443", level: guided, deep: { label: "L7 HTTPS · L4 TCP 443 · L3 IP" }, desc: { en: "…", fr: "…" } }
  - { from: firewall, to: nginx, level: guided, desc: { en: "…", fr: "…" } }
  - { from: nginx, to: netbox, desc: { en: "…", fr: "…" } }
  - { from: netbox, to: ldap, label: "bind", dashed: true, when: { flag: LDAP }, desc: { en: "…", fr: "…" } }   # dashed = secondary relation
```

Rules:
- Solid arrows lay the boxes out left to right; dashed arrows are secondary and do not move anything. No coordinates ever.
- Quick shows 4–7 boxes; Guided adds the pieces a first-timer should know exist; Deep shows every layer (DNS, TLS, ports, sockets, units, log files).
- `desc` on every node, edge and group, both languages; it is what the reader gets on hover.
- Only reference variables and choices declared for the page (series + page).

## 7. Checklist before delivering

- [ ] Every user-facing string exists in `en` and `fr`.
- [ ] Every `${VAR}` and `<V name>` in the prose is declared (series or page); every `<When>` references a declared choice.
- [ ] No key redeclared that the series already declares.
- [ ] Same `##`/`###` headings, same order, same count, in both language files.
- [ ] A `<Run>` block that does the whole page with the variables.
- [ ] 2–6 `<Check>`s with stable expected output; no secret in `expect`.
- [ ] `<Warn>` before anything that locks out, deletes or costs.
- [ ] The first-pass comment at the top of each `.mdx`, naming the docs to validate against.
- [ ] `diagram.yaml` with `desc` everywhere, `focus` on one node, levels used.
- [ ] Every file delivered once, in full, as a `file="…"` block; the Run script writes the same content.
- [ ] `on=` / `as=` on every block when the page uses more than one terminal; `interactive` commands alone.
- [ ] What the reader must provide is a `required` variable, never an example in a block.

## 8. A worked request

> Write the page `host-a-nextjs-site` for a new series `vps` (title "A VPS from scratch"): from a freshly delivered Debian 12 VPS to a Next.js site served by nginx with a Let's Encrypt certificate, the app run by systemd, deployed by `git pull` + `npm run build`. Series variables: SERVER_IP (ip), USERNAME (user, the deploy user), DOMAIN (domain). Page variables: APP_DIR (path, default /srv/site), REPO_URL (url), NODE_MAJOR (text, default "22"), APP_PORT (port, default "3000"). Choices: TLS (select letsencrypt | selfsigned, default letsencrypt), PM (select systemd | pm2, default systemd). Deliver series.yaml, tuto.yaml, page-en.mdx, page-fr.mdx, diagram.yaml.

The answer is five fenced blocks, complete, following sections 3–6 and passing the checklist in 7.
