FMG API From Scratch Part 3: Your First API Call, and Why "OK" Isn't Enough
Part 2 was all concepts. This part makes the first real calls: log in, read something, log out. Then we turn those calls into a small Python client that every later part builds on, and that is deliberately suspicious of good news.
The code for this part is in the fmg-api-from-scratch repo at tag part-03.
Anatomy of a request
Every FortiManager API call is an HTTP POST to one URL, /jsonrpc, with a JSON body. The operation, the object path and the data all live in the body, unlike the FortiOS REST API where the URL and HTTP method carry the meaning.
{
"id": 1,
"method": "get",
"params": [
{ "url": "/sys/status" }
],
"session": "<session-token>"
}
| Field | What it is |
|---|---|
id | A number you choose. FortiManager echoes it back so you can match responses to requests |
method | What to do: the verb (table below) |
params | A list holding one object per request. Almost always one |
params[].url | The object path, such as /sys/status or /dvmdb/adom/<adom>/device. This is where the database from Part 2 is chosen |
params[].data | The payload for writes and actions |
session | The token you got at login. Absent on the login request itself |
The verbs I use in this series:
| Method | Used for |
|---|---|
get | Read |
add | Create |
set | Create or overwrite (see the test below, it does not behave the way the word suggests) |
update | Change specific fields |
delete | Remove |
exec | Run an action: log in, log out, execute a script, install |
Step 1: log in
Session authentication is an exec to /sys/login/user:
{
"id": 1,
"method": "exec",
"params": [
{
"url": "/sys/login/user",
"data": { "user": "<api-user>", "passwd": "<password>" }
}
]
}
The account needs JSON API Access enabled and read privileges as a minimum. Part 4 builds a proper least-privilege account for this. Fortinet’s API Best Practices guide describes the two authentication options: a regular administrator with JSON API access (log in, get a session, send it with each request), or a REST API admin with a permanent API key that saves the login and logout steps. I’m using session login throughout because that’s where the worked examples cluster, and swapping to a key later is a small change to one method.
The response:
{
"cid": 38,
"id": 1,
"result": [
{
"status": { "code": 0, "message": "OK" },
"url": "/sys/login/user"
}
],
"session": "<REDACTED>"
}
The session token is at the top level, not inside result. You send it back as "session" in every later request. Treat it like a password: anyone holding it is you until it’s logged out or expires.
cid is a counter FortiManager adds. I haven’t found it documented, so I don’t rely on it.
If you try these calls by hand with curl, don’t put the password on the command line. It ends up in your shell history and in the process list. Feed the JSON in with -d @- from a here-document, or from a file with tight permissions.
Step 2: read something
Here is a get against the Device Database from Part 2: the copy of one device’s DNS configuration that FortiManager holds.
{
"id": 2,
"method": "get",
"params": [{ "url": "/pm/config/device/<device>/global/system/dns" }],
"session": "<session-token>"
}
And the real response from my lab (device name replaced with a placeholder):
{
"cid": 39,
"id": 2,
"result": [
{
"data": {
"alt-primary": "0.0.0.0",
"alt-secondary": "0.0.0.0",
"cache-notfound-responses": 0,
"dns-cache-limit": 5000,
"dns-cache-ttl": 1800,
"domain": ["lab.local"],
"interface": [],
"interface-select-method": 0,
"oid": 153,
"primary": "192.0.2.53",
"protocol": 1,
"retry": 2,
"secondary": "8.8.8.8",
"server-select-method": 1,
"source-ip": "0.0.0.0",
"ssl-certificate": ["Fortinet_Factory"],
"timeout": 5,
"vrf-select": 0
},
"status": { "code": 0, "message": "OK" },
"url": "/pm/config/device/<device>/global/system/dns"
}
]
}
(I’ve trimmed a few unremarkable fields, and the primary resolver is a documentation address.) Three things to notice:
- It isn’t the CLI.
protocol,interface-select-method,server-select-methodandvrf-selectcome back as integers, not the words you’d see inshow system dns. The next section shows how to get the words. - Lists are lists.
domainandssl-certificateare arrays even when they hold one value. oidis FortiManager’s numeric ID for the object. Part 8 needs numeric IDs for install.
Getting names instead of numbers
Add "verbose": 1 to the request and FortiManager returns the enumerations as CLI names. Where you put it matters:
{
"id": 3,
"method": "get",
"params": [{ "url": "/pm/config/device/<device>/global/system/dns" }],
"session": "<session-token>",
"verbose": 1
}
It sits at the top level of the body, next to method and params, not inside params[0]. I tested both on FortiManager 7.6.6. Inside params[0] it is silently ignored: no error, and you get integers exactly as before. At the top level:
| Field | Without verbose | With top-level verbose |
|---|---|---|
protocol | 1 | "cleartext" |
server-select-method | 1 | "least-rtt" |
interface-select-method | 0 | "auto" |
cache-notfound-responses | 0 | "disable" |
log | 0 | "disable" |
vrf-select | 0 | 0 |
The last row is the catch. vrf-select stayed an integer, so the mapping isn’t complete for every field. Use verbose to read and understand a value, but when a number matters, check it against the CLI or the GUI’s Preview button from Part 2 instead of assuming.
A useful side effect of the /sys/status call is that it splits the version into parts:
{
"Branch Point": "3654",
"Build": "3654",
"Major": 7,
"Minor": 6,
"Patch": 6,
"Platform Full Name": "FortiManager-VM64-KVM",
"Version": "v7.6.6-build3654 260127 (GA.M)"
}
(trimmed, and with the serial number and hostname removed). Separate Major, Minor and Patch fields mean a script can refuse to run against a version it hasn’t been tested on, without parsing a string.
Step 3: log out
{
"id": 4,
"method": "exec",
"params": [{ "url": "/sys/logout" }],
"session": "<session-token>"
}
Do it every time. Sessions left open use resources on the FortiManager and, on an account that’s meant to be locked down, are one more token that could be stolen. The Python client below logs out automatically.
What failure looks like
Here is where it stops being a tutorial and starts being useful. Send FortiManager a wrong password and the login call returns HTTP 200:
{
"cid": 34,
"id": 1,
"result": [
{
"status": { "code": -22, "message": "Login fail" },
"url": "/sys/login/user"
}
]
}
The HTTP layer was perfectly happy. The failure is inside the body, in status.code. Anything that only checks response.raise_for_status() sails straight past it.
A script that ignores that and carries on sends its next requests with no session, and each comes back with:
{
"status": { "code": -11, "message": "No permission for the resource" },
"url": "/sys/status"
}
That message is misleading. It isn’t a permissions problem. The call had no valid session, and FortiManager reports that as “no permission”. If you see -11 after a login that “worked”, check the session first, and check whether the login really worked at all.
So the rules, for any FortiManager client you write:
- Check the status code in the body on every call. HTTP success only means the request arrived.
- Stop at the first failed login. Never send anything with an empty session.
- Treat a status of 0 as necessary, not sufficient. Some reads come back with status 0 and no
datakey at all. That means either “nothing to return for these arguments” or “no such endpoint”, and the response alone can’t tell you which. Part 7 has a real example of each reading being wrong. - Never put credentials in error messages or logs.
How add, set and update really behave
Part 6 depends on knowing exactly what these three verbs do to an object that already exists, so I tested them rather than trusting the names. The test object is a throwaway script in the lab ADOM, at /pm/config/adom/<adom>/obj/fmg/script, created, changed and deleted inside a try/finally so nothing is left behind.
| Step | Result |
|---|---|
add a new object | Succeeds. The URL is the collection, and the response echoes the new name |
add the same name again | Fails with -2 Object already exists |
update on the object URL with only desc | Changes desc. content is untouched |
set on the object URL with only content | Changes content. desc, name and type are all kept |
set on a name that doesn’t exist | Succeeds and creates it |
get on a name that doesn’t exist | Fails with -3 Object does not exist |
The surprise is the fourth row. I expected set to replace the whole object, so that fields I didn’t send would be reset. On this object in 7.6.6 it behaved like update, with one difference: it also creates the object if it’s missing. Two cautions follow from that:
- This is one object type on one version. Don’t assume every table behaves this way. If you depend on a field being cleared or reset, write it explicitly, then
getthe object back and check. - Don’t use
setto remove a field. If you sent a smaller payload and hoped the rest would vanish, it may not.
Note also that an object that doesn’t exist gives you a real error (-3) when you ask for it by its exact URL. The “OK with no data” reply in rule 3 is a different, quieter kind of answer.
The client
Here’s the whole thing. It’s about 150 lines of code, and each rule above has a home in it.
"""fmg_client.py - a minimal FortiManager JSON-RPC client.
Rules this client enforces (each one is a lesson from the series):
* HTTP 200 proves nothing. Success or failure lives in the response body,
so every call checks the status code inside `result[0].status`.
* A status of 0 is necessary, not sufficient. `get()` also insists that a
`data` key is present, because "OK" with no data can mean "nothing to
return for these arguments" or "no such endpoint", and only a second try
with known-good arguments can tell the two apart.
* A failed login stops everything. Nothing is ever sent with an empty session.
* Secrets never appear in exceptions or in repr().
* TLS verification is on by default. Turning it off is explicit and warns.
"""
from __future__ import annotations
import warnings
from typing import Any
import requests
class FMGError(RuntimeError):
"""FortiManager answered, but the body reported a failure (or we refused to send)."""
def __init__(self, code: int | None, message: str, url: str | None = None):
self.code = code
self.message = message
self.url = url
detail = f"FMG error {code}: {message}" if code is not None else f"FMG client error: {message}"
if url:
detail += f" ({url})"
super().__init__(detail)
class FMGShapeError(FMGError):
"""The status said OK but the response is not shaped the way it should be."""
def __init__(self, message: str, url: str | None = None):
super().__init__(None, message, url)
class FMGClient:
def __init__(self, host: str, *, verify: bool | str = True,
timeout: float = 30, http: Any = None):
self.url = f"https://{host}/jsonrpc"
self.timeout = timeout
if http is None:
http = requests.Session()
http.verify = verify
if verify is False:
warnings.warn(
"TLS verification is disabled. Fine for a throwaway lab, "
"not for anything else.",
UserWarning,
stacklevel=2,
)
self._http = http
self._session: str | None = None
self._id = 0
def __repr__(self) -> str:
return f"FMGClient(url={self.url!r}, logged_in={self._session is not None})"
def __enter__(self) -> "FMGClient":
return self
def __exit__(self, exc_type, exc, tb) -> None:
# Always try to log out, but never let a logout failure hide the
# exception that got us here.
try:
self.logout()
except (FMGError, requests.RequestException):
if exc_type is None:
raise
def _request(self, method: str, url: str, data: Any = None, *,
top: dict | None = None, **extra: Any) -> dict:
"""POST one JSON-RPC request; return the whole payload once its status is OK.
`extra` goes inside params[0]; `top` goes at the top level of the body.
"""
self._id += 1
params: dict[str, Any] = {"url": url}
if data is not None:
params["data"] = data
params.update(extra)
body: dict[str, Any] = {"id": self._id, "method": method, "params": [params]}
if self._session:
body["session"] = self._session
if top:
body.update(top)
resp = self._http.post(self.url, json=body, timeout=self.timeout)
resp.raise_for_status() # HTTP-level problems only
payload = resp.json()
results = payload.get("result")
if not results:
raise FMGShapeError("response has no 'result' list", url)
status = results[0].get("status", {})
code = status.get("code")
if code != 0: # the check that actually matters
raise FMGError(code, status.get("message", "(no message)"),
results[0].get("url", url))
return payload
def _call(self, method: str, url: str, data: Any = None, *,
top: dict | None = None, **extra: Any) -> dict:
return self._request(method, url, data, top=top, **extra)["result"][0]
def _require_login(self, url: str) -> None:
if not self._session:
raise FMGError(None, "not logged in - call login() first", url)
def login(self, user: str, password: str) -> None:
payload = self._request("exec", "/sys/login/user",
data={"user": user, "passwd": password})
session = payload.get("session")
if not session:
raise FMGShapeError("login reported success but returned no session token",
"/sys/login/user")
self._session = session
def logout(self) -> None:
if not self._session:
return
try:
self._call("exec", "/sys/logout")
finally:
self._session = None
def get(self, url: str, *, verbose: bool = False, **extra: Any) -> Any:
"""Read. Returns the `data` block; raises if it is missing.
verbose=True asks for enum values as CLI names ("cleartext") instead of
integers (1). It must sit at the top level of the request body.
"""
self._require_login(url)
result = self._call("get", url, top={"verbose": 1} if verbose else None, **extra)
if "data" not in result:
raise FMGShapeError(
"status was OK but the response has no 'data' key - nothing to "
"return for these arguments, or no such endpoint? Try known-good arguments", url)
return result["data"]
def execute(self, url: str, data: Any = None, **extra: Any) -> dict:
"""Run an action. Returns the whole first result (some actions have no data)."""
self._require_login(url)
return self._call("exec", url, data, **extra)
A few design choices worth explaining:
- One function builds and checks every request.
_requestis the only place that talks to the network, so there is exactly one place where the status check can be forgotten, and it isn’t. - Two places for extra fields, on purpose. Anything passed as a keyword goes inside
params[0]. Top-level fields such asverbosego through a separatetopargument, because as theverbosetest showed, putting a field in the wrong one fails silently. FMGErrorcarries the code, the message and the URL, and nothing else. The request body, which contains the password on login, is never included, so it can’t leak into a log or a traceback. There’s a test for that.FMGShapeErroris for “OK, but wrong”. It covers a missingresult, a login with no session, and agetwith nodata.- The
withblock always logs out, and if logout itself fails while another exception is already in flight, the original exception wins. Otherwise a tidy-up failure hides the real problem. - TLS verification is on by default.
verify=Falseworks, because lab FortiManagers usually have self-signed certificates, but it emits a warning every time.verify="/path/to/ca.pem"is the right fix, and Part 4 covers it.
Using it
The example script reads credentials from a .env file (copy .env.example, never commit the real one) and does the same three things as the raw calls:
with FMGClient(host, verify=tls_setting()) as fmg:
fmg.login(user, password)
status = fmg.get("/sys/status")
print(f"FortiManager {status['Version']}")
dns = fmg.get(f"/pm/config/device/{args.device}/global/system/dns")
print(json.dumps(dns, indent=2))
python examples/first_call.py --device <device-name>
The real output from my lab (trimmed, with the device name and resolver address replaced):
FortiManager v7.6.6-build3654 260127 (GA.M)
Device Database copy of system dns for <device>:
{
"domain": ["lab.local"],
"interface-select-method": 0,
"oid": 153,
"primary": "192.0.2.53",
"protocol": 1,
"secondary": "8.8.8.8",
"server-select-method": 1,
"ssl-certificate": ["Fortinet_Factory"],
"vrf-select": 0
}
To get names instead of numbers, pass verbose=True: fmg.get(url, verbose=True).
Testing it without a FortiManager
I don’t want a reader to need a FortiManager to check that the client’s error handling works, so the tests use a fake HTTP object that replays canned responses. The canned responses are shaped like the real ones above: the login failure, the -11 with no session, and a status-0 response with no data.
python -m unittest -v
There are 18 tests at this tag, covering:
- a failed login raising even though the HTTP status was 200
- the password never appearing in an exception or in
repr() get()before login sending nothing at all- a status of 0 with no
databeing rejected verboselanding at the top level of the body, never insideparams- logout happening even when your own code raises, without hiding your exception
- TLS verification being on by default and
verify=Falsewarning
One caution about tests that pass: my first run reported “Ran 0 tests, OK”, because Python’s test discovery needs an __init__.py in the tests folder. A green result that proves nothing is worse than a red one. So I also checked the tests could fail by deliberately breaking the client: ignoring the body’s status code, dropping the data check, allowing calls without a login, and moving verbose into params. Each one made the suite fail. If you change the client, try the same trick.
The mistake worth passing on
In my original script I checked the status code but never checked that the response had the shape I expected. A status of 0 with no data looked exactly like “the log isn’t there yet”, and I spent a while debugging the wrong thing. The client now refuses that shape, so it fails loudly instead of quietly returning nothing.
I’ll tell the full story in Part 7, including the part I got wrong: I concluded the endpoint didn’t exist, and it did. It simply had nothing to return for the device I was asking about. The response was ambiguous, and I read it the way that suited my theory. The general rule is to treat “OK with no data” as a question, and to answer it by asking the same endpoint something you know has an answer.
Rules of thumb
- The HTTP status tells you the request arrived.
result[0].status.codetells you whether it worked. -22is a failed login.-11after a login means check the session before the permissions.- A client should stop at the first failed login and never send an empty session.
- Read the
datayou expected, not just the status you hoped for. verbosegoes at the top level of the request body. In the wrong place it does nothing, and says nothing.- After a
setorupdate,getthe object back and check it. - Log out every time, and let a
withblock make sure of it.
What comes next
Part 4 builds the account this client logs in with: a dedicated administrator with an admin profile that can do exactly what the script needs and nothing else, scoped to one ADOM and one trusted host, with the credentials kept in .env. We’ll also test that the restrictions actually hold, by trying to do things the account shouldn’t be able to do.
Sources:
- FortiManager API Best Practices Guide: Getting started (7.6.0)
- Technical Tip: Using FortiManager/FortiAnalyzer API (Fortinet Community)
- Python
requestsdocumentation - Python
unittestdocumentation