FMG API From Scratch Part 1: Why FortiManager, and Not Straight to FortiOS
I have a Python script called fmg_push.py. It renders Jinja templates locally, pushes the result into FortiManager, stages it against a device, reads back what FortiManager thinks it staged, and installs it to the live FortiGate. It’s about a thousand lines long, it works, and it took a lot longer to get right than it should have, because for a while it failed in ways that looked exactly like success.
This series rebuilds it from the first API call. By the end you’ll have your own working version and you’ll know why every part of it is there, including the parts I got wrong first. The code lives in a public repo, one tagged commit per part from Part 3 onward, so you can check out the code as it stood at each step and compare it with yours.
This series is the foundation: a careful, tested tool that changes one managed device safely. It stops short of building a whole FortiGate site from nothing (registering a new device, templates, interfaces, SD-WAN, a policy package). That is a larger job, and it deserves its own follow-up series built on this tool.
This first part has no code at all. It answers the question I should have asked before I wrote a line: why go through FortiManager at all?
Who this is for
You should be comfortable with:
- Reading and writing FortiOS CLI, and knowing roughly what FortiManager is for. If ADOMs and device registration are new, the NSE 5 series covers them.
- Basic Python. If you’ve followed the Python for Network Engineers series, you have enough.
- JSON, and what an HTTP POST is.
By Part 9 you’ll have a small command-line tool that can authenticate to FortiManager, render a config from templates and variables, push it, stage it against one device, show you what will change, and install it, with a service account that can do that and nothing else.
The question first: why not talk to the FortiGate directly?
FortiOS has a perfectly good REST API. I’ve used it on this site already, for parsing policy and SD-WAN health with jq and in the Python series. For one device, it’s the right tool: a URL, an HTTP method, a token, done.
So why would anyone add a layer?
| Straight to FortiOS REST | Through FortiManager | |
|---|---|---|
| Scope | One device per connection | One endpoint, every device in the ADOM you’re allowed to touch |
| What a write does | Changes the running config on that device as the request lands | Changes FortiManager’s copy of the config. Nothing reaches the device until you ask for an install |
| Review before change | You build your own diff | FortiManager compares its new copy against the config it retrieves from the device and shows the difference (Install Preview) |
| History | Whatever you log yourself | Configuration revisions per device, with revert |
| Credentials | A token per device to create, store and rotate | One service account, scoped to an ADOM and a trusted host |
| Templates and variables | Yours to build | Provisioning templates, Jinja CLI templates and metadata variables, all native |
| Drift | You find out by polling and diffing | FortiManager tracks whether its copy and the device agree |
| If the credential leaks | You lose one device | You lose everything that account can reach |
That last row is worth pausing on. FortiManager doesn’t remove risk, it concentrates it. A stolen device token is a bad day. A stolen FortiManager admin credential with write access to a whole ADOM is a much worse one. That’s why Part 4 is entirely about the service account, and why I keep FortiManager’s API reachable only from a management network and never from the internet.
The line that matters
The real reason to go through FortiManager isn’t any single row of that table. It’s how a change actually reaches the device. From the FortiManager 7.6.6 Administration Guide (Key Concepts, page 57):
When FortiManager installs changes to managed devices, for example installing Policy Packages and CLI templates to a FortiGate, it follows a sequence where the configuration is first copied to the device’s Device Database on FortiManager before actual installation to the target device.
And on the next page:
The diff between the old and new configuration is installed to the target FortiGate, but not the original content.
Read those twice. FortiManager doesn’t fire your config at a FortiGate. It builds a complete new configuration file for that device inside its own database, retrieves the device’s current configuration, works out the difference, and installs only the difference.
That has a consequence that catches nearly everyone who automates FortiManager for the first time: writing to the API and changing a device are two different operations. You write into FortiManager’s databases with one set of calls, then ask FortiManager to install with another. Part 2 is about those databases, because nearly everything that went wrong in my own script came from misunderstanding which one I was writing to.
When straight to FortiOS is still the right answer
I’m not arguing you should route everything through FortiManager. Direct is right when:
- The device isn’t managed. My scratch FortiGate-VM for the Golden Template series deliberately isn’t registered to FortiManager, so local CLI is fine on it.
- You’re reading, not writing. Monitor endpoints (session tables, SD-WAN health, interface counters) are a device-level question and the device is the source of truth.
- You’re bootstrapping. Before a device is registered there’s nothing for FortiManager to manage.
- FortiManager is the thing that’s broken. I’ve done this once, and I’ll be honest about it: my lab FortiManager’s licence had lapsed and the GUI wouldn’t load, and I needed a route on the hub. I applied it on the console with content identical to what I’d already staged in FortiManager, and then had to reconcile the two afterwards. It worked. It was also a documented, deliberate exception to my own rule of no local changes on managed devices, and I’d rather not need it again.
The rule I’ve settled on: pick one control plane per device, and if you have to break it, write down why and reconcile afterwards. Two systems both believing they own a config is how drift starts.
Two APIs that look alike and aren’t
Both FortiOS and FortiManager speak JSON over HTTPS, so it’s tempting to assume they work the same way. They don’t.
FortiOS REST puts the meaning in the URL and the HTTP method. To read the DNS settings of one device:
GET https://<fortigate>/api/v2/cmdb/system/dns
Authorization: Bearer <api-token>
FortiManager’s JSON API is JSON-RPC. Everything, the operation, the object path and the data, goes in the body of a POST to a single endpoint, /jsonrpc. The equivalent read looks like this:
POST https://<fortimanager>/jsonrpc
Content-Type: application/json
{
"id": 1,
"method": "get",
"params": [{ "url": "/pm/config/device/<device>/global/system/dns" }],
"session": "<session-token>"
}
Two things to notice, both of which come back later in the series:
- That FortiManager call isn’t reading the FortiGate. It’s reading FortiManager’s copy of that device’s configuration, its Device Database. The two agree only if nothing has changed on either side since they last synchronised.
- The HTTP layer tells you very little. Success or failure is reported inside the response body, in a status code, and, as Part 3 shows, “success” turns out to mean less than you’d hope.
Here’s what comes back from my lab, trimmed to the interesting fields, with the device name replaced and the resolver swapped for a documentation address:
{
"result": [
{
"data": {
"domain": ["lab.local"],
"primary": "192.0.2.53",
"protocol": 1,
"secondary": "8.8.8.8"
},
"status": { "code": 0, "message": "OK" },
"url": "/pm/config/device/<device>/global/system/dns"
}
]
}
Notice that protocol is the integer 1, not a word from the CLI. Part 3 has the full response and shows how to get the words back.
Where the documentation is, and isn’t
This is the part that surprised me. FortiManager’s API is powerful and the documentation around it is spread across several places, so it helps to know where to look. I started out thinking it was barely documented. That was an overstatement: it is documented in pieces, by different people, and none of the pieces tells you what happens when a call goes wrong.
| Source | What it gives you | The catch |
|---|---|---|
| FortiManager API Best Practices Guide | Public. Explains the JSON API is JSON-RPC and covers authentication, asynchronous tasks, workspace locking and logging | Deliberately short. It’s guidance, not a reference, and it doesn’t cover scripts, install or script logs |
| Getting started, in the same guide | The two authentication options (a REST API admin with a permanent key, or a normal admin with JSON API access using session login) | Points you at the Fortinet Developer Network for the full reference |
| Fortinet Developer Network | The full API reference | You may need a Fortinet account to get in |
| How-To FortiManager API (community) | Worked examples: login, reading ADOMs and devices, creating and running CLI scripts, the scope shape, script log retrieval | A community resource, not Fortinet’s. It shows what to send, not what happens when a field is wrong |
The Ansible collection fortinet.fortimanager | One module per endpoint, including the install preview calls. A good index of URLs | Whether a call works on your version is for you to test |
| FortiManager Administration Guide | The GUI, thoroughly, and the install sequence quoted above | Thin to silent on many raw endpoints. Fortinet publishes a version per release, and I use the 7.6.6 one |
| The GUI’s own Preview button | The exact JSON API request behind a change made in the Device Database editor, with a copy button | Only covers changes you can make in the GUI. Covered in the 7.6.6 Administration Guide under “Preview the JSON request or CLI script for changes in the device database” |
| Fortinet Community technical tip on the FortiManager and FortiAnalyzer API | A short worked first call | One example, not a tour |
| Third-party libraries: pyfmg, pyFortimanagerAPI, and Network to Code’s fortimanager-ansible | Worked code to read and compare against | I haven’t tested any of them for this series |
| Your browser’s developer tools | Every call the FortiManager GUI makes, which is how I found some of the endpoints I couldn’t find anywhere else | Reverse-engineering, covered in Part 5 |
I’ll use plain requests throughout rather than one of those libraries, on purpose. When you’re learning a management API you want to see every call and every response, and a wrapper hides exactly what you need to look at. Once you understand what’s happening underneath, a library may well save you typing.
The Preview button deserves a special mention because it’s the most underused thing in the whole product. It’s documented, it’s in the GUI, and it shows you the JSON that FortiManager itself would send. Before you guess an endpoint, make the change once in the GUI and read the preview.
What we’re building
Nine parts, each ending with a piece of working code:
| Part | Topic | You end up with |
|---|---|---|
| 1 | Why FortiManager, and not straight to FortiOS | The design decision, and a map of the docs |
| 2 | ADOM database, Device database and the live device | The mental model that makes everything else make sense |
| 3 | Your first API call | A login, a read, and a small Python client class with tests |
| 4 | A safe service account | Least-privilege access, an audit of the account, a pinned certificate |
| 5 | Discovery | Surveying the box by shape, and finding endpoints the documentation doesn’t cover |
| 6 | Push | Scripts, variables and Jinja, and the trap I fell into |
| 7 | Stage and inspect | Tasks, logs found by their task id, and reading back what FortiManager staged |
| 8 | Install | Preview then commit, and a guard so only the diff you read gets installed |
| 9 | The finished tool | The final fmg_push.py, tests and a hardening checklist |
Titles and order may shift as I write them up. If a later part teaches me something that changes an earlier one, I’ll fix the earlier one and say so.
The lab and the versions
- FortiManager 7.6.6 (build 3654, a VM on Proxmox). This is the version that matters for the series, because the API you call is FortiManager’s. I run it deliberately, and haven’t moved to 7.6.7.
- A physical FortiGate 70G on FortiOS 7.6.7 (build 3704), and FortiGate-VMs on 7.6.6, all managed by that FortiManager. FortiOS versions vary in the lab on purpose.
One caveat on that combination. The FortiManager 7.6.6 release notes list the supported FortiOS versions as “7.6.0 to 7.6.6”, alongside 7.4.0 to 7.4.12, 7.2.0 to 7.2.13 and 7.0.0 to 7.0.19. FortiOS 7.6.7 is not on that list. It works in my lab, and the 70G is managed without complaint, but that is an observation, not a guarantee. Check the release notes and compatibility information for your own combination before trusting it anywhere that matters.
To follow along you need:
- A FortiManager VM. Fortinet’s VM trial licence guide describes a free trial. At the time of writing Fortinet’s licensing documentation limits it to three managed devices or VDOMs and two ADOMs, and it has no FortiGuard subscriptions or FortiAnalyzer features. I couldn’t find the trial’s duration stated, so check when you activate it. Three devices is plenty here, because the series only ever needs one or two.
- At least one FortiGate, physical or VM, registered to it. If you haven’t built that yet, my FortiManager lab on Proxmox series does.
- A Linux host with Python 3 on the management network to run the scripts from. Mine is a small jumpbox VM. Don’t run this from your laptop across the internet, and never expose FortiManager’s API beyond your management network. It manages your whole fleet.
The repo
Everything in this series is in a public GitHub repo, fmg-api-from-scratch, with one tagged commit per part from Part 3 (part-03, part-04 and so on up to part-09). Parts 1 and 2 have no code. It contains placeholders only: no real addresses, serial numbers, keys or configs, an .env.example and never an .env, and I ran a secret scan over the full history before publishing it. It is MIT licensed.
The honest bit
A fair amount of what follows was worked out by watching the FortiManager GUI talk to its own API, or by probing, because the documentation didn’t cover it. I’ll flag which calls are documented and which I captured, because the second kind can change between FortiManager releases without notice. Everything here is tested on the versions above and nothing else.
I’ll also tell you where I was wrong. A “successful” response that did nothing, an endpoint that returned a plausible answer that was out of date, and a fix that briefly deleted variables a working device depended on. Those aren’t embarrassing footnotes, they’re the reason the order of this series is what it is.
What comes next
Part 2 opens up the box. FortiManager keeps two databases, one for the ADOM and one for each device, and the difference between them is why some of my pushes “succeeded” and installed nothing at all.
Sources:
- FortiManager 7.6.6 Release Notes: supported FortiOS versions
- FortiManager API Best Practices Guide: Introduction (7.6.0)
- FortiManager API Best Practices Guide: Getting started (7.6.0)
- Fortinet Developer Network
- How-To FortiManager API (community)
- Technical Tip: Using FortiManager/FortiAnalyzer API (Fortinet Community)
- FortiManager VM Trial License Guide: Activating a free trial (7.6.0)
- FortiManager 7.6.6 Administration Guide: “Sequence of operations for installation to managed devices” (Key Concepts, pp. 57-58) and “Preview the JSON request or CLI script for changes in the device database” (Device Manager, p. 208)
- pyfmg, pyFortimanagerAPI, networktocode/fortimanager-ansible