I am not a fan of the official FortiClient EMS API documentation that is available on the Fortinet Developer Network (FNDN). It is a bare-bones documentation that is sparse on explaining how to interact with the API, has very few examples, no responses, lacks a lot of API endpoints, and the endpoints that exist are badly documented (I challenge you to try creating a ZTNA tag with an associated rule using only the documentation).
To offer a bit of help to you, dear reader, in this regard, I went through the most common configurations when working with EMS, how you accomplish these tasks using the API, and I will also show you how you can help yourself when working with the API.
The setup
- FortiGate 70G on 7.6.7
- FortiClient EMS on 7.4.7
- 2x Windows 11 client on 25H2 with FortiClient 7.4.7
All the API examples I give will be done using Python. Everything featured in this post will also be on the Fortinet resources GitHub repository.
I am using an on-prem EMS. Cloud EMS is a bit different, especially when it comes to the login, and you have to adapt your own scripts accordingly. See the official documentation for Cloud EMS.
The most important thing: Reverse engineering the API
I want to give the best advice first, and in this case it’s getting comfortable with reverse engineering API calls.
In practice, this means that you do something in the GUI and simultaneously use the browser tools to find out what happens, because most actions in the GUI create the exact API call you need to accomplish this in your API tool of choice, be it Python, cURL, or anything of the like.
Let’s take the example mentioned in the intro: Creating a ZTNA tag with an associated rule

As we can see, I have this ZTNA tag named BROWSER-TAG-NAME, with a user notification message, a comment, and a rule that validates on FortiClient that the user is part of the AD group ZTNA_USERS.
Once I hit save on this and with my browser tools open and recording, I can see, on Chrome, in the Network tab, a create call. In the Headers tab of this call, I can see the Request URL https://192.168.1.208/api/v1/tags/zero_trust/create and the Request Method of POST.

On the Payload tab, after clicking on View Source, I can see the full JSON payload.

The Response tab also shows the response, which isn’t that important.
With these three pieces of information, the URL, method and payload, I can recreate this API call and find out what all the information in the JSON payload means.
This approach of reverse engineering is, with the current state of the official documentation, invaluable.
Now let’s get to the meat of this post.
AI usage disclosure
There is a function called deep_merge, which I will call out when it is used, that is purely written by Claude Sonnet 4.6. It is used to update a dictionary with new information, also called a deep merge. I did this because it would have taken me too much time to do it myself, and on that day, it was too late, and I just wanted to get this part done.
Every other piece of code you see in this post is written by me.
How I structure this
Just so I don’t have to repeat myself, I will explain how I approach each section concerning the example I give.
I will start with an explanation of what is being done and some additional information where required, embed the Python script I have made (again, check the GitHub repository if you want to have it all in one place), post the response EMS gives for the relevant API call, and write some more about it, if there is anything to write about.
All the Python scripts are, hopefully, well commented. Most of the things being done are basic, and only some parts require special attention, which are explained in more detail.
I am not good at Python, so if you believe any of my scripts are bad, please keep that in mind.
Logging in, getting your token and logging out.
Keep in mind that this is for on-prem EMS. Cloud EMS handles logging in differently.
The first step you need to make when you want to work with the FortiClient EMS API is logging in, and for that, you need an administrator. There is nothing special about this administrator, so on EMS, head to Administration -> Admin Users and add your user with the permissions you need. Setting Trusted Hosts is a good idea.
Once you log in to the API, you get a Cross-Site Request Forgery (CSRF) token in your cookies. It is important to know that this token is in your cookies; it is not sent in the response you get after logging in.
This token has to be used for various API endpoints when you create, update or delete information. Endpoints where you only GET information usually don’t need this token.
After you are done with whatever you need to do using the API, it is common courtesy to perform a logout, where you supply your token.
Login, get token, logout
'''
ems_login_token_logout.py
Perform a login on the FortiClient EMS API, get the CSRF token and then logout
'''
import requests
#Set credentials
username = 'apiadmin'
password = 'Start123$'
#Disable warnings
requests.urllib3.disable_warnings()
#Set some variables for the API
ems_server = '192.168.1.208'
#Set all used URLs
api_url_prefix = f'https://{ems_server}/api/v1'
login_url = f'{api_url_prefix}/auth/signin'
logout_url = f'{api_url_prefix}/auth/signout'
#Variables for data and headers
auth_data = {"name": f"{username}", "password": f"{password}"}
api_headers = {"Content-type": "application/json"}
#Setup session, login to EMS, and set new headers with CSRF token and referer
session = requests.Session()
login_response = session.post(url=login_url, json=auth_data, headers=api_headers, verify=False, timeout=30)
change_headers = {"Content-type": "application/json", "Referer": f"https://{ems_server}", "X-CSRFToken": f"{session.cookies["csrftoken"]}"}
#Perform a logout
session.post(url=logout_url, headers=change_headers, verify=False, timeout=30)
Login, get token, logout response
{
"result":{
"retval":1,
"message":"Login successful."
},
"data":{
"login_domain":null,
"is_password_insecure":false,
"site":"Default"
}
},
{
"result":{
"retval":1,
"message":"Logout successful."
}
}
Authorizing and editing a FortiGate
In order for FortiClient EMS to share endpoint information and ZTNA tag information with a FortiGate, the FortiGate needs to first get authorized. After that, you have to decide what tags you want to share and from which endpoints. Often, you want to share Security Posture Tags, and you want to Share All FortiClients, and that is what this script does.
Authorize and edit FortiGate
'''
ems_fgt_authorization_share_clients.py
Authorize a FortiGate and set properties on it using the FortiClient EMS API
'''
import requests
#Disable warnings
requests.urllib3.disable_warnings()
#Set credentials
username = 'apiadmin'
password = 'Start123$'
#Set some variables for the API
ems_server = '192.168.1.208'
fgt_serial = 'FGT70GSERIAL'
#Set all used URLs
api_url_prefix = f'https://{ems_server}/api/v1'
login_url = f'{api_url_prefix}/auth/signin'
logout_url = f'{api_url_prefix}/auth/signout'
fgt_authorization_url = f'{api_url_prefix}/client_certificates/set'
fgt_properties_url = f'{api_url_prefix}/fabric_device_auth/{fgt_serial}/update'
#Variables for data and headers
auth_data = {"name": f"{username}", "password": f"{password}"}
api_headers = {"Content-type": "application/json"}
fgt_authorization_data = {"filters": {"management_mode": "standalone", "cns": [f"{fgt_serial}"]}, "properties": {"authorized": True}}
#share_mode 0 is "Only share FortiClients connected to this fabric device (Recommended)"
#share_mode 1 is "Share all FortiClients"
#share_tag_types 1 is "Security Posture Tags", 2 is "Outbreak Tags", 3 is "Classification Tags", 4 is "Fabric Tags"
fgt_properties_data = {"share_mode":1,"selected_cn_list":[],"share_tag_types":[1],"alias":"FGT-EMSAPI"}
#Setup session, login to EMS, and set new headers with CSRF token and referer
session = requests.Session()
login_response = session.post(url=login_url, json=auth_data, headers=api_headers, verify=False, timeout=30)
change_headers = {"Content-type": "application/json", "Referer": f"https://{ems_server}", "X-CSRFToken": f"{session.cookies["csrftoken"]}"}
#Authorize the FortiGate and set properties
session.patch(url=fgt_authorization_url, json=fgt_authorization_data, headers=change_headers, verify=False, timeout=30)
session.patch(url=fgt_properties_url, json=fgt_properties_data, headers=change_headers, verify=False, timeout=30)
#Perform a logout
session.post(url=logout_url, headers=change_headers, verify=False, timeout=30)
Authorize and edit FortiGate response
{
"result":{
"retval":1,
"message":null
},
"data":{
"cns":[
"FGT70GSERIAL"
]
}
},
{
"result":{
"retval":1,
"message":"Fabric device successfully updated."
}
}
Creating a domain import
Skipping creating an authentication server, eh? Yes. The reason is that creating an authentication server is form-based and I couldn’t find a way to do this using the API, especially when it comes to AD with LDAPS with a certificate upload. Sorry.
But I know how to create a domain import, which isn’t that easy either, thanks to how EMS handles authentication servers. A short explanation of this.
EMS collects all authentication servers as Identity Providers (IDPs) in the /api/v1/idps/index endpoint, which you can perform a GET on, but creating it is done via a form on the /api/v1/idps/adfs/create endpoint using a POST.

Each IDP has a GUID, and you need this GUID to perform a domain import, because you first have to perform a walk on the directory structure to get the top-level objects (OUs and containers in most cases) and their information (GUID, name, DN and path). If your journey stops there and you just want to import from these top-level objects, you’re done. If you want to import individual groups, for example, in an OU, you have to go down a rabbit hole, because you have to get the GUID of that OU, perform a directory walk on that OU, get the information of the groups therein and get the information for the import (the same as for the OU).
In an ordered list:
- Get IDP/authentication server GUID
- Perform a directory walk using the IDP GUID to find top-level objects
- Find information on relevant top-level objects
- Optionally, perform a directory walk of top-level objects to find sublevel objects
- Optionally, get information on relevant sublevel objects
The script handles this case, and there is an example directory structure given, and if you reverse engineer the API process, it will start to make more sense than what I have presented here.
Create domain import
'''
ems_create_domain_import.py
Create a domain import using the FortiClient EMS API
'''
import urllib.parse
import json
import requests
#Disable warnings
requests.urllib3.disable_warnings()
#Set credentials
username = 'apiadmin'
password = 'Start123$'
#Set some variables for the API
ems_server = '192.168.1.208'
idp_name = "ad.labdomain.com"
#Adding groups and OUs to a policy is a bit complicated, so here is an example.
#Consider the following AD structure:
#ad.labdomain.com
#└── CLIENTS
#└── GROUPS
# ├── VPN_USERS
# ├── ZTNA_USERS
#└── SERVERS
# └── PROD
#If you want to import the entire CLIENTS OU add the name to the assigned_ous list
#The script will get the required information of the OU and create a dictionary for the import
#If you want to import the VPN_USERS group, and using the structure from above, you first have add the parent OU to the parent_ous list
#In the example the parent OU is GROUP
#Then add the VPN_USERS group to the assigned_group_names list
assigned_ous = ['CLIENTS','SERVERS']
parent_ous = ['GROUPS']
assigned_group_names = ['VPN_USERS','ZTNA_USERS']
assigned_ous_groups = []
#Set all used URLs
api_url_prefix = f'https://{ems_server}/api/v1'
login_url = f'{api_url_prefix}/auth/signin'
logout_url = f'{api_url_prefix}/auth/signout'
idps_url = f'{api_url_prefix}/idps/index'
#Variables for data and headers
auth_data = {"name": f"{username}", "password": f"{password}"}
api_headers = {"Content-type": "application/json"}
#Setup session, login to EMS, and set new headers with CSRF token and referer
session = requests.Session()
login_response = session.post(url=login_url, json=auth_data, headers=api_headers, verify=False, timeout=30)
change_headers = {"Content-type": "application/json", "Referer": f"https://{ems_server}", "X-CSRFToken": f"{session.cookies["csrftoken"]}"}
#Get authentication servers data
response = session.get(url=idps_url, headers=api_headers, verify=False, timeout=30)
response_decoded = json.loads(response.content.decode('utf-8'))
#Get all the necessary information directly from the IDP and create the data for the domain import
for idp in response_decoded['data']:
if idp['domain_info']['name'] == idp_name:
idp_id = idp['domain_info']['guid']
#The DN has to be URL encoded
idp_dn_urlencoded = urllib.parse.quote(idp['connection_info']['basedn'], safe="")
domain_import_url = f'{api_url_prefix}/idps/adfs/{idp_id}/patch'
#The live navigation is used to get the information regarding the directory structure straight from the authentication server
live_navigation_url = f'{api_url_prefix}/idps/{idp_id}/live_navigate?dn={idp_dn_urlencoded}'
#The response from the live navigation contains the directory structure with all OUs, but not groups
response = session.get(url=live_navigation_url, headers=api_headers, verify=False, timeout=30)
response_decoded = json.loads(response.content.decode('utf-8'))
#The two ifs are used to search for the OU name in the assigned OUs, if we want to import an entire OU, and search through the parent OUs if we need to import groups in OUs
for ou in response_decoded['data']:
if ou['name'] in assigned_ous:
#A temporary dictionary is used to store the information for the to-be-imported object
temp_dict = {"guid": ou['guid'], "name": ou['name'], "dn": ou['dn'], "path": ou['canonical_name'], }
#The temporary dictionary gets added to a list
assigned_ous_groups.append(temp_dict)
#Much the same is done for groups, except we first have to go through the OUs, like with the initial authentication server
if ou['name'] in parent_ous:
ou_dn_urlencoded = urllib.parse.quote(ou['dn'], safe="")
groups_live_navigation_url = f'{api_url_prefix}/idps/{idp_id}/live_navigate?dn={ou_dn_urlencoded}'
response = session.get(url=groups_live_navigation_url, headers=api_headers, verify=False, timeout=30)
response_decoded = json.loads(response.content.decode('utf-8'))
for group in response_decoded['data']:
if group['name'] in assigned_group_names:
temp_dict = {"guid": group['guid'], "name": group['name'], "dn": group['dn'], "path": group['canonical_name'], }
assigned_ous_groups.append(temp_dict)
#With all the dictionaries in the list we can assemble the JSON payload
domain_data = {
"sync_mins":60,
"is_imported": True,
"selected_group_containers": assigned_ous_groups
}
#Create domain import
session.patch(url=domain_import_url, json=domain_data, headers=change_headers, verify=False, timeout=30)
#Perform a logout
session.post(url=logout_url, headers=change_headers, verify=False, timeout=30)
Create domain import result
{
"result":{
"retval":1,
"message":"IDP updated successfully."
},
"data":{
"guid":"c95b36ba-5adb-480d-b8da-87fc7598d2da",
"domain_name":"ad.labdomain.com",
"selected_group_containers":[
{
"guid":"d9b2392d-01e5-4e8b-98bf-26fb85a3399c",
"path":"ad.labdomain.com/CLIENTS",
"name":"CLIENTS",
"dn":"OU=CLIENTS,DC=ad,DC=labdomain,DC=com"
},
{
"guid":"a79fcd99-07e2-4c75-a615-58043fd244da",
"path":"ad.labdomain.com/GROUPS/VPN_USERS",
"name":"VPN_USERS",
"dn":"CN=VPN_USERS,OU=GROUPS,DC=ad,DC=labdomain,DC=com"
},
{
"guid":"83658fae-a526-4d9e-8030-8193a7fa0198",
"path":"ad.labdomain.com/GROUPS/ZTNA_USERS",
"name":"ZTNA_USERS",
"dn":"CN=ZTNA_USERS,OU=GROUPS,DC=ad,DC=labdomain,DC=com"
},
{
"guid":"015eb230-26f7-4a10-bde7-df92b0891848",
"path":"ad.labdomain.com/SERVERS",
"name":"SERVERS",
"dn":"OU=SERVERS,DC=ad,DC=labdomain,DC=com"
}
],
"sync_mins":60
}
}
Getting imported objects
Not a very important piece, but if you want the information on all the objects your domain import has imported, you can use this script.
GET imported objects
'''
ems_get_imported_ad_objects.py
Get imported groups of AD server using the FortiClient EMS API
'''
import json
import requests
#Disable warnings
requests.urllib3.disable_warnings()
#Set credentials
username = 'apiadmin'
password = 'Start123$'
#Set some variables for the API
ems_server = '192.168.1.208'
idp_name = "ad.labdomain.com"
#Set all used URLs
api_url_prefix = f'https://{ems_server}/api/v1'
login_url = f'{api_url_prefix}/auth/signin'
logout_url = f'{api_url_prefix}/auth/signout'
idps_url = f'{api_url_prefix}/idps/index'
#Variables for data and headers
auth_data = {"name": f"{username}", "password": f"{password}"}
api_headers = {"Content-type": "application/json"}
#Setup session, login to EMS, and set new headers with CSRF token and referer
session = requests.Session()
login_response = session.post(url=login_url, json=auth_data, headers=api_headers, verify=False, timeout=30)
change_headers = {"Content-type": "application/json", "Referer": f"https://{ems_server}", "X-CSRFToken": f"{session.cookies["csrftoken"]}"}
#Get authentication servers data
response = session.get(url=idps_url, headers=api_headers, verify=False, timeout=30)
response_decoded = json.loads(response.content.decode('utf-8'))
#Get imported OUs from IDP
for idp in response_decoded['data']:
if idp['domain_info']['name'] == idp_name:
idp_id = idp['domain_info']['guid']
idp_ous_url = f'{api_url_prefix}/idps/adfs/{idp_id}/imported_ous'
response = session.get(url=idp_ous_url, headers=api_headers, verify=False, timeout=30)
response_decoded = json.loads(response.content.decode('utf-8'))
print(response_decoded['data']['group_containers'])
#Perform a logout
session.post(url=logout_url, headers=change_headers, verify=False, timeout=30)
GET imported objects response
[
{
"id":223,
"name":"CLIENTS",
"type":2,
"dn":"OU=CLIENTS,DC=ad,DC=labdomain,DC=com",
"is_user_selected":true,
"guid":"d9b2392d-01e5-4e8b-98bf-26fb85a3399c",
"full_path":"ad.labdomain.com/CLIENTS",
"parent_ids":[
227
],
"has_child":0,
"blocked":false,
"policy_id":"None",
"domain_id":3,
"policy_name":"None",
"telemetry_server_list_id":"None",
"telemetry_server_list_name":"None",
"total_devices":2,
"domain_type":1
},
{
"id":224,
"name":"VPN_USERS",
"type":4,
"dn":"CN=VPN_USERS,OU=GROUPS,DC=ad,DC=labdomain,DC=com",
"is_user_selected":true,
"guid":"a79fcd99-07e2-4c75-a615-58043fd244da",
"full_path":"ad.labdomain.com/GROUPS/VPN_USERS",
"parent_ids":[
227
],
"has_child":0,
"blocked":false,
"policy_id":"None",
"domain_id":3,
"policy_name":"None",
"telemetry_server_list_id":"None",
"telemetry_server_list_name":"None",
"total_devices":0,
"domain_type":1
},
{
"id":225,
"name":"ZTNA_USERS",
"type":4,
"dn":"CN=ZTNA_USERS,OU=GROUPS,DC=ad,DC=labdomain,DC=com",
"is_user_selected":true,
"guid":"83658fae-a526-4d9e-8030-8193a7fa0198",
"full_path":"ad.labdomain.com/GROUPS/ZTNA_USERS",
"parent_ids":[
227
],
"has_child":0,
"blocked":false,
"policy_id":"None",
"domain_id":3,
"policy_name":"None",
"telemetry_server_list_id":"None",
"telemetry_server_list_name":"None",
"total_devices":0,
"domain_type":1
},
{
"id":226,
"name":"SERVERS",
"type":2,
"dn":"OU=SERVERS,DC=ad,DC=labdomain,DC=com",
"is_user_selected":true,
"guid":"015eb230-26f7-4a10-bde7-df92b0891848",
"full_path":"ad.labdomain.com/SERVERS",
"parent_ids":[
227
],
"has_child":1,
"blocked":false,
"policy_id":"None",
"domain_id":3,
"policy_name":"None",
"telemetry_server_list_id":"None",
"telemetry_server_list_name":"None",
"total_devices":1,
"domain_type":1
}
]
Creating a system and remote access profile
I am only focusing on these two types of profiles because they are probably the most used ones, and going through all of them would take too long. If you want to know how to interact with the other types, you can reverse engineer them.
Both profile data variables contain most, if not all, options you can set. You can omit options if you want the defaults (most options are already the defaults).
Create system and remote access profile
'''
ems_create_profiles.py
Create a system and a remote access VPN profile using the FortiClient EMS API
'''
import requests
#Disable warnings
requests.urllib3.disable_warnings()
#Set credentials
username = 'apiadmin'
password = 'Start123$'
#Set some variables for the API
ems_server = '192.168.1.208'
temp_password = 'Start123$'
#Set all used URLs
api_url_prefix = f'https://{ems_server}/api/v1'
login_url = f'{api_url_prefix}/auth/signin'
logout_url = f'{api_url_prefix}/auth/signout'
system_profile_url = f'{api_url_prefix}/profiles/system/create'
vpn_profile_url = f'{api_url_prefix}/profiles/vpn/create'
#Variables for data and headers
auth_data = {"name": f"{username}", "password": f"{password}"}
api_headers = {"Content-type": "application/json"}
system_profile_data = {
"name": "SYS_EMS-API",
"is_chromebook": False,
"enabled": True,
"display_enabled": True,
"clone_from": None,
"json": {
"system": {
"ui": {
"disable_backup": 0,
"hide_user_info": 0,
"hide_system_tray_icon": 0,
"show_host_tag": 1,
"password": f"{temp_password}",
"lock": f"{temp_password}",
"unreg_pwd": f"{temp_password}",
"culture_code": "os-default",
"default_tab": "VPN",
"allow_shutdown_when_registered": 0
},
"log_settings": {
"onnet_local_logging": 1,
"level": 6,
"log_events": "antiexploit,antiransomware,av,cloudscan,endpoint,firewall,fssoma,ipsecvpn,pam,sandboxing,sslvpn,update,vuln,webfilter,ztna,configd,scheduler,shield,wanacc",
"remote_logging": {
"log_upload_enabled": 0,
"log_retention_days": 90,
"log_upload_freq_minutes": 60,
"send_software_inventory": 0,
"send_os_events": {
"enabled": 1,
"interval": 120
},
"log_upload_server": "",
"log_upload_ssl_enabled": 1,
"log_generation_timeout_secs": 900,
"log_compressed": 0,
"netlog_categories": 32
}
},
"proc_protect": 1,
"proxy": {
"update": 0,
"fail_over_to_fdn": 0,
"online_scep": 0,
"virus_submission": 0,
"type": "http",
"address": None,
"port": "80",
"username": None,
"password": ""
},
"update": {
"use_custom_server": 0,
"timeout": 60,
"failoverport": 8000,
"auto_patch": 0,
"update_action": "disable",
"scheduled_update": {
"enabled": 1,
"type": "interval",
"daily_at": "00:00",
"update_interval_in_hours": 1
},
"submit_virus_info_to_fds": 1,
"submit_vuln_info_to_fds": 1,
"use_legacy_fdn": 0,
"server": "",
"port": 80,
"fail_over_to_fdn": 0,
"restrict_services_to_regions": "",
"ocsp_mode": 0
},
"fortiproxy": {
"enabled": 1,
"enable_https_proxy": 1,
"http_timeout": 60,
"client_comforting": {
"pop3_client": 1,
"pop3_server": 1,
"smtp": 1
},
"selftest": {
"enabled": 1,
"last_port": 65535,
"notify": 1
}
},
"certificates": [31],
"user_identity": {
"enable_manually_entering": 0,
"enable_linkedin": 0,
"enable_google": 0,
"enable_salesforce": 0,
"notify_user": 0
},
"installer": {
"allow_admin_uninstall_when_locked": 1
},
"cryptography": {
"drbg_reseed_minutes": 1440
}
},
"extra": {
"trigger_vuln_scan": True
},
"endpoint_control": {
"ui": {
"hide_compliance_warning": 0
},
"notify_fgt_on_logoff": 0,
"forensics_license": 1,
"enable_dem": 0,
"disable_unregister": 1,
"disable_fgt_switch": 0,
"show_bubble_notifications": 1,
"send_software_inventory": 0,
"invalid_cert_action": "warn",
"edr_collector": 1,
"enable_dns_cache": 0,
"auto_start": 0
},
"fssoma": {
"enabled": 0,
"serveraddress": "",
"presharedkey": ""
},
"wan_optimization": {
"enabled": 0,
"max_disk_cache_size_mb": 512,
"support_http": 1,
"support_cifs": 1,
"support_mapi": 1,
"support_ftp": 1
},
"pam": {
"enabled": 0,
"default_port": 9191
}
}
}
vpn_profile_data = {
"name": "VPN_EMS-API",
"is_chromebook": False,
"enabled": True,
"display_enabled": True,
"clone_from": None,
"json": {
"vpn": {
"display_vpn": 1,
"enabled": 1,
"sslvpn": {
"options": {
"enabled": 0,
"prefer_sslvpn_dns": 1,
"disallow_invalid_server_certificate": 0,
"warn_invalid_server_certificate": 1,
"preferred_dtls_tunnel": 0,
"show_auth_cert_only": 0,
"use_gui_saml_auth": 0,
"block_ipv6": 1,
"dnscache_service_control": 0,
"no_dns_registration": 0,
"negative_split_tunnel_metric": None,
"mtu_size": 1300,
"dtls_mtu": 1100
},
"connections": []
},
"ipsecvpn": {
"options": {
"enabled": 1,
"use_win_current_user_cert": 1,
"use_win_local_computer_cert": 1,
"beep_if_error": 0,
"usewincert": 1,
"usesmcardcert": 1,
"use_gui_saml_auth": 0,
"block_ipv6": 1,
"enable_udp_checksum": 0,
"disable_default_route": 0,
"show_auth_cert_only": 0,
"check_for_cert_private_key": 0,
"enhanced_key_usage_mandatory": 0,
"disallow_invalid_server_certificate": 0,
"prefer_ipsecvpn_dns": 1,
"no_dns_registration": 0,
"mtu_size": 1280
},
"connections": [
{
"name": "API-IPSEC-VPN",
"pinned": 0,
"dns_priority": 1,
"machine": None,
"keep_running": 0,
"traffic_keep_strategy": 0,
"traffic_keep_timer": 5000,
"disclaimer_msg": "",
"single_user_mode": 0,
"ui": {
"show_remember_password": 0,
"show_alwaysup": 0,
"show_autoconnect": 0,
"show_passcode": 0,
"save_username": 0
},
"traffic_control": {
"enabled": 0,
"mode": 1,
"apps": [],
"fqdns": [],
"isdb_objects": [],
"vsdb_objects": []
},
"redundant_sort_method": 0,
"tags": {
"allowed": "",
"prohibited": ""
},
"host_check_fail_warning": "",
"ike_settings": {
"server": "192.0.2.1",
"authentication_method": "Preshared Key",
"auth_data": f"{temp_password}",
"transport_mode": 0,
"tcp_port": 443,
"udp_port": 500,
"cert_subjectcheck": 0,
"prompt_certificate": 1,
"xauth_timeout": 120,
"xauth": {
"use_otp": 0,
"enabled": 0,
"prompt_username": 0
},
"version": 2,
"mode": "aggressive",
"dhgroup": [31],
"key_life": 28800,
"localid": None,
"networkid": 0,
"eap_method": 1,
"implied_SPDO": 0,
"implied_SPDO_timeout": 60,
"nat_traversal": 1,
"enable_local_lan": 1,
"session_resume": 0,
"enable_ike_fragmentation": 1,
"mode_config": 1,
"modeconfig_type": 0,
"dpd": 1,
"proposals": [
{
"encryption": "AES128",
"authentication": "SHA256"
},
{
"encryption": "AES256",
"authentication": "SHA256"
}
],
"run_fcauth_system": 0,
"failover_sslvpn_connection": None,
"sso_enabled": 0,
"use_external_browser": 0,
"ike_saml_port": 443,
"keep_fqdn_resolution_consistency": 0,
"no_vnic_dns_server": 0,
"azure_auto_login": {
"enabled": 0,
"azure_app": {
"tenant_name": "",
"client_id": ""
}
}
},
"ipsec_settings": {
"remote_networks": [
{
"addr": "0.0.0.0",
"mask": "0.0.0.0"
},
{
"addr": "::/0",
"mask": "::/0"
}
],
"dhgroup": 31,
"key_life_type": "seconds",
"key_life_seconds": 3600,
"key_life_Kbytes": 5200,
"replay_detection": 1,
"pfs": 1,
"virtualip": {
"type": "modeconfig",
"ip": "0.0.0.0",
"mask": "0.0.0.0",
"dnsserver": "0.0.0.0",
"winserver": "0.0.0.0"
},
"proposals": [
{
"encryption": "AES128GCM",
"authentication": "NONE"
},
{
"encryption": "AES256",
"authentication": "SHA256"
}
],
"ipv4_split_exclude_networks": []
},
"on_connect": [
{
"os": "windows",
"script": ""
},
{
"os": "MacOSX",
"script": ""
}
],
"on_disconnect": [
{
"os": "windows",
"script": ""
},
{
"os": "MacOSX",
"script": ""
}
],
"android_cert_path": ""
}
]
},
"lockdown": {
"enabled": 0,
"grace_period": 120,
"max_attempts": 3,
"exceptions": {
"apps": None,
"ips": None,
"domains": None,
"icdb_domains": []
},
"detect_captive_portal": {
"enabled": 0,
"os_active_probing": 1
}
},
"options": {
"current_connection_name": "",
"current_connection_type": None,
"autoconnect_tunnel": "",
"vendor_id": None,
"on_os_start_connect": "",
"on_os_start_connect_has_priority": 0,
"show_vpn_before_logon": 1,
"minimize_window_on_connect": 1,
"use_windows_credentials": 0,
"suppress_vpn_notification": 0,
"secure_remote_access": 0,
"certs_require_keyspec": 0,
"disable_internet_check": 1,
"use_webview2_saml_auth": 0,
"enable_multi_vpn": 0,
"enforce_disabling_smartdns": 0,
"enable_view_selected_vpns": 0,
"keep_running_max_tries": 0,
"after_logon_saml_auth": 0,
"before_logon_saml_auth": 1,
"allow_personal_vpns": 1,
"disable_connect_disconnect": 0,
"autoconnect_on_install": 0,
"autoconnect_only_when_offnet": 0,
"temp_password": f"{temp_password}"
}
}
}
}
#Setup session, login to EMS, and set new headers with CSRF token and referer
session = requests.Session()
login_response = session.post(url=login_url, json=auth_data, headers=api_headers, verify=False, timeout=30)
change_headers = {"Content-type": "application/json", "Referer": f"https://{ems_server}", "X-CSRFToken": f"{session.cookies["csrftoken"]}"}
#Create system profile
session.post(url=system_profile_url, json=system_profile_data, headers=change_headers, verify=False, timeout=30)
#Create VPN profile
session.post(url=vpn_profile_url, json=vpn_profile_data, headers=change_headers, verify=False, timeout=30)
#Perform a logout
session.post(url=logout_url, headers=change_headers, verify=False, timeout=30)
Create system and remote access profile response
{
"result":{
"retval":1,
"message":"Profile component created succesfully."
},
"data":{
"id":68,
"json":{
"fssoma":{
"enabled":0,
"presharedkey":"",
"serveraddress":""
},
"version":"5.6.0",
"pam":{
"enabled":0,
"default_port":9191
},
"endpoint_control":{
"forensics_license":1,
"enable_dns_cache":0,
"enable_dem":0,
"send_software_inventory":0,
"disable_fgt_switch":0,
"auto_start":0,
"disable_unregister":1,
"notify_fgt_on_logoff":0,
"invalid_cert_action":"warn",
"ui":{
"hide_compliance_warning":0
},
"show_bubble_notifications":1,
"edr_collector":1
},
"system":{
"certificates":[
],
"cryptography":{
"drbg_reseed_minutes":1440
},
"update":{
"timeout":60,
"auto_patch":0,
"fail_over_to_fdn":0,
"restrict_services_to_regions":"",
"scheduled_update":{
"type":"interval",
"enabled":1,
"daily_at":"00:00",
"update_interval_in_hours":1
},
"submit_virus_info_to_fds":1,
"port":80,
"update_action":"disable",
"failoverport":8000,
"use_legacy_fdn":0,
"ocsp_mode":0,
"submit_vuln_info_to_fds":1,
"server":"",
"use_custom_server":0
},
"proxy":{
"username":null,
"update":0,
"fail_over_to_fdn":0,
"port":80,
"virus_submission":0,
"type":"http",
"password":"",
"address":null,
"online_scep":0
},
"installer":{
"allow_admin_uninstall_when_locked":1
},
"proc_protect":1,
"user_identity":{
"enable_google":0,
"enable_linkedin":0,
"enable_manually_entering":0,
"notify_user":0,
"enable_salesforce":0
},
"log_settings":{
"onnet_local_logging":1,
"level":6,
"log_events":"antiexploit,antiransomware,av,cloudscan,endpoint,firewall,fssoma,ipsecvpn,pam,sandboxing,sslvpn,update,vuln,webfilter,ztna,configd,scheduler,shield,wanacc",
"remote_logging":{
"send_os_events":{
"enabled":1,
"interval":120
},
"log_upload_enabled":0,
"log_retention_days":90,
"log_upload_ssl_enabled":1,
"send_software_inventory":0,
"log_upload_server":"",
"log_generation_timeout_secs":900,
"netlog_categories":32,
"log_upload_freq_minutes":60,
"log_compressed":0
}
},
"fortiproxy":{
"http_timeout":60,
"selftest":{
"notify":1,
"enabled":1,
"last_port":65535
},
"client_comforting":{
"pop3_client":1,
"pop3_server":1,
"smtp":1
},
"enable_https_proxy":1,
"enabled":1
},
"ui":{
"allow_shutdown_when_registered":0,
"disable_backup":0,
"unreg_pwd":"QyCr5xV6oIlvsBXpKY0WOZBgz7m3EtM4lYbtuuNudjSPW4meHcM6EHXKBb1XzeMcW4yVBnnVmBCcW2EDcwP7GevjLB27aE1Ht6PHPIEa0PzI1HdVrB8M6wewPN4B91Jb$OXwDzKLMons/0ycr3vyNBQQ53CCqovZM3N7geRo1J9wyGYGDehQ/pwjRehPmKcmhUfcchXh/83ucpm+uX7BONA==",
"lock":"Enc 94150c56da89b13d9645ed3cd5928f88764097e7f638f57aebdf9e07050cddc07d0a71024d30b4a834e32df39b63c2ca1b7516e79895cfda4556f2dd7a8151507d59b62b3e9dabdfdc7fb1dae92467fcc94b05106769a7ba",
"password":"Enc fee42e05beef862d7dd22b0e497534ebcc6840941f7ffdc6567519915361f10e87043d14c05c140e4ab9e49ee7a6328f53ee8ea9aed16eb8",
"culture_code":"os-default",
"hide_user_info":0,
"default_tab":"VPN",
"hide_system_tray_icon":0,
"show_host_tag":1
}
},
"wan_optimization":{
"support_cifs":1,
"support_http":1,
"support_ftp":1,
"enabled":0,
"support_mapi":1,
"max_disk_cache_size_mb":512
}
}
}
},
{
"result":{
"retval":1,
"message":"Profile component created succesfully."
},
"data":{
"id":69,
"json":{
"vpn":{
"sslvpn":{
"connections":[
],
"options":{
"dtls_mtu":1100,
"no_dns_registration":0,
"show_auth_cert_only":0,
"disallow_invalid_server_certificate":0,
"use_gui_saml_auth":0,
"warn_invalid_server_certificate":1,
"block_ipv6":1,
"negative_split_tunnel_metric":null,
"preferred_dtls_tunnel":0,
"mtu_size":1300,
"dnscache_service_control":0,
"enabled":0,
"prefer_sslvpn_dns":1
}
},
"ipsecvpn":{
"connections":[
{
"name":"API-IPSEC-VPN",
"pinned":0,
"dns_priority":1,
"machine":0,
"keep_running":0,
"traffic_keep_strategy":0,
"traffic_keep_timer":5000,
"disclaimer_msg":"",
"single_user_mode":0,
"ui":{
"show_remember_password":0,
"show_alwaysup":0,
"show_autoconnect":0,
"show_passcode":0,
"save_username":0
},
"traffic_control":{
"enabled":0,
"mode":1,
"apps":[
],
"fqdns":[
],
"isdb_objects":[
],
"vsdb_objects":[
]
},
"redundant_sort_method":0,
"tags":{
"allowed":"",
"prohibited":""
},
"host_check_fail_warning":"",
"ike_settings":{
"server":"192.0.2.1",
"authentication_method":"Preshared Key",
"auth_data":"Enc 564234f7087b4c8c602610fb456a4af44209e584441c59506e86588d8d018f97c1",
"transport_mode":0,
"tcp_port":443,
"udp_port":500,
"cert_subjectcheck":0,
"prompt_certificate":0,
"xauth_timeout":120,
"xauth":{
"use_otp":0,
"enabled":0,
"prompt_username":0,
"username":"",
"password":""
},
"version":2,
"mode":"aggressive",
"dhgroup":[
31
],
"key_life":28800,
"localid":"",
"networkid":0,
"eap_method":1,
"implied_SPDO":0,
"implied_SPDO_timeout":60,
"nat_traversal":1,
"enable_local_lan":1,
"session_resume":0,
"enable_ike_fragmentation":1,
"mode_config":1,
"modeconfig_type":0,
"dpd":1,
"proposals":[
{
"encryption":"AES128",
"authentication":"SHA256"
},
{
"encryption":"AES256",
"authentication":"SHA256"
}
],
"run_fcauth_system":0,
"failover_sslvpn_connection":"",
"sso_enabled":0,
"use_external_browser":0,
"ike_saml_port":443,
"keep_fqdn_resolution_consistency":0,
"no_vnic_dns_server":0,
"azure_auto_login":{
"enabled":0,
"azure_app":{
"tenant_name":"",
"client_id":""
}
},
"fgt":1,
"dpd_retry_count":3,
"dpd_retry_interval":20,
"certificate":null,
"nat_alive_freq":10
},
"ipsec_settings":{
"remote_networks":[
{
"addr":"0.0.0.0",
"mask":"0.0.0.0"
},
{
"addr":"::/0",
"mask":"::/0"
}
],
"dhgroup":31,
"key_life_type":"seconds",
"key_life_seconds":3600,
"key_life_Kbytes":5200,
"replay_detection":1,
"pfs":1,
"virtualip":{
"type":"modeconfig",
"ip":"0.0.0.0",
"mask":"0.0.0.0",
"dnsserver":"0.0.0.0",
"winserver":"0.0.0.0"
},
"proposals":[
{
"encryption":"AES128GCM",
"authentication":"NONE"
},
{
"encryption":"AES256",
"authentication":"SHA256"
}
],
"ipv4_split_exclude_networks":[
],
"use_vip":1
},
"on_connect":[
{
"os":"windows",
"script":""
},
{
"os":"MacOSX",
"script":""
}
],
"on_disconnect":[
{
"os":"windows",
"script":""
},
{
"os":"MacOSX",
"script":""
}
],
"android_cert_path":"",
"uid":"00C4F2CA-5814-4612-8F26-3838D8746782",
"warn_invalid_server_certificate":1,
"type":"manual"
}
],
"options":{
"disable_default_route":0,
"block_ipv6":1,
"use_win_local_computer_cert":1,
"check_for_cert_private_key":0,
"mtu_size":1280,
"usesmcardcert":1,
"beep_if_error":0,
"enhanced_key_usage_mandatory":0,
"no_dns_registration":0,
"show_auth_cert_only":0,
"disallow_invalid_server_certificate":0,
"prefer_ipsecvpn_dns":1,
"use_gui_saml_auth":0,
"use_win_current_user_cert":1,
"enable_udp_checksum":0,
"usewincert":1,
"enabled":1
}
},
"enabled":1,
"lockdown":{
"grace_period":120,
"max_attempts":3,
"detect_captive_portal":{
"enabled":0,
"os_active_probing":1
},
"exceptions":{
"domains":[
],
"ips":[
],
"apps":[
],
"icdb_domains":[
]
},
"enabled":0
},
"options":{
"after_logon_saml_auth":0,
"temp_password":"Start123$",
"enable_view_selected_vpns":0,
"minimize_window_on_connect":1,
"enable_multi_vpn":0,
"autoconnect_tunnel":"",
"show_vpn_before_logon":1,
"on_os_start_connect":"",
"secure_remote_access":0,
"current_connection_type":"",
"certs_require_keyspec":0,
"disable_internet_check":1,
"autoconnect_on_install":0,
"allow_personal_vpns":1,
"disconnect_password":"",
"vendor_id":"",
"use_windows_credentials":0,
"autoconnect_only_when_offnet":0,
"current_connection_name":"",
"before_logon_saml_auth":1,
"disable_connect_disconnect":0,
"on_os_start_connect_has_priority":0,
"suppress_vpn_notification":0,
"use_webview2_saml_auth":0,
"keep_running_max_tries":0,
"enforce_disabling_smartdns":0
},
"display_vpn":1
}
}
}
}
Updating a system and remote access profile
When it comes to updating things in EMS, you have to be careful because you often cannot just do a PATCH. Most endpoints for updating use a PUT, and when you do that, you are only setting the options you supply, and for everything else, the defaults are used.
In the example script, I disable security postage tags on the FortiClient GUI and disable bubble notifications in the system settings profile. If I were to send just this to the /api/v1/profiles/system/{ID}/update endpoint, it would set these two things, but it would set everything else to the default values. I handle this case by first performing a GET on the existing profile, saving the information in a variable and performing a deep merge with the updated values. The resulting variable, including the old and updated information, then gets used for the PUT.
This update procedure is also where the deep_merge function is used, which Claude has written.
Update system and remote access profile using deep merge
'''
ems_update_profiles_deep_merge.py
Update a system and a remote access VPN profile by first importing the existing configuration using the FortiClient EMS API
This script updates the system settings profile created in ems_create_profiles.py by disabling security posting tags on the GUI and disabling bubble notifications
This script updates the VPN profile created in ems_create_profiles.py by
* Enabling the save username option
* Changing the remote gateway
* Changing the phase 1 DH group
* Enabling session resume
* Changing the phase 2 proposals
* Disabling personal VPNs
'''
import json
import requests
#Disable warnings
requests.urllib3.disable_warnings()
def deep_merge(base, override):
"""
Function written by Claude Sonnet 4.6
Recursively merge `override` into `base`.
- Dicts: merged recursively.
- Lists of dicts with a 'name' key: matched by name, then merged recursively.
- Everything else: override replaces base.
"""
if isinstance(base, dict) and isinstance(override, dict):
result = base.copy()
for key, override_val in override.items():
base_val = result.get(key)
result[key] = deep_merge(base_val, override_val)
return result
if (
isinstance(base, list)
and isinstance(override, list)
and all(isinstance(i, dict) and "name" in i for i in base + override)
):
# Match connection objects by 'name', merge matched pairs
base_by_name = {item["name"]: item for item in base}
result = []
for override_item in override:
name = override_item["name"]
if name in base_by_name:
result.append(deep_merge(base_by_name[name], override_item))
else:
result.append(override_item) # new entry, add as-is
# Preserve base entries not present in override
override_names = {item["name"] for item in override}
for base_item in base:
if base_item["name"] not in override_names:
result.append(base_item)
return result
# Scalar, plain list, or mismatched types: override wins
return override
#Set credentials
username = 'apiadmin'
password = 'Start123$'
#Set some variables for the API
ems_server = '192.168.1.208'
temp_password = 'Start123$'
system_profile_name = "SYS_EMS-API"
vpn_profile_name = "VPN_EMS-API"
vpn_ipsec_connection_name = "API-IPSEC-VPN"
#Set all used URLs
api_url_prefix = f'https://{ems_server}/api/v1'
login_url = f'{api_url_prefix}/auth/signin'
logout_url = f'{api_url_prefix}/auth/signout'
system_profiles_get_url = f'{api_url_prefix}/profiles/system/index'
vpn_profiles_get_url = f'{api_url_prefix}/profiles/vpn/index'
#Variables for data and headers
auth_data = {"name": f"{username}", "password": f"{password}"}
api_headers = {"Content-type": "application/json"}
updated_system_profile_data = {
"json": {
"system": {
"ui": {
"show_host_tag": 0,
}
},
"endpoint_control": {
"show_bubble_notifications": 0,
}
}
}
updated_vpn_profile_data = {
"name": f"{vpn_profile_name}",
"json": {
"vpn": {
"ipsecvpn": {
"connections": [
{
"name": f"{vpn_ipsec_connection_name}",
"ui": {
"save_username": 1
},
"ike_settings": {
"server": "192.0.2.254",
"dhgroup": [21],
"session_resume": 1,
},
"ipsec_settings": {
"proposals": [
{
"encryption": "AES256GCM",
"authentication": "NONE"
},
{
"encryption": "AES256",
"authentication": "SHA512"
}
]
},
}
]
},
"options": {
"allow_personal_vpns": 0,
}
}
}
}
#Setup session, login to EMS, and set new headers with CSRF token and referer
session = requests.Session()
login_response = session.post(url=login_url, json=auth_data, headers=api_headers, verify=False, timeout=30)
change_headers = {"Content-type": "application/json", "Referer": f"https://{ems_server}", "X-CSRFToken": f"{session.cookies["csrftoken"]}"}
#Get system profiles
response = session.get(url=system_profiles_get_url, headers=api_headers, verify=False, timeout=30)
response_decoded = json.loads(response.content.decode('utf-8'))
for profile in response_decoded['data']['local']:
if profile['name'] == system_profile_name:
system_profile_get_url = f'{api_url_prefix}/profiles/system/{profile["id"]}/get'
response = session.get(url=system_profile_get_url, headers=api_headers, verify=False, timeout=30)
response_decoded = json.loads(response.content.decode('utf-8'))
system_profile_data = response_decoded['data']
#Update system_profile_data with the updated information
system_profile_data = deep_merge(system_profile_data, updated_system_profile_data)
#Set the correct URL for updating the system profile using the profile ID
system_profile_update_url = f'{api_url_prefix}/profiles/system/{profile["id"]}/update'
#Update system profile
session.put(url=system_profile_update_url, json=system_profile_data, headers=change_headers, verify=False, timeout=30)
#Get VPN profiles
response = session.get(url=vpn_profiles_get_url, headers=api_headers, verify=False, timeout=30)
response_decoded = json.loads(response.content.decode('utf-8'))
for profile in response_decoded['data']['local']:
if profile['name'] == vpn_profile_name:
vpn_profile_get_url = f'{api_url_prefix}/profiles/vpn/{profile["id"]}/get'
response = session.get(url=vpn_profile_get_url, headers=api_headers, verify=False, timeout=30)
response_decoded = json.loads(response.content.decode('utf-8'))
vpn_profile_data = response_decoded['data']
#Update vpn_profile_data with the updated information
#update_dictionary(vpn_profile_data['json']['vpn'], updated_vpn_profile_data['json']['vpn'])
vpn_profile_data = deep_merge(vpn_profile_data, updated_vpn_profile_data)
#Set the correct URL for updating the VPN profile using the profile ID
vpn_profile_update_url = f'{api_url_prefix}/profiles/vpn/{profile["id"]}/update'
#Update VPN profile
session.put(url=vpn_profile_update_url, json=vpn_profile_data, headers=change_headers, verify=False, timeout=30)
#Perform a logout
session.post(url=logout_url, headers=change_headers, verify=False, timeout=30)
Update system and remote access profile using deep merge response
{
"result":{
"retval":1,
"message":"Profile component updated succesfully."
},
"data":{
"id":70,
"json":{
"fssoma":{
"enabled":0,
"presharedkey":"",
"serveraddress":""
},
"version":"5.6.0",
"pam":{
"enabled":0,
"default_port":9191
},
"endpoint_control":{
"forensics_license":1,
"enable_dns_cache":0,
"enable_dem":0,
"send_software_inventory":0,
"disable_fgt_switch":0,
"auto_start":0,
"disable_unregister":1,
"notify_fgt_on_logoff":0,
"invalid_cert_action":"warn",
"ui":{
"hide_compliance_warning":0
},
"show_bubble_notifications":0,
"edr_collector":1
},
"system":{
"certificates":[
],
"cryptography":{
"drbg_reseed_minutes":1440
},
"update":{
"timeout":60,
"auto_patch":0,
"fail_over_to_fdn":0,
"restrict_services_to_regions":"",
"scheduled_update":{
"type":"interval",
"enabled":1,
"daily_at":"00:00",
"update_interval_in_hours":1
},
"submit_virus_info_to_fds":1,
"port":80,
"update_action":"disable",
"failoverport":8000,
"use_legacy_fdn":0,
"ocsp_mode":0,
"submit_vuln_info_to_fds":1,
"server":"",
"use_custom_server":0
},
"proxy":{
"username":null,
"update":0,
"fail_over_to_fdn":0,
"port":80,
"virus_submission":0,
"type":"http",
"password":"",
"address":null,
"online_scep":0
},
"installer":{
"allow_admin_uninstall_when_locked":1
},
"proc_protect":1,
"user_identity":{
"enable_google":0,
"enable_linkedin":0,
"enable_manually_entering":0,
"notify_user":0,
"enable_salesforce":0
},
"log_settings":{
"onnet_local_logging":1,
"level":6,
"log_events":"antiexploit,antiransomware,av,cloudscan,endpoint,firewall,fssoma,ipsecvpn,pam,sandboxing,sslvpn,update,vuln,webfilter,ztna,configd,scheduler,shield,wanacc",
"remote_logging":{
"send_os_events":{
"enabled":1,
"interval":120
},
"log_upload_enabled":0,
"log_retention_days":90,
"log_upload_ssl_enabled":1,
"send_software_inventory":0,
"log_upload_server":"",
"log_generation_timeout_secs":900,
"netlog_categories":32,
"log_upload_freq_minutes":60,
"log_compressed":0
}
},
"fortiproxy":{
"http_timeout":60,
"selftest":{
"notify":1,
"enabled":1,
"last_port":65535
},
"client_comforting":{
"pop3_client":1,
"pop3_server":1,
"smtp":1
},
"enable_https_proxy":1,
"enabled":1
},
"ui":{
"allow_shutdown_when_registered":0,
"disable_backup":0,
"unreg_pwd":"goBsfMBfsTRbI3732fJADZ2gJEqSFB7zeBKnNb2U3WRDVgKla5vP6XG4xPpK85mDIpq2xSvSfyagMMdQ7Hl73WiOMntt5LdOOgUvUi0MT8Oq74vif2XsiFd3tf57SFV7$TQJ1qTiQelcT/qoHkN4laeQI6jvKLeoe+1WevZOq9oBktCLyBB4goInKHqF9jiPGDNhrT7rToVWNEXKeqF8myQ==",
"lock":"Enc 283431078f7ac0acbb5fd257d3279947f5cd01bbe523458b9d50b1a1d5a8bf373b784abec45318e4646034cf13d32e00df29a194c3a247b9db53f6e768362e37239b7005d02266424b0efd63aac9c86669a86c0a0368caa7",
"password":"Enc 68be3193bbd992ab167dd677ba394598d6c827dcedc1d91d842296f9fcfb9cc97b6d62fc1e38124e0b1ea9dc2233665e1a76f192808a1f09",
"culture_code":"os-default",
"hide_user_info":0,
"default_tab":"VPN",
"hide_system_tray_icon":0,
"show_host_tag":0
}
},
"wan_optimization":{
"support_cifs":1,
"support_http":1,
"support_ftp":1,
"enabled":0,
"support_mapi":1,
"max_disk_cache_size_mb":512
}
}
}
},
{
"result":{
"retval":1,
"message":"Profile component updated succesfully."
},
"data":{
"id":70,
"json":{
"vpn":{
"sslvpn":{
"connections":[
],
"options":{
"dtls_mtu":1100,
"no_dns_registration":0,
"show_auth_cert_only":0,
"disallow_invalid_server_certificate":0,
"use_gui_saml_auth":0,
"warn_invalid_server_certificate":1,
"block_ipv6":1,
"negative_split_tunnel_metric":null,
"preferred_dtls_tunnel":0,
"mtu_size":1300,
"dnscache_service_control":0,
"enabled":0,
"prefer_sslvpn_dns":1
}
},
"ipsecvpn":{
"connections":[
{
"name":"API-IPSEC-VPN",
"pinned":0,
"dns_priority":1,
"machine":0,
"keep_running":0,
"traffic_keep_strategy":0,
"traffic_keep_timer":5000,
"disclaimer_msg":"",
"single_user_mode":0,
"ui":{
"show_remember_password":0,
"show_alwaysup":0,
"show_autoconnect":0,
"show_passcode":0,
"save_username":1
},
"traffic_control":{
"enabled":0,
"mode":1,
"apps":[
],
"fqdns":[
],
"isdb_objects":[
],
"vsdb_objects":[
]
},
"redundant_sort_method":0,
"tags":{
"allowed":"",
"prohibited":""
},
"host_check_fail_warning":"",
"ike_settings":{
"server":"192.0.2.254",
"authentication_method":"Preshared Key",
"auth_data":"Enc f7446dfccd809fbf6486f27f81c515eafa55049574aae9672f18533318cdb297e0",
"transport_mode":0,
"tcp_port":443,
"udp_port":500,
"cert_subjectcheck":0,
"prompt_certificate":0,
"xauth_timeout":120,
"xauth":{
"use_otp":0,
"enabled":0,
"prompt_username":0,
"username":"",
"password":""
},
"version":2,
"mode":"aggressive",
"dhgroup":[
21
],
"key_life":28800,
"localid":"",
"networkid":0,
"eap_method":1,
"implied_SPDO":0,
"implied_SPDO_timeout":60,
"nat_traversal":1,
"enable_local_lan":1,
"session_resume":1,
"enable_ike_fragmentation":1,
"mode_config":1,
"modeconfig_type":0,
"dpd":1,
"proposals":[
{
"encryption":"AES128",
"authentication":"SHA256"
},
{
"encryption":"AES256",
"authentication":"SHA256"
}
],
"run_fcauth_system":0,
"failover_sslvpn_connection":"",
"sso_enabled":0,
"use_external_browser":0,
"ike_saml_port":443,
"keep_fqdn_resolution_consistency":0,
"no_vnic_dns_server":0,
"azure_auto_login":{
"enabled":0,
"azure_app":{
"tenant_name":"",
"client_id":""
}
},
"fgt":1,
"dpd_retry_count":3,
"dpd_retry_interval":20,
"certificate":null,
"nat_alive_freq":10
},
"ipsec_settings":{
"remote_networks":[
{
"addr":"0.0.0.0",
"mask":"0.0.0.0"
},
{
"addr":"::/0",
"mask":"::/0"
}
],
"dhgroup":31,
"key_life_type":"seconds",
"key_life_seconds":3600,
"key_life_Kbytes":5200,
"replay_detection":1,
"pfs":1,
"virtualip":{
"type":"modeconfig",
"ip":"0.0.0.0",
"mask":"0.0.0.0",
"dnsserver":"0.0.0.0",
"winserver":"0.0.0.0"
},
"proposals":[
{
"encryption":"AES256GCM",
"authentication":"NONE"
},
{
"encryption":"AES256",
"authentication":"SHA512"
}
],
"ipv4_split_exclude_networks":[
],
"use_vip":1
},
"on_connect":[
{
"os":"windows",
"script":""
},
{
"os":"MacOSX",
"script":""
}
],
"on_disconnect":[
{
"os":"windows",
"script":""
},
{
"os":"MacOSX",
"script":""
}
],
"android_cert_path":"",
"uid":"B0A6E39F-F101-46DC-B94E-2F42A375ECF7",
"warn_invalid_server_certificate":1,
"type":"manual"
}
],
"options":{
"disable_default_route":0,
"block_ipv6":1,
"use_win_local_computer_cert":1,
"check_for_cert_private_key":0,
"mtu_size":1280,
"usesmcardcert":1,
"beep_if_error":0,
"enhanced_key_usage_mandatory":0,
"no_dns_registration":0,
"show_auth_cert_only":0,
"disallow_invalid_server_certificate":0,
"prefer_ipsecvpn_dns":1,
"use_gui_saml_auth":0,
"use_win_current_user_cert":1,
"enable_udp_checksum":0,
"usewincert":1,
"enabled":1
}
},
"enabled":1,
"lockdown":{
"grace_period":120,
"max_attempts":3,
"detect_captive_portal":{
"enabled":0,
"os_active_probing":1
},
"exceptions":{
"domains":[
],
"ips":[
],
"apps":[
],
"icdb_domains":[
]
},
"enabled":0
},
"options":{
"after_logon_saml_auth":0,
"temp_password":"Start123$",
"enable_view_selected_vpns":0,
"minimize_window_on_connect":1,
"enable_multi_vpn":0,
"autoconnect_tunnel":"",
"show_vpn_before_logon":1,
"on_os_start_connect":"",
"secure_remote_access":0,
"current_connection_type":"",
"certs_require_keyspec":0,
"disable_internet_check":1,
"autoconnect_on_install":0,
"allow_personal_vpns":0,
"disconnect_password":"",
"vendor_id":"",
"use_windows_credentials":0,
"autoconnect_only_when_offnet":0,
"current_connection_name":"",
"before_logon_saml_auth":1,
"disable_connect_disconnect":0,
"on_os_start_connect_has_priority":0,
"suppress_vpn_notification":0,
"use_webview2_saml_auth":0,
"keep_running_max_tries":0,
"enforce_disabling_smartdns":0
},
"display_vpn":1
}
}
}
}
In the GitHub repository, there is also a script called ems_update_profiles_entire_data.py, which has the entire data as a variable and sends it without first copying the existing profile. This is an alternative and is almost the same as creating a profile, except it is still an update.
Creating security posture/ZTNA tags and rules
This is the one that, I believe, is impossible to do with the official documentation, because the documentation, when it comes to rules, just says what type of values (string, integer or boolean) you have to supply, but it is not possible to know what any value should actually be. Reverse engineering luckily solves this problem.
If you want to employ custom logic for rules, good luck with only the documentation, because all you get is string for what you need to supply.
Both these cases are covered in the script, and there is some additional information in the comments regarding a few keys you use in rules.
Create security posture/ZTNA tag and rules
'''
ems_create_ztna_tag_rule.py
Create a ZTNA tag and associated rule using the FortiClient EMS API
'''
import requests
#Disable warnings
requests.urllib3.disable_warnings()
#Set credentials
username = 'apiadmin'
password = 'Start123$'
#Set some variables for the API
ems_server = '192.168.1.208'
#Set all used URLs
api_url_prefix = f'https://{ems_server}/api/v1'
login_url = f'{api_url_prefix}/auth/signin'
logout_url = f'{api_url_prefix}/auth/signout'
ztna_url = f'{api_url_prefix}/tags/zero_trust/create'
#Variables for data and headers
auth_data = {"name": f"{username}", "password": f"{password}"}
api_headers = {"Content-type": "application/json"}
#fct_based means the rule is evaluated on FortiClient directly, not on EMS
#The id value in the rules key must be incremented for each rule of a tag
#The type should be the position of the option in the GUI, meaning that the first element of the dropdown is 1, the second 2, etc.
#Not all types where checked if this rule of numbering holds true and this can change in the future
#The os number is in the order in the GUI from left to right, starting at 1 for Windows
#The content is completely dependent on the type of rule used, but most often corresponds to whatever you would enter in a field or what you can select
ztna_data = {
"name":"ZTNA-TAG_API",
"description":"This is the User Notification Message",
"status": True,
"comments":"Created using the API",
"rules":[
{
"negative": False,
"content":"GROUPS/ZTNA_USERS",
"domainName":"ad.labdomain.com",
"type":1,
"os":1,
"id":1,
"fct_based":True
},
{
"negative": False,
"content":"C:\\temp\\file1.txt",
"context":"",
"type":4,
"os":1,
"id":2,
}
],
"use_custom_logic": False
}
ztna_data_custom_logic = {
"name":"ZTNA-TAG-CUSTOM-LOGIC_API",
"description":"This is the User Notification Message",
"status": True,
"comments":"Created using the API with custom logic",
"rules":[
{
"negative": False,
"content":"GROUPS/ZTNA_USERS",
"domainName":"ad.labdomain.com",
"type":1,
"os":1,
"id":1,
"fct_based":True
},
{
"negative": False,
"content":"C:\\temp\\file1.txt",
"context":"",
"type":4,
"os":1,
"id":2,
}
],
"use_custom_logic": True,
"logic":{
"android": None,
"ios": None,
"linux": None,
"mac": None,
"windows":"{\"op\":\"or\",\"rules\":[{\"id\":1},{\"id\":2}]}"
}
}
#Setup session, login to EMS, and set new headers with CSRF token and referer
session = requests.Session()
login_response = session.post(url=login_url, json=auth_data, headers=api_headers, verify=False, timeout=30)
change_headers = {"Content-type": "application/json", "Referer": f"https://{ems_server}", "X-CSRFToken": f"{session.cookies["csrftoken"]}"}
#Create ZTNA tag and rule
session.post(url=ztna_url, json=ztna_data, headers=change_headers, verify=False, timeout=30)
#Create ZTNA tag and rule with custom logic
session.post(url=ztna_url, json=ztna_data_custom_logic, headers=change_headers, verify=False, timeout=30)
#Perform a logout
session.post(url=logout_url, headers=change_headers, verify=False, timeout=30)
Create security posture/ZTNA tag and rules response
{
"result":{
"retval":1,
"message":"Tag 'ZTNA-TAG_API' created successfully."
},
"data":{
"id":81,
"tag_uid":"8fa2287f-9c7e-43aa-a792-6cd32b16013f",
"name":"ZTNA-TAG_API",
"type":"zero_trust",
"type_id":1,
"detection_level":null,
"description":"This is the User Notification Message",
"update_time":"2026-06-28T14:24:12.973"
}
},
{
"result":{
"retval":1,
"message":"Tag 'ZTNA-TAG-CUSTOM-LOGIC_API' created successfully."
},
"data":{
"id":84,
"tag_uid":"53cf2c7d-32d7-4e6a-96d6-8ddc228dc742",
"name":"ZTNA-TAG-CUSTOM-LOGIC_API",
"type":"zero_trust",
"type_id":1,
"detection_level":null,
"description":"This is the User Notification Message",
"update_time":"2026-06-28T14:24:13.025"
}
}
Updating security posture/ZTNA tags and rules
If you want to update a tag and rule, you should just send the entire data to the /api/v1/tags/zero_trust/update_one endpoint as a POST, as an easy way. The problem is that a GET on /api/v1/tags/zero_trust/{ID}/get gives you different information from what the POST requires, so you’d first have to format the GET response, perform a deep merge with your new information and then send a POST. Maybe there is a better solution, but I can’t say I found one.
Update security posture/ZTNA tag and rules
'''
ems_update_ztna_tag_rule.py
Update a ZTNA tag and associated rule using the FortiClient EMS API
This updates the first rule to target VPN_USERS (from ZTNA_USERS) and the second rule to search for file2.txt (from file1.txt)
'''
import json
import requests
#Disable warnings
requests.urllib3.disable_warnings()
#Set credentials
username = 'apiadmin'
password = 'Start123$'
#Set some variables for the API
ems_server = '192.168.1.208'
ztna_tag_name = 'ZTNA-TAG_API'
#Set all used URLs
api_url_prefix = f'https://{ems_server}/api/v1'
login_url = f'{api_url_prefix}/auth/signin'
logout_url = f'{api_url_prefix}/auth/signout'
ztna_get_url = f'{api_url_prefix}/tags/zero_trust/index'
ztna_update_url = f'{api_url_prefix}/tags/zero_trust/update_one'
#Variables for data and headers
auth_data = {"name": f"{username}", "password": f"{password}"}
api_headers = {"Content-type": "application/json"}
#fct_based means the rule is evaluated on FortiClient directly, not on EMS
#The id value in the rules key must be incremented for each rule of a tag
#The type should be the position of the option in the GUI, meaning that the first element of the dropdown is 1, the second 2, etc.
#Not all types where checked if this rule of numbering holds true and this can change in the future
#The os number is in the order in the GUI from left to right, starting at 1 for Windows
#The content is completely dependent on the type of rule used, but most often corresponds to whatever you would enter in a field or what you can select
updated_ztna_data = {
"name":"ZTNA-TAG_API",
"description":"This is the User Notification Message",
"status": True,
"comments":"Created using the API",
"rules":[
{
"negative": False,
"content":"GROUPS/VPN_USERS",
"domainName":"ad.labdomain.com",
"type":1,
"os":1,
"id":1,
"fct_based":True
},
{
"negative": False,
"content":"C:\\temp\\file2.txt",
"context":"",
"type":4,
"os":1,
"id":2,
}
],
"use_custom_logic": False
}
#Setup session, login to EMS, and set new headers with CSRF token and referer
session = requests.Session()
login_response = session.post(url=login_url, json=auth_data, headers=api_headers, verify=False, timeout=30)
change_headers = {"Content-type": "application/json", "Referer": f"https://{ems_server}", "X-CSRFToken": f"{session.cookies["csrftoken"]}"}
#Get ZTNA tag information
response = session.get(url=ztna_get_url, headers=api_headers, verify=False, timeout=30)
response_decoded = json.loads(response.content.decode('utf-8'))
for ztna_tag in response_decoded['data']['tags']:
if ztna_tag['name'] == ztna_tag_name:
updated_ztna_data['id'] = ztna_tag['id']
session.post(url=ztna_update_url, json=updated_ztna_data, headers=change_headers, verify=False, timeout=30)
#Perform a logout
session.post(url=logout_url, headers=change_headers, verify=False, timeout=30)
Update security posture/ZTNA tag and rules response
{
"result":{
"retval":1,
"message":"Tag 'ZTNA-TAG_API' updated successfully."
},
"data":{
"id":81,
"name":"ZTNA-TAG_API"
}
}
Compare the data of the updated_ztna_data variable with the data from a GET request on that tag, and you see the problem.
GET security posture/ZTNA tag and rules response
{
"result":{
"retval":1,
"message":null
},
"data":{
"tag_name":"ZTNA-TAG_API",
"tag_type":1,
"client_count":0,
"description":"This is the User Notification Message",
"detection_level":"",
"tag_id":81,
"status":true,
"comments":"Created using the API",
"use_custom_logic":false,
"logic_windows":"{\"op\": \"and\", \"rules\": [{\"id\": 1}, {\"id\": 2}]}",
"logic_mac":null,
"logic_linux":null,
"logic_ios":null,
"logic_android":null,
"error_msg":null,
"tag_detection_type":null,
"rules":[
{
"id":1,
"os":1,
"type":1,
"negative":false,
"content":"VPN_USERS",
"context":"a79fcd99-07e2-4c75-a615-58043fd244da",
"fct_based":true,
"domain_name":"ad.labdomain.com"
},
{
"id":2,
"os":1,
"type":4,
"negative":false,
"content":"C:\\temp\\file2.txt",
"context":""
}
]
}
}
tag_id instead of id, the logic parts are completely different, and you have additional key-value pairs. It’s not impossible to do a nice update, but I didn’t create such a function for this post.
Create an on-fabric detection rule
This API endpoint is completely missing from the documentation, by the way.
A funny thing about the Local IP/Subnet type. In the GUI, you are restricted in what you can enter, and you can only enter private IP ranges. If you use the API, you can set any and all subnets and IPs. You cannot edit the specific rule after this in the GUI, however.
Create on-fabric detection rule
'''
ems_create_on_net_rule.py
Create on-net rule using the FortiClient EMS API
'''
import requests
#Disable warnings
requests.urllib3.disable_warnings()
#Set credentials
username = 'apiadmin'
password = 'Start123$'
#Set some variables for the API
ems_server = '192.168.1.208'
#Set all used URLs
api_url_prefix = f'https://{ems_server}/api/v1'
login_url = f'{api_url_prefix}/auth/signin'
logout_url = f'{api_url_prefix}/auth/signout'
on_net_url = f'{api_url_prefix}/on_net_rules/create'
#Variables for data and headers
auth_data = {"name": f"{username}", "password": f"{password}"}
api_headers = {"Content-type": "application/json"}
on_net_data = {
"name":"ON-NET_API",
"enabled": True,
"comments":"On-net rule created via the API",
"local_ip":"192.0.2.0/24",
"dns_server_ip":"198.51.100.1",
"public_ip":"203.0.113.1"
}
#Setup session, login to EMS, and set new headers with CSRF token and referer
session = requests.Session()
login_response = session.post(url=login_url, json=auth_data, headers=api_headers, verify=False, timeout=30)
change_headers = {"Content-type": "application/json", "Referer": f"https://{ems_server}", "X-CSRFToken": f"{session.cookies["csrftoken"]}"}
#Create on-net rule
session.post(url=on_net_url, json=on_net_data, headers=change_headers, verify=False, timeout=30)
#Perform a logout
session.post(url=logout_url, headers=change_headers, verify=False, timeout=30)
Create on-fabric detection rule response
{
"result":{
"retval":1,
"message":"On-fabric Detection Rule created successfully."
},
"data":"ON-NET_API"
}
Updating an on-fabric detection rule
Much like with the ZTNA tag, the GET response is different from what you need for an update, so while /api/v1/on_net_rules/{ID}/update exists as an endpoint with a PATCH method, it is easier to send the data for the entire rule again.
Update on-fabric detection rule
'''
ems_update_on_net_rule.py
Update on-net rule using the FortiClient EMS API
This updates the on-fabric rule with a new DNS server and public IP
'''
import json
import requests
#Disable warnings
requests.urllib3.disable_warnings()
#Set credentials
username = 'apiadmin'
password = 'Start123$'
#Set some variables for the API
ems_server = '192.168.1.208'
on_net_rule_name = "ON-NET_API"
#Set all used URLs
api_url_prefix = f'https://{ems_server}/api/v1'
login_url = f'{api_url_prefix}/auth/signin'
logout_url = f'{api_url_prefix}/auth/signout'
on_net_get_url = f'{api_url_prefix}/on_net_rules/index'
on_net_update_url = f'{api_url_prefix}/on_net_rules/5/update'
#Variables for data and headers
auth_data = {"name": f"{username}", "password": f"{password}"}
api_headers = {"Content-type": "application/json"}
updated_on_net_data = {
"name":f"{on_net_rule_name}",
"enabled": True,
"comments":"On-net rule create via the API",
"local_ip":"192.0.2.0/24",
"dns_server_ip":"198.51.100.100",
"public_ip":"203.0.113.100"
}
#Setup session, login to EMS, and set new headers with CSRF token and referer
session = requests.Session()
login_response = session.post(url=login_url, json=auth_data, headers=api_headers, verify=False, timeout=30)
change_headers = {"Content-type": "application/json", "Referer": f"https://{ems_server}", "X-CSRFToken": f"{session.cookies["csrftoken"]}"}
#Get on-net rules information
response = session.get(url=on_net_get_url, headers=api_headers, verify=False, timeout=30)
response_decoded = json.loads(response.content.decode('utf-8'))
#Get ID of on-net rule, set URL accordingly and update rule
for rule in response_decoded['data']['rule_sets']:
if rule['name'] == on_net_rule_name:
on_net_update_url = f'{api_url_prefix}/on_net_rules/{rule['id']}/update'
session.patch(url=on_net_update_url, json=updated_on_net_data, headers=change_headers, verify=False, timeout=30)
#Perform a logout
session.post(url=logout_url, headers=change_headers, verify=False, timeout=30)
Update on-fabric detection rule
'''
ems_update_on_net_rule.py
Update on-net rule using the FortiClient EMS API
This updates the on-fabric rule with a new DNS server and public IP
'''
import requests
#Disable warnings
requests.urllib3.disable_warnings()
#Set credentials
username = 'apiadmin'
password = 'Start123$'
#Set some variables for the API
ems_server = '192.168.1.208'
#Set all used URLs
api_url_prefix = f'https://{ems_server}/api/v1'
login_url = f'{api_url_prefix}/auth/signin'
logout_url = f'{api_url_prefix}/auth/signout'
on_net_url = f'{api_url_prefix}/on_net_rules/5/update'
#Variables for data and headers
auth_data = {"name": f"{username}", "password": f"{password}"}
api_headers = {"Content-type": "application/json"}
updated_on_net_data = {
"name":"ON-NET_API",
"enabled": True,
"comments":"On-net rule create via the API",
"local_ip":"192.0.2.0/24",
"dns_server_ip":"198.51.100.100",
"public_ip":"203.0.113.100"
}
#Setup session, login to EMS, and set new headers with CSRF token and referer
session = requests.Session()
login_response = session.post(url=login_url, json=auth_data, headers=api_headers, verify=False, timeout=30)
change_headers = {"Content-type": "application/json", "Referer": f"https://{ems_server}", "X-CSRFToken": f"{session.cookies["csrftoken"]}"}
#Update on-net rule
session.patch(url=on_net_url, json=updated_on_net_data, headers=change_headers, verify=False, timeout=30)
#Perform a logout
session.post(url=logout_url, headers=change_headers, verify=False, timeout=30)
Compare the data of the updated_on_net_data variable with the data from a GET request on the rule, and you, again, see the problem.
GET on-fabric detection rule response
{
"result":{
"retval":1,
"message":null
},
"data":[
{
"id":19,
"rule_number":0,
"type":8,
"content":"198.51.100.1",
"rule_set_id":6,
"vdom_id":1
},
{
"id":20,
"rule_number":1,
"type":11,
"content":"1.1.1.0/24",
"rule_set_id":6,
"vdom_id":1
},
{
"id":21,
"rule_number":2,
"type":14,
"content":"203.0.113.1",
"rule_set_id":6,
"vdom_id":1
}
]
}
We have a completely different structure here. Maybe there is a better solution, but I don’t know it.
Creating a policy
This is probably the most complicated piece that I cover, because a lot goes into a policy.
You need:
- The assignment of imported OUs and groups, which is only a bit less difficult than the domain import
- Get the required on-fabric detection rule ID
- Get the IDs of each profile you want to assign
This makes this script the one I spent the most time on, but it works.
Create policy
'''
ems_create_policy.py
Create a policy with profiles for AD OUs and groups and an on-net rule using the FortiClient EMS API
'''
import json
import requests
#Disable warnings
requests.urllib3.disable_warnings()
#Set credentials
username = 'apiadmin'
password = 'Start123$'
#Set some variables for the API
ems_server = '192.168.1.208'
idp_name = "ad.labdomain.com"
#Adding groups and OUs to a policy is a bit complicated, so here is an example.
#Consider the following AD structure:
#ad.labdomain.com
#└── CLIENTS
#└── GROUPS
# ├── VPN_USERS
# ├── ZTNA_USERS
#└── SERVERS
# └── PROD
#If you want to assign the entire CLIENTS OU to a policy add the name to the assigned_ous list
#The script will get the ID of the OU and add it to the list of IDs that should get added to the policy
#If you want to add the VPN_USERS group to a policy and using the structure from above you first have to get the GUID of the GROUPS OU
#With the GUID you can then look up all the groups in that OU and get the ID for the group and add that to the list of IDs that should get added to the policy
#In order to facilitate this add the parent OU to the parent_ous list and the groups you want to assign, that are in the parent OU, to the assigned_group_names list
assigned_ous = ['CLIENTS']
parent_ous = ['GROUPS', 'SERVERS']
assigned_group_names = ['PROD', 'VPN_USERS']
on_net_rule_name = "ON-NET_API"
on_net_rule_id = 0
ou_group_ids = []
#You just need to supply the name for the desired profile
on_net_profiles = {
"vpn": {'name':'Default'},
"ztna": {'name':'Default'},
"webfilter": {'name':'Default'},
"videofilter": {'name':'Default'},
"vulnerability_scan": {'name':'Default'},
"malware": {'name':'Default'},
"sandbox": {'name':'Default'},
"firewall": {'name':'Default'},
"ftdata_scan": {'name':'Default'},
"system": {'name':'SYS_EMS-API'}
}
off_net_profiles = {
"vpn": {'name':'VPN_EMS-API'},
"ztna": {'name':'Default'},
"webfilter": {'name':'Default'},
"videofilter": {'name':'Default'},
"vulnerability_scan": {'name':'Default'},
"malware": {'name':'Default'},
"sandbox": {'name':'Default'},
"firewall": {'name':'Default'},
"ftdata_scan": {'name':'Default'},
"system": {'name':'SYS_EMS-API'}
}
#Set all used URLs
api_url_prefix = f'https://{ems_server}/api/v1'
login_url = f'{api_url_prefix}/auth/signin'
logout_url = f'{api_url_prefix}/auth/signout'
policy_url = f'{api_url_prefix}/endpoint_policies/create'
idps_url = f'{api_url_prefix}/idps/index'
on_net_url = f'{api_url_prefix}/on_net_rules/index'
#This list is used later to loop through so we don't need to reuse code
profile_type_names = ['vpn', 'ztna','webfilter','videofilter','vulnerability_scan','malware','sandbox','firewall','ftdata_scan','system']
#Variables for data and headers
auth_data = {"name": f"{username}", "password": f"{password}"}
api_headers = {"Content-type": "application/json"}
#Setup session, login to EMS, and set new headers with CSRF token and referer
session = requests.Session()
login_response = session.post(url=login_url, json=auth_data, headers=api_headers, verify=False, timeout=30)
change_headers = {"Content-type": "application/json", "Referer": f"https://{ems_server}", "X-CSRFToken": f"{session.cookies["csrftoken"]}"}
#Get authentication servers data
response = session.get(url=idps_url, headers=api_headers, verify=False, timeout=30)
response_decoded = json.loads(response.content.decode('utf-8'))
#Get imported OUs from IDPS and add the IDs to the ou_group_ids list
for idp in response_decoded['data']:
if idp['domain_info']['name'] == idp_name:
idp_id = idp['domain_info']['guid']
idp_groups_url = f'{api_url_prefix}/idps/adfs/{idp_id}/imported_ous'
response = session.get(url=idp_groups_url, headers=api_headers, verify=False, timeout=30)
response_decoded = json.loads(response.content.decode('utf-8'))
#The two ifs are used to search for the OU name in the assigned OUs, if we want to assign an entire OU to a policy and search through the parent OUs if we need to assign groups in OUs
for ou in response_decoded['data']['group_containers']:
if ou['name'] in assigned_ous or ou['name'] in assigned_group_names:
ou_group_ids.append(ou['id'])
if ou['name'] in parent_ous:
ou_groups_url = f'{api_url_prefix}/idps/adfs/{ou['guid']}/imported_ous'
response = session.get(url=ou_groups_url, headers=api_headers, verify=False, timeout=30)
response_decoded = json.loads(response.content.decode('utf-8'))
for group in response_decoded['data']['group_containers']:
if group['name'] in assigned_group_names:
ou_group_ids.append(group['id'])
#We create a list that will hold dictionaries with each ID that should be assigned to the policy
endpoint_group_ids = []
for id_entry in ou_group_ids:
id_dict = {"id":id_entry}
endpoint_group_ids.append(id_dict)
#Get on-net rules and set ID based on given name
response = session.get(url=on_net_url, headers=api_headers, verify=False, timeout=30)
response_decoded = json.loads(response.content.decode('utf-8'))
for rule in response_decoded['data']['rule_sets']:
if rule['name'] == on_net_rule_name:
on_net_rule_id = rule['id']
#Loop through all profile types and add the ID for each named profile to the dictionary
for profile_type in profile_type_names:
profile_url = f'{api_url_prefix}/profiles/{profile_type}/index'
response = session.get(url=profile_url, headers=api_headers, verify=False, timeout=30)
response_decoded = json.loads(response.content.decode('utf-8'))
for profile in response_decoded['data']['local']:
if profile['name'] == on_net_profiles[f'{profile_type}']['name']:
on_net_profiles[f'{profile_type}']['id'] = profile['id']
if profile['name'] == off_net_profiles[f'{profile_type}']['name']:
off_net_profiles[f'{profile_type}']['id'] = profile['id']
policy_data = {
"name":"POLICY_API",
"endpoint_groups":endpoint_group_ids,
"enable_on_off_net":True,
"profile_components":{
"vpn":{
"id":on_net_profiles['vpn']['id'],
},
"ztna":{
"id":on_net_profiles['ztna']['id'],
},
"webfilter":{
"id":on_net_profiles['webfilter']['id'],
},
"videofilter":{
"id":on_net_profiles['videofilter']['id'],
},
"vulnerability_scan":{
"id":on_net_profiles['vulnerability_scan']['id'],
},
"malware":{
"id":on_net_profiles['malware']['id'],
},
"sandbox":{
"id":on_net_profiles['sandbox']['id'],
},
"firewall":{
"id":on_net_profiles['firewall']['id'],
},
"ftdata_scan":{
"id":on_net_profiles['ftdata_scan']['id'],
},
"system":{
"id":on_net_profiles['system']['id'],
}
},
"off_net_profile_components":{
"vpn":{
"id":off_net_profiles['vpn']['id'],
},
"ztna":{
"id":off_net_profiles['ztna']['id'],
},
"webfilter":{
"id":off_net_profiles['webfilter']['id'],
},
"videofilter":{
"id":off_net_profiles['videofilter']['id'],
},
"vulnerability_scan":{
"id":off_net_profiles['vulnerability_scan']['id'],
},
"malware":{
"id":off_net_profiles['malware']['id'],
},
"sandbox":{
"id":off_net_profiles['sandbox']['id'],
},
"firewall":{
"id":off_net_profiles['firewall']['id'],
},
"ftdata_scan":{
"id":off_net_profiles['ftdata_scan']['id'],
},
"system":{
"id":off_net_profiles['system']['id'],
}
},
"telemetry_server_list":None,
"on_net_rules":[
{
"id":on_net_rule_id,
}
],
"comments":"Policy created using the API",
"enabled":True,
}
#Create policy
session.post(url=policy_url, json=policy_data, headers=change_headers, verify=False, timeout=30)
#Perform a logout
session.post(url=logout_url, headers=change_headers, verify=False, timeout=30)
Create policy
{
"result":{
"retval":1,
"message":"Endpoint policy created successfully."
},
"data":{
"warning":null
}
}
Updating a policy
Third time’s the charm, and updating a policy has the same problem as ZTNA tags and on-fabric detection rules. The GET response is different from what you need, so just send the entire data again for an update instead of updating just the desired components.
Update policy
'''
ems_update_policy.py
Update a policy with profiles for AD OUs and groups and an on-net rule using the FortiClient EMS API
This script updates the policy by setting different groups (from VPN_USERS to ZTNA_USERS) and a different VPN profile in the off-net profile components (from VPN_EMS-API to Default)
'''
import json
import requests
#Disable warnings
requests.urllib3.disable_warnings()
#Set credentials
username = 'apiadmin'
password = 'Start123$'
#Set some variables for the API
ems_server = '192.168.1.208'
idp_name = "ad.labdomain.com"
policy_name = "POLICY_API"
#Adding groups and OUs to a policy is a bit complicated, so here is an example.
#Consider the following AD structure:
#ad.labdomain.com
#└── CLIENTS
#└── GROUPS
# ├── VPN_USERS
# ├── ZTNA_USERS
#└── SERVERS
# └── PROD
#If you want to assign the entire CLIENTS OU to a policy add the name to the assigned_ous list
#The script will get the ID of the OU and add it to the list of IDs that should get added to the policy
#If you want to add the VPN_USERS group to a policy and using the structure from above you first have to get the GUID of the GROUPS OU
#With the GUID you can then look up all the groups in that OU and get the ID for the group and add that to the list of IDs that should get added to the policy
#In order to facilitate this add the parent OU to the parent_ous list and the groups you want to assign, that are in the parent OU, to the assigned_group_names list
assigned_ous = ["CLIENTS"]
parent_ous = ['GROUPS', 'SERVERS']
assigned_group_names = ['PROD', 'ZTNA_USERS']
on_net_rule_name = "ON-NET_API"
ou_group_ids = []
on_net_rule_id = 0
#You just need to supply the name for the desired profile
on_net_profiles = {
"vpn": {'name':'Default'},
"ztna": {'name':'Default'},
"webfilter": {'name':'Default'},
"videofilter": {'name':'Default'},
"vulnerability_scan": {'name':'Default'},
"malware": {'name':'Default'},
"sandbox": {'name':'Default'},
"firewall": {'name':'Default'},
"ftdata_scan": {'name':'Default'},
"system": {'name':'SYS_EMS-API'}
}
off_net_profiles = {
"vpn": {'name':'Default'},
"ztna": {'name':'Default'},
"webfilter": {'name':'Default'},
"videofilter": {'name':'Default'},
"vulnerability_scan": {'name':'Default'},
"malware": {'name':'Default'},
"sandbox": {'name':'Default'},
"firewall": {'name':'Default'},
"ftdata_scan": {'name':'Default'},
"system": {'name':'SYS_EMS-API'}
}
#Set all used URLs
api_url_prefix = f'https://{ems_server}/api/v1'
login_url = f'{api_url_prefix}/auth/signin'
logout_url = f'{api_url_prefix}/auth/signout'
policy_get_url = f'{api_url_prefix}/endpoint_policies/index'
idps_url = f'{api_url_prefix}/idps/index'
on_net_url = f'{api_url_prefix}/on_net_rules/index'
#This list is used later to loop through so we don't need to reuse code
profile_type_names = ['vpn', 'ztna','webfilter','videofilter','vulnerability_scan','malware','sandbox','firewall','ftdata_scan','system']
#Variables for data and headers
auth_data = {"name": f"{username}", "password": f"{password}"}
api_headers = {"Content-type": "application/json"}
#Setup session, login to EMS, and set new headers with CSRF token and referer
session = requests.Session()
login_response = session.post(url=login_url, json=auth_data, headers=api_headers, verify=False, timeout=30)
change_headers = {"Content-type": "application/json", "Referer": f"https://{ems_server}", "X-CSRFToken": f"{session.cookies["csrftoken"]}"}
#Get authentication servers data
response = session.get(url=idps_url, headers=api_headers, verify=False, timeout=30)
response_decoded = json.loads(response.content.decode('utf-8'))
#Get imported OUs from IDPS and add the IDs to the ou_group_ids list
for idp in response_decoded['data']:
if idp['domain_info']['name'] == idp_name:
idp_id = idp['domain_info']['guid']
idp_groups_url = f'{api_url_prefix}/idps/adfs/{idp_id}/imported_ous'
response = session.get(url=idp_groups_url, headers=api_headers, verify=False, timeout=30)
response_decoded = json.loads(response.content.decode('utf-8'))
#The two ifs are used to search for the OU name in the assigned OUs, if we want to assign an entire OU to a policy and search through the parent OUs if we need to assign groups in OUs
for ou in response_decoded['data']['group_containers']:
if ou['name'] in assigned_ous or ou['name'] in assigned_group_names:
ou_group_ids.append(ou['id'])
if ou['name'] in parent_ous:
ou_groups_url = f'{api_url_prefix}/idps/adfs/{ou['guid']}/imported_ous'
response = session.get(url=ou_groups_url, headers=api_headers, verify=False, timeout=30)
response_decoded = json.loads(response.content.decode('utf-8'))
for group in response_decoded['data']['group_containers']:
if group['name'] in assigned_group_names:
ou_group_ids.append(group['id'])
#We create a list that will hold dictionaries with each ID that should be assigned to the policy
endpoint_group_ids = []
for id_entry in ou_group_ids:
id_dict = {"id":id_entry}
endpoint_group_ids.append(id_dict)
#Get on-net rules and set ID based on given name
response = session.get(url=on_net_url, headers=api_headers, verify=False, timeout=30)
response_decoded = json.loads(response.content.decode('utf-8'))
for rule in response_decoded['data']['rule_sets']:
if rule['name'] == on_net_rule_name:
on_net_rule_id = rule['id']
#Loop through all profile types and add the ID for each named profile to the dictionary
for profile_type in profile_type_names:
profile_url = f'{api_url_prefix}/profiles/{profile_type}/index'
response = session.get(url=profile_url, headers=api_headers, verify=False, timeout=30)
response_decoded = json.loads(response.content.decode('utf-8'))
for profile in response_decoded['data']['local']:
if profile['name'] == on_net_profiles[f'{profile_type}']['name']:
on_net_profiles[f'{profile_type}']['id'] = profile['id']
if profile['name'] == off_net_profiles[f'{profile_type}']['name']:
off_net_profiles[f'{profile_type}']['id'] = profile['id']
updated_policy_data = {
"name":"POLICY_API",
"endpoint_groups":endpoint_group_ids,
"enable_on_off_net":True,
"profile_components":{
"vpn":{
"id":on_net_profiles['vpn']['id'],
},
"ztna":{
"id":on_net_profiles['ztna']['id'],
},
"webfilter":{
"id":on_net_profiles['webfilter']['id'],
},
"videofilter":{
"id":on_net_profiles['videofilter']['id'],
},
"vulnerability_scan":{
"id":on_net_profiles['vulnerability_scan']['id'],
},
"malware":{
"id":on_net_profiles['malware']['id'],
},
"sandbox":{
"id":on_net_profiles['sandbox']['id'],
},
"firewall":{
"id":on_net_profiles['firewall']['id'],
},
"ftdata_scan":{
"id":on_net_profiles['ftdata_scan']['id'],
},
"system":{
"id":on_net_profiles['system']['id'],
}
},
"off_net_profile_components":{
"vpn":{
"id":off_net_profiles['vpn']['id'],
},
"ztna":{
"id":off_net_profiles['ztna']['id'],
},
"webfilter":{
"id":off_net_profiles['webfilter']['id'],
},
"videofilter":{
"id":off_net_profiles['videofilter']['id'],
},
"vulnerability_scan":{
"id":off_net_profiles['vulnerability_scan']['id'],
},
"malware":{
"id":off_net_profiles['malware']['id'],
},
"sandbox":{
"id":off_net_profiles['sandbox']['id'],
},
"firewall":{
"id":off_net_profiles['firewall']['id'],
},
"ftdata_scan":{
"id":off_net_profiles['ftdata_scan']['id'],
},
"system":{
"id":off_net_profiles['system']['id'],
}
},
"telemetry_server_list":None,
"on_net_rules":[
{
"id":on_net_rule_id,
}
],
"comments":"Test",
"enabled":True,
}
#Get policy ID, set patch URL and update policy
response = session.get(url=policy_get_url, headers=api_headers, verify=False, timeout=30)
response_decoded = json.loads(response.content.decode('utf-8'))
for policy in response_decoded['data']:
if policy['name'] == policy_name:
policy_update_url = f'{api_url_prefix}/endpoint_policies/{policy['id']}/update'
#Update policy
session.patch(url=policy_update_url, json=updated_policy_data, headers=change_headers, verify=False, timeout=30)
#Perform a logout
session.post(url=logout_url, headers=change_headers, verify=False, timeout=30)
Update policy response
{
"result":{
"retval":1,
"message":"Endpoint policy updated successfully."
},
"data":{
"warning":"None"
}
}
Look at the GET from a policy and compare it with the updated_policy_data variable.
GET policy response
{
"result":{
"retval":1,
"message":"None"
},
"data":[
{
"id":49,
"name":"POLICY_API",
"enable_on_off_net":true,
"comments":"Policy created using the API",
"enabled":true,
"is_default":false,
"is_sase":false,
"priority":1,
"groups":{
"195":{
"name":"CLIENTS",
"path":"ad.labdomain.com/CLIENTS"
},
"200":{
"name":"PROD",
"path":"ad.labdomain.com/SERVERS/PROD"
}
},
"rule_sets":{
"6":{
"name":"ON-NET_API",
"enabled":true
}
},
"users":{
},
"fct_users_count":{
"synced":1,
"unseen":0,
"out_of_sync":0
},
"profile_components":{
"malware":{
"id":1,
"name":"Default"
},
"sandbox":{
"id":1,
"name":"Default"
},
"webfilter":{
"id":1,
"name":"Default",
"fp_name":"None"
},
"firewall":{
"id":1,
"name":"Default"
},
"vpn":{
"id":1,
"name":"Default"
},
"vulnerability_scan":{
"id":1,
"name":"Default"
},
"system":{
"id":70,
"name":"SYS_EMS-API"
},
"ztna":{
"id":1,
"name":"Default"
},
"videofilter":{
"id":1,
"name":"Default"
},
"ftdata_scan":{
"id":1,
"name":"Default"
}
},
"off_net_profile_components":{
"malware":{
"id":1,
"name":"Default"
},
"sandbox":{
"id":1,
"name":"Default"
},
"webfilter":{
"id":1,
"name":"Default",
"fp_name":"None"
},
"firewall":{
"id":1,
"name":"Default"
},
"vpn":{
"id":70,
"name":"VPN_EMS-API"
},
"vulnerability_scan":{
"id":1,
"name":"Default"
},
"system":{
"id":70,
"name":"SYS_EMS-API"
},
"ztna":{
"id":1,
"name":"Default"
},
"videofilter":{
"id":1,
"name":"Default"
},
"ftdata_scan":{
"id":1,
"name":"Default"
}
}
},
{
"id":1,
"name":"Default",
"enable_on_off_net":false,
"comments":"",
"enabled":true,
"is_default":true,
"is_sase":false,
"priority":2,
"groups":{
},
"rule_sets":{
},
"users":{
},
"fct_users_count":{
"synced":0,
"unseen":0,
"out_of_sync":0
},
"profile_components":{
"malware":{
"id":1,
"name":"Default"
},
"sandbox":{
"id":1,
"name":"Default"
},
"webfilter":{
"id":1,
"name":"Default",
"fp_name":"None"
},
"firewall":{
"id":1,
"name":"Default"
},
"vpn":{
"id":1,
"name":"Default"
},
"vulnerability_scan":{
"id":1,
"name":"Default"
},
"system":{
"id":34,
"name":"LAB-SYS"
},
"ztna":{
"id":1,
"name":"Default"
},
"videofilter":{
"id":1,
"name":"Default"
},
"ftdata_scan":{
"id":1,
"name":"Default"
}
},
"off_net_profile_components":{
}
}
]
}
groups instead of endpoint_groups in a different format and rule_sets instead of on_net_rules, again, in a different format.
Creating an installer
All installer API endpoints are missing from the documentation.
Creating an installer isn’t that difficult, and most options are self-explanatory, but you have to keep the three variables for the version and the features in mind when working with it.
Note: Feature 15, EDR, is only available in Cloud EMS. If you have an on-prem EMS, like me, you have to remove this feature. I have commented it out in my script. Also, installer names are kept track of internally, so you cannot create installers with the same name, even if you delete one. Maybe there is some cleanup on EMS upgrades or periodically, however.
Create installer
'''
ems_create_installer.py
Create FortiClient installer using the FortiClient EMS API
'''
import json
import requests
#Disable warnings
requests.urllib3.disable_warnings()
#Set credentials
username = 'apiadmin'
password = 'Start123$'
#Set some variables for the API
ems_server = '192.168.1.208'
version_major_minor = "7.4"
installer_name = "7.4.7"
fct_comparable = 7004007
system_profile_name = "SYS_EMS-API"
system_profile_id = 0
vpn_profile_name = "VPN_EMS-API"
vpn_profile_id = 0
#Set all used URLs
api_url_prefix = f'https://{ems_server}/api/v1'
login_url = f'{api_url_prefix}/auth/signin'
logout_url = f'{api_url_prefix}/auth/signout'
installer_url = f'{api_url_prefix}/assignable_installers/create'
system_profiles_get_url = f'{api_url_prefix}/profiles/system/index'
vpn_profiles_get_url = f'{api_url_prefix}/profiles/vpn/index'
#Variables for data and headers
auth_data = {"name": f"{username}", "password": f"{password}"}
api_headers = {"Content-type": "application/json"}
#Setup session, login to EMS, and set new headers with CSRF token and referer
session = requests.Session()
login_response = session.post(url=login_url, json=auth_data, headers=api_headers, verify=False, timeout=30)
change_headers = {"Content-type": "application/json", "Referer": f"https://{ems_server}", "X-CSRFToken": f"{session.cookies["csrftoken"]}"}
#Get system profiles
response = session.get(url=system_profiles_get_url, headers=api_headers, verify=False, timeout=30)
response_decoded = json.loads(response.content.decode('utf-8'))
for profile in response_decoded['data']['local']:
if profile['name'] == system_profile_name:
system_profile_id = {profile["id"]}
#The id is a set, so we convert it to a list and get the only element from it
system_profile_id = list(system_profile_id)[0]
#Get VPN profiles
response = session.get(url=vpn_profiles_get_url, headers=api_headers, verify=False, timeout=30)
response_decoded = json.loads(response.content.decode('utf-8'))
for profile in response_decoded['data']['local']:
if profile['name'] == vpn_profile_name:
vpn_profile_id = {profile["id"]}
#The id is a set, so we convert it to a list and get the only element from it
vpn_profile_id = list(vpn_profile_id)[0]
installer_data = {
"name":"API-INSTALLER",
"notes":"FortiClient installer created via API",
"version_major_minor":version_major_minor,
"auto_update":None,
"installer_name":installer_name,
"fct_comparable":fct_comparable,
"windows_installer":True,
"mac_installer":True,
"linux_installer":True,
"windows_arm_installer":False,
"linux_arm_installer":False ,
"features":[
5, #Zero Trust Telemetry
3, #Secure Access Architecture Components
7, #Vulnerability Scan
6, #Advanced Persistent Threat (APT) Components
1, #AntiVirus, Anti-Exploit, Removable Media Access
10, #Anti-Ransomware
9, #Cloud Based Malware Outbreak Detection
2, #Web and Video Filtering
4, #Application Firewall
8, #Single Sign-On Mobility Agent
11, #Zero Trust Network Access
13, #Privileged Access Agent
16, #Data Protection
12#, #FIPS Certification
#15, #EDR, only in cloud EMS, remove if on-prem EMS
],
"auto_register":True,
"desktop_shortcut":True,
"start_menu_shortcut":False,
"msi_files":True,
"override_invitation_code":False,
"group_assignment_rules_id":None,
"vpn_profile_component_id":vpn_profile_id,
"system_profile_component_id":system_profile_id,
"invalid_cert_action":None,
"telemetry_server_list_id":None
}
#Create installer
response = session.post(url=installer_url, json=installer_data, headers=change_headers, verify=False, timeout=30)
response_decoded = response.content.decode('utf-8')
print(response_decoded)
#Perform a logout
session.post(url=logout_url, headers=change_headers, verify=False, timeout=30)
Create installer response
{
"result":{
"retval":1,
"message":null
}
}
Updating an installer
Like always at this point, the GET is different from what you need, so send the entire data as your payload again.
Update installer
'''
ems_update_installer.py
Update FortiClient installer using the FortiClient EMS API
This updates the existing installer by:
* Setting Mac and Linux installers to False
* Removing the Data Protection and FIPS Certification features
'''
import json
import requests
#Disable warnings
requests.urllib3.disable_warnings()
#Set credentials
username = 'apiadmin'
password = 'Start123$'
#Set some variables for the API
ems_server = '192.168.1.208'
installer_real_name = "API-INSTALLER"
version_major_minor = "7.4"
installer_name = "7.4.7"
fct_comparable = 7004007
system_profile_name = "SYS_EMS-API"
system_profile_id = 0
vpn_profile_name = "VPN_EMS-API"
vpn_profile_id = 0
#Set all used URLs
api_url_prefix = f'https://{ems_server}/api/v1'
login_url = f'{api_url_prefix}/auth/signin'
logout_url = f'{api_url_prefix}/auth/signout'
installer_index_url = f'{api_url_prefix}/assignable_installers/index'
system_profiles_get_url = f'{api_url_prefix}/profiles/system/index'
vpn_profiles_get_url = f'{api_url_prefix}/profiles/vpn/index'
#Variables for data and headers
auth_data = {"name": f"{username}", "password": f"{password}"}
api_headers = {"Content-type": "application/json"}
#Setup session, login to EMS, and set new headers with CSRF token and referer
session = requests.Session()
login_response = session.post(url=login_url, json=auth_data, headers=api_headers, verify=False, timeout=30)
change_headers = {"Content-type": "application/json", "Referer": f"https://{ems_server}", "X-CSRFToken": f"{session.cookies["csrftoken"]}"}
#Get system profiles
response = session.get(url=system_profiles_get_url, headers=api_headers, verify=False, timeout=30)
response_decoded = json.loads(response.content.decode('utf-8'))
for profile in response_decoded['data']['local']:
if profile['name'] == system_profile_name:
system_profile_id = {profile["id"]}
#The id is a set, so we convert it to a list and get the only element from it
system_profile_id = list(system_profile_id)[0]
#Get VPN profiles
response = session.get(url=vpn_profiles_get_url, headers=api_headers, verify=False, timeout=30)
response_decoded = json.loads(response.content.decode('utf-8'))
for profile in response_decoded['data']['local']:
if profile['name'] == vpn_profile_name:
vpn_profile_id = {profile["id"]}
#The id is a set, so we convert it to a list and get the only element from it
vpn_profile_id = list(vpn_profile_id)[0]
updated_installer_data = {
"name":"API-INSTALLER",
"notes":"FortiClient installer updated via API",
"version_major_minor":version_major_minor,
"auto_update":None,
"installer_name":installer_name,
"fct_comparable":fct_comparable,
"windows_installer":True,
"mac_installer":False,
"linux_installer":False,
"windows_arm_installer":False,
"linux_arm_installer":False ,
"features":[
5, #Zero Trust Telemetry
3, #Secure Access Architecture Components
7, #Vulnerability Scan
6, #Advanced Persistent Threat (APT) Components
1, #AntiVirus, Anti-Exploit, Removable Media Access
10, #Anti-Ransomware
9, #Cloud Based Malware Outbreak Detection
2, #Web and Video Filtering
4, #Application Firewall
8, #Single Sign-On Mobility Agent
11, #Zero Trust Network Access
13, #Privileged Access Agent
],
"auto_register":True,
"desktop_shortcut":True,
"start_menu_shortcut":False,
"msi_files":True,
"override_invitation_code":False,
"group_assignment_rules_id":None,
"vpn_profile_component_id":vpn_profile_id,
"system_profile_component_id":system_profile_id,
"invalid_cert_action":None,
"telemetry_server_list_id":None
}
#Get installer ID
response = session.get(url=installer_index_url, headers=api_headers, verify=False, timeout=30)
response_decoded = json.loads(response.content.decode('utf-8'))
for installers in response_decoded['data']['installers']:
if installers['name'] == installer_real_name:
installer_id = {installers["id"]}
#The id is a set, so we convert it to a list and get the only element from it
installer_id = list(installer_id)[0]
#Assemble the URLs with the ID
installer_update_url = f'{api_url_prefix}/assignable_installers/{installer_id}/update'
#Update installer
session.patch(url=installer_update_url, json=updated_installer_data, headers=change_headers, verify=False, timeout=30)
#Perform a logout
session.post(url=logout_url, headers=change_headers, verify=False, timeout=30)
Update installer response
{
"result":{
"retval":1,
"message":null
}
}
If we look at the GET, we can see that there is a lot more information in an installer, like the entire VPN and system settings profile information, if you have attached one.
GET installer response
{
"result":{
"retval":1,
"message":null
},
"data":{
"id":35,
"fds":true,
"name":"API-INSTALLER",
"notes":"FortiClient installer updated via API",
"folder":"api-installer",
"auto_register":true,
"desktop_shortcut":true,
"start_menu_shortcut":false,
"msi_files":true,
"override_invitation_code":false,
"windows_installer":true,
"windows_arm_installer":false,
"linux_installer":false,
"linux_arm_installer":false,
"mac_installer":false,
"invalid_cert_action":null,
"features":[
1,
2,
3,
4,
5,
6,
7,
8,
9,
10,
11,
13
],
"uninstaller":false,
"installer_name":"7.4.7",
"installer_win":{
"id":1,
"name":"7.4.7",
"is_official":true,
"os":"win",
"custom":0,
"version_major_minor":"7.4",
"version":"7.4.7",
"version_comparable":7004007
},
"installer_win_invalid":false,
"installer_mac":{
"id":1,
"name":"7.4.7",
"is_official":true,
"os":"osx",
"custom":0,
"version_major_minor":"7.4",
"version":"7.4.7",
"version_comparable":7004007
},
"installer_mac_invalid":false,
"installer_linux":{
"id":1,
"name":"7.4.7",
"is_official":true,
"os":"lin",
"custom":0,
"version_major_minor":"7.4",
"version":"7.4.7",
"version_comparable":7004007
},
"installer_linux_invalid":false,
"telemetry_server_list":{
},
"group_assignment_rule":{
},
"supported":true,
"standalone_invitation_code":null,
"vpn_component":{
"id":70,
"name":"VPN_EMS-API",
"json":{
"vpn":{
"sslvpn":{
"connections":[
],
"options":{
"dtls_mtu":1100,
"no_dns_registration":0,
"show_auth_cert_only":0,
"disallow_invalid_server_certificate":0,
"use_gui_saml_auth":0,
"warn_invalid_server_certificate":1,
"block_ipv6":1,
"negative_split_tunnel_metric":null,
"preferred_dtls_tunnel":0,
"mtu_size":1300,
"dnscache_service_control":0,
"enabled":0,
"prefer_sslvpn_dns":1
}
},
"ipsecvpn":{
"connections":[
{
"name":"API-IPSEC-VPN",
"pinned":0,
"dns_priority":1,
"machine":0,
"keep_running":0,
"traffic_keep_strategy":0,
"traffic_keep_timer":5000,
"disclaimer_msg":"",
"single_user_mode":0,
"ui":{
"show_remember_password":0,
"show_alwaysup":0,
"show_autoconnect":0,
"show_passcode":0,
"save_username":1
},
"traffic_control":{
"enabled":0,
"mode":1,
"apps":[
],
"fqdns":[
],
"isdb_objects":[
],
"vsdb_objects":[
]
},
"redundant_sort_method":0,
"tags":{
"allowed":"",
"prohibited":""
},
"host_check_fail_warning":"",
"ike_settings":{
"server":"192.0.2.254",
"authentication_method":"Preshared Key",
"auth_data":"Enc f7446dfccd809fbf6486f27f81c515eafa55049574aae9672f18533318cdb297e0",
"transport_mode":0,
"tcp_port":443,
"udp_port":500,
"cert_subjectcheck":0,
"prompt_certificate":0,
"xauth_timeout":120,
"xauth":{
"use_otp":0,
"enabled":0,
"prompt_username":0,
"username":"",
"password":""
},
"version":2,
"mode":"aggressive",
"dhgroup":[
21
],
"key_life":28800,
"localid":"",
"networkid":0,
"eap_method":1,
"implied_SPDO":0,
"implied_SPDO_timeout":60,
"nat_traversal":1,
"enable_local_lan":1,
"session_resume":1,
"enable_ike_fragmentation":1,
"mode_config":1,
"modeconfig_type":0,
"dpd":1,
"proposals":[
{
"encryption":"AES128",
"authentication":"SHA256"
},
{
"encryption":"AES256",
"authentication":"SHA256"
}
],
"run_fcauth_system":0,
"failover_sslvpn_connection":"",
"sso_enabled":0,
"use_external_browser":0,
"ike_saml_port":443,
"keep_fqdn_resolution_consistency":0,
"no_vnic_dns_server":0,
"azure_auto_login":{
"enabled":0,
"azure_app":{
"tenant_name":"",
"client_id":""
}
},
"fgt":1,
"dpd_retry_count":3,
"dpd_retry_interval":20,
"certificate":null,
"nat_alive_freq":10
},
"ipsec_settings":{
"remote_networks":[
{
"addr":"0.0.0.0",
"mask":"0.0.0.0"
},
{
"addr":"::/0",
"mask":"::/0"
}
],
"dhgroup":31,
"key_life_type":"seconds",
"key_life_seconds":3600,
"key_life_Kbytes":5200,
"replay_detection":1,
"pfs":1,
"virtualip":{
"type":"modeconfig",
"ip":"0.0.0.0",
"mask":"0.0.0.0",
"dnsserver":"0.0.0.0",
"winserver":"0.0.0.0"
},
"proposals":[
{
"encryption":"AES256GCM",
"authentication":"NONE"
},
{
"encryption":"AES256",
"authentication":"SHA512"
}
],
"ipv4_split_exclude_networks":[
],
"use_vip":1
},
"on_connect":[
{
"os":"windows",
"script":""
},
{
"os":"MacOSX",
"script":""
}
],
"on_disconnect":[
{
"os":"windows",
"script":""
},
{
"os":"MacOSX",
"script":""
}
],
"android_cert_path":"",
"uid":"B0A6E39F-F101-46DC-B94E-2F42A375ECF7",
"warn_invalid_server_certificate":1,
"type":"manual"
}
],
"options":{
"disable_default_route":0,
"block_ipv6":1,
"use_win_local_computer_cert":1,
"check_for_cert_private_key":0,
"mtu_size":1280,
"usesmcardcert":1,
"beep_if_error":0,
"enhanced_key_usage_mandatory":0,
"no_dns_registration":0,
"show_auth_cert_only":0,
"disallow_invalid_server_certificate":0,
"prefer_ipsecvpn_dns":1,
"use_gui_saml_auth":0,
"use_win_current_user_cert":1,
"enable_udp_checksum":0,
"usewincert":1,
"enabled":1
}
},
"enabled":1,
"lockdown":{
"grace_period":120,
"max_attempts":3,
"detect_captive_portal":{
"enabled":0,
"os_active_probing":1
},
"exceptions":{
"domains":[
],
"ips":[
],
"apps":[
],
"icdb_domains":[
]
},
"enabled":0
},
"options":{
"after_logon_saml_auth":0,
"temp_password":"Start123$",
"enable_view_selected_vpns":0,
"minimize_window_on_connect":1,
"enable_multi_vpn":0,
"autoconnect_tunnel":"",
"show_vpn_before_logon":1,
"on_os_start_connect":"",
"secure_remote_access":0,
"current_connection_type":"",
"certs_require_keyspec":0,
"disable_internet_check":1,
"autoconnect_on_install":0,
"allow_personal_vpns":0,
"disconnect_password":"",
"vendor_id":"",
"use_windows_credentials":0,
"autoconnect_only_when_offnet":0,
"current_connection_name":"",
"before_logon_saml_auth":1,
"disable_connect_disconnect":0,
"on_os_start_connect_has_priority":0,
"suppress_vpn_notification":0,
"use_webview2_saml_auth":0,
"keep_running_max_tries":0,
"enforce_disabling_smartdns":0
},
"display_vpn":1
}
},
"is_chromebook":false,
"is_default":false,
"is_sase":false,
"update_time":"2026-06-28T14:13:37.294",
"enabled":true,
"display_enabled":true,
"parser_error":null,
"type":5
},
"system_component":{
"id":70,
"name":"SYS_EMS-API",
"json":{
"fssoma":{
"enabled":0,
"presharedkey":"",
"serveraddress":""
},
"version":"5.6.0",
"pam":{
"enabled":0,
"default_port":9191
},
"endpoint_control":{
"forensics_license":1,
"enable_dns_cache":0,
"enable_dem":0,
"send_software_inventory":0,
"disable_fgt_switch":0,
"auto_start":0,
"disable_unregister":1,
"notify_fgt_on_logoff":0,
"invalid_cert_action":"warn",
"ui":{
"hide_compliance_warning":0
},
"show_bubble_notifications":0,
"edr_collector":1
},
"system":{
"certificates":[
],
"cryptography":{
"drbg_reseed_minutes":1440
},
"update":{
"timeout":60,
"auto_patch":0,
"fail_over_to_fdn":0,
"restrict_services_to_regions":"",
"scheduled_update":{
"type":"interval",
"enabled":1,
"daily_at":"00:00",
"update_interval_in_hours":1
},
"submit_virus_info_to_fds":1,
"port":80,
"update_action":"disable",
"failoverport":8000,
"use_legacy_fdn":0,
"ocsp_mode":0,
"submit_vuln_info_to_fds":1,
"server":"",
"use_custom_server":0
},
"proxy":{
"username":null,
"update":0,
"fail_over_to_fdn":0,
"port":80,
"virus_submission":0,
"type":"http",
"password":"",
"address":null,
"online_scep":0
},
"installer":{
"allow_admin_uninstall_when_locked":1
},
"proc_protect":1,
"user_identity":{
"enable_google":0,
"enable_linkedin":0,
"enable_manually_entering":0,
"notify_user":0,
"enable_salesforce":0
},
"log_settings":{
"onnet_local_logging":1,
"level":6,
"log_events":"antiexploit,antiransomware,av,cloudscan,endpoint,firewall,fssoma,ipsecvpn,pam,sandboxing,sslvpn,update,vuln,webfilter,ztna,configd,scheduler,shield,wanacc",
"remote_logging":{
"send_os_events":{
"enabled":1,
"interval":120
},
"log_upload_enabled":0,
"log_retention_days":90,
"log_upload_ssl_enabled":1,
"send_software_inventory":0,
"log_upload_server":"",
"log_generation_timeout_secs":900,
"netlog_categories":32,
"log_upload_freq_minutes":60,
"log_compressed":0
}
},
"fortiproxy":{
"http_timeout":60,
"selftest":{
"notify":1,
"enabled":1,
"last_port":65535
},
"client_comforting":{
"pop3_client":1,
"pop3_server":1,
"smtp":1
},
"enable_https_proxy":1,
"enabled":1
},
"ui":{
"allow_shutdown_when_registered":0,
"disable_backup":0,
"unreg_pwd":"goBsfMBfsTRbI3732fJADZ2gJEqSFB7zeBKnNb2U3WRDVgKla5vP6XG4xPpK85mDIpq2xSvSfyagMMdQ7Hl73WiOMntt5LdOOgUvUi0MT8Oq74vif2XsiFd3tf57SFV7$TQJ1qTiQelcT/qoHkN4laeQI6jvKLeoe+1WevZOq9oBktCLyBB4goInKHqF9jiPGDNhrT7rToVWNEXKeqF8myQ==",
"lock":"Enc 283431078f7ac0acbb5fd257d3279947f5cd01bbe523458b9d50b1a1d5a8bf373b784abec45318e4646034cf13d32e00df29a194c3a247b9db53f6e768362e37239b7005d02266424b0efd63aac9c86669a86c0a0368caa7",
"password":"Enc 68be3193bbd992ab167dd677ba394598d6c827dcedc1d91d842296f9fcfb9cc97b6d62fc1e38124e0b1ea9dc2233665e1a76f192808a1f09",
"culture_code":"os-default",
"hide_user_info":0,
"default_tab":"VPN",
"hide_system_tray_icon":0,
"show_host_tag":0
}
},
"wan_optimization":{
"support_cifs":1,
"support_http":1,
"support_ftp":1,
"enabled":0,
"support_mapi":1,
"max_disk_cache_size_mb":512
},
"extra":{
"trigger_vuln_scan":true
}
},
"is_chromebook":false,
"is_default":false,
"is_sase":false,
"update_time":"2026-06-28T14:13:37.243",
"enabled":true,
"display_enabled":true,
"parser_error":null,
"type":7
},
"assembly_error_type":0,
"assembly_state":2,
"assembly_progress":100,
"hotfix_name":null,
"hotfix_details":[
],
"auto_update":null
}
}
Creating an invitation
If you work with Cloud EMS, note that the API endpoints use /cloud/invitations instead of the on-prem /api/v1/invitation
Invitations are relatively straightforward, and the only thing of note is that if you want to have an installer attached, you need to create it with the invitation. You cannot create an invitation and later attach an installer.
Create invitation
'''
ems_create_invitation.py
Create invitation with domain verification and installer using the FortiClient EMS API
'''
import json
import requests
#Disable warnings
requests.urllib3.disable_warnings()
#Set credentials
username = 'apiadmin'
password = 'Start123$'
#Set some variables for the API
ems_server = '192.168.1.208'
version_major_minor = "7.4"
installer_name = "7.4.7"
fct_comparable = 7004007
system_profile_name = "SYS_EMS-API"
system_profile_id = 0
vpn_profile_name = "VPN_EMS-API"
vpn_profile_id = 0
idp_name = "ad.labdomain.com"
idp_id = 0
#Set all used URLs
api_url_prefix = f'https://{ems_server}/api/v1'
login_url = f'{api_url_prefix}/auth/signin'
logout_url = f'{api_url_prefix}/auth/signout'
invitation_url = f'{api_url_prefix}/invitation/create'
system_profiles_get_url = f'{api_url_prefix}/profiles/system/index'
vpn_profiles_get_url = f'{api_url_prefix}/profiles/vpn/index'
idps_url = f'{api_url_prefix}/idps/index'
#Variables for data and headers
auth_data = {"name": f"{username}", "password": f"{password}"}
api_headers = {"Content-type": "application/json"}
#Setup session, login to EMS, and set new headers with CSRF token and referer
session = requests.Session()
login_response = session.post(url=login_url, json=auth_data, headers=api_headers, verify=False, timeout=30)
change_headers = {"Content-type": "application/json", "Referer": f"https://{ems_server}", "X-CSRFToken": f"{session.cookies["csrftoken"]}"}
#Get system profiles
response = session.get(url=system_profiles_get_url, headers=api_headers, verify=False, timeout=30)
response_decoded = json.loads(response.content.decode('utf-8'))
for profile in response_decoded['data']['local']:
if profile['name'] == system_profile_name:
system_profile_id = {profile["id"]}
#The id is a set, so we convert it to a list and get the only element from it
system_profile_id = list(system_profile_id)[0]
#Get VPN profiles
response = session.get(url=vpn_profiles_get_url, headers=api_headers, verify=False, timeout=30)
response_decoded = json.loads(response.content.decode('utf-8'))
for profile in response_decoded['data']['local']:
if profile['name'] == vpn_profile_name:
vpn_profile_id = {profile["id"]}
#The id is a set, so we convert it to a list and get the only element from it
vpn_profile_id = list(vpn_profile_id)[0]
#Get authentication servers data
response = session.get(url=idps_url, headers=api_headers, verify=False, timeout=30)
response_decoded = json.loads(response.content.decode('utf-8'))
#Get all the necessary information directly from the IDP and create the data for the domain import
for idp in response_decoded['data']:
if idp['domain_info']['name'] == idp_name:
idp_id = idp['domain_info']['guid']
#Use None for expiry_date to have no expiration
#The mapping for the authentication type is 0=None, 1=Local, 2=Domain, 3=SAML
invitation_data = {
"name":"API-INVITATION-INSTALLER",
"comments":"Invitation created via API",
"has_email_notifications":False,
"expiry_date":"2026-12-31",
"authentication_type":2,
"email_template_id":1,
"is_bulk":True,
"listen_address":f"{ems_server}:8013",
"assignable_installer":{
"name":"INVITATION-INSTALLER",
"notes":"FortiClient invitation installer created via API",
"version_major_minor":version_major_minor,
"auto_update":None,
"installer_name":installer_name,
"fct_comparable":fct_comparable,
"windows_installer":True,
"mac_installer":True,
"linux_installer":True,
"windows_arm_installer":False,
"linux_arm_installer":False ,
"features":[
5, #Zero Trust Telemetry
3, #Secure Access Architecture Components
7, #Vulnerability Scan
6, #Advanced Persistent Threat (APT) Components
1, #AntiVirus, Anti-Exploit, Removable Media Access
10, #Anti-Ransomware
9, #Cloud Based Malware Outbreak Detection
2, #Web and Video Filtering
4, #Application Firewall
8, #Single Sign-On Mobility Agent
11, #Zero Trust Network Access
13, #Privileged Access Agent
16, #Data Protection
12#, #FIPS Certification
#15, #EDR, only in cloud EMS, remove if on-prem EMS
],
"auto_register":True,
"desktop_shortcut":True,
"start_menu_shortcut":False,
"msi_files":True,
"override_invitation_code":False,
"group_assignment_rules_id":None,
"vpn_profile_component_id":vpn_profile_id,
"system_profile_component_id":system_profile_id,
"invalid_cert_action":None,
"telemetry_server_list_id":None
},
"domain_guid":idp_id,
"user_id":None,
"saml_config_id":None
}
#Create invitation
session.post(url=invitation_url, json=invitation_data, headers=change_headers, verify=False, timeout=30)
#Perform a logout
session.post(url=logout_url, headers=change_headers, verify=False, timeout=30)
Create invitation response
{
"result":{
"retval":1,
"message":null
},
"data":{
"id":7,
"uid":"6f868940-eca1-4880-8ac0-21f8ba350158",
"name":"API-INVITATION-INSTALLER",
"invitation_code":"_VjE6MTkyLjE2OC4xLjIwODo4MDEzOmRlZmF1bHQ6NmY4Njg5NDAtZWNhMS00ODgwLThhYzAtMjFmOGJhMzUwMTU4"
}
}
Updating an invitation
This process is pointless. Apparently, you can only change the name, EMS listen address, and comment of an existing invitation, even if you do update it. You can send more information, like changing the Verification Type, but it doesn’t actually update it. There is an update script in the repository, but I don’t bother posting one here.
Getting endpoint data and matching on the endpoint name
Probably the most fundamental API call and it’s almost at the end.
The script gets all endpoint data using the API and performs a match on a specific endpoint using the endpoint’s name.
GET endpoint data and match on endpoint name
'''
ems_get_endpoint_data.py
Get endpoint data and ID for named endpoint using the FortiClient EMS API
'''
import json
import requests
#Set credentials
username = 'apiadmin'
password = 'Start123$'
#Disable warnings
requests.urllib3.disable_warnings()
#Set some variables for the API
ems_server = '192.168.1.208'
endpoint_name = "WIN11-CLIENT"
#Set all used URLs
api_url_prefix = f'https://{ems_server}/api/v1'
login_url = f'{api_url_prefix}/auth/signin'
logout_url = f'{api_url_prefix}/auth/signout'
endpoints_url = f'{api_url_prefix}/endpoints/index'
#Variables for data and headers
auth_data = {"name": f"{username}", "password": f"{password}"}
api_headers = {"Content-type": "application/json"}
#Setup session, login to EMS, and set new headers with CSRF token and referer
session = requests.Session()
login_response = session.post(url=login_url, json=auth_data, headers=api_headers, verify=False, timeout=30)
change_headers = {"Content-type": "application/json", "Referer": f"https://{ems_server}", "X-CSRFToken": f"{session.cookies["csrftoken"]}"}
#Get endpoint data
response = session.get(url=endpoints_url, headers=api_headers, verify=False, timeout=30)
response_decoded = json.loads(response.content.decode('utf-8'))
print(response_decoded)
#Get endpoint ID with name match
for endpoint in response_decoded['data']['endpoints']:
if endpoint['name'] == endpoint_name:
print(f"Endpoint ID for {endpoint_name}: {endpoint['device_id']}")
#Perform a logout
session.post(url=logout_url, headers=change_headers, verify=False, timeout=30)
GET endpoint data and match on endpoint name response
{
"result":{
"retval":1,
"message":"None"
},
"data":{
"endpoints":[
{
"device_id":55,
"host":"FCXLAB",
"name":"FCXLAB",
"ip_addr":"None",
"os_version":"Microsoft Windows 11 Pro",
"model":"",
"vendor":"",
"cpu":"",
"memory":0,
"sn":"",
"hdd":"None",
"public_ip_addr":"None",
"domain_id":2,
"installer_name":"None",
"deployment_state":"None",
"fdc_campaign_deployment_status":0,
"fgt_sn":"None",
"forticlient_id":"None",
"uid":"None",
"fct_version":"None",
"diskenc":"None",
"av_product":"None",
"is_installed":false,
"is_managed":false,
"is_migrating":false,
"is_ems_registered":"None",
"can_quarantine":"None",
"is_ems_online":false,
"is_ems_onnet":"None",
"is_excluded":false,
"is_quarantined":0,
"quarantine_access_code":"None",
"invitation_name":"None",
"invitation_code":"None",
"invitation_version":"None",
"comparable_fct_version":"None",
"last_seen":"None",
"last_seen_fct_user_id":"None",
"endpoint_policy_name":"None",
"endpoint_policy_id":"None",
"ip_list_name":"None",
"orig_groups":[
],
"av_enabled":"None",
"rtp_enabled":"None",
"ae_enabled":"None",
"cs_enabled":"None",
"rm_enabled":"None",
"fw_enabled":"None",
"wf_enabled":"None",
"vf_enabled":"None",
"vpn_enabled":"None",
"vuln_enabled":"None",
"ssoma_enabled":"None",
"sb_enabled":"None",
"sb_cloud_enabled":"None",
"fd_enabled":"None",
"rs_enabled":"None",
"edr_installed":"None",
"edr_app_enabled":"None",
"edr_feature_enabled":"None",
"onboarding_supported":"None",
"client_version_up_to_date":true,
"client_av_sig_version_up_to_date":true,
"client_policy_synced":true,
"client_policy_primary_synced":true,
"client_policy_offnet_synced":true,
"client_policy_iplist_synced":true,
"client_policy_onnet_rule_synced":true,
"client_policy_verification_rule_synced":true,
"sys_events_count":0,
"av_events_count":0,
"wf_events_count":0,
"vf_events_count":0,
"fw_events_count":0,
"sb_events_count":0,
"fd_events_count":0,
"ae_events_count":0,
"rm_events_count":0,
"cs_events_count":0,
"unreg_events_count":0,
"rs_events_count":0,
"nwifsc_events_count":0,
"owmsg_events_count":0,
"reg_events_count":0,
"ztna_sign_events_count":0,
"ztna_revoke_events_count":0,
"pam_events_count":0,
"vcm_events_count":0,
"vpn_events_count":0,
"vuln_events_count":0,
"pua_events_count":0,
"vuln_events_max_severity":"None",
"fdc_events_count":0,
"forensics_enabled":false,
"groups":[
{
"group_id":195,
"group_name":"CLIENTS",
"group_path":"ad.labdomain.com/CLIENTS"
}
],
"profile_components":{
},
"off_net_profile_components":{
},
"health_warning_count":0,
"health_error_count":0
},
{
"device_id":1,
"host":"WIN11-CLIENT",
"name":"WIN11-CLIENT",
"ip_addr":"192.168.1.231",
"os_version":"Microsoft Windows 11 Professional Edition, 64-bit (build 26200)",
"model":"VMware20,1",
"vendor":"VMware, Inc.",
"cpu":"Intel(R) Core(TM) i7-14700K",
"memory":8190,
"sn":"VMware-56 4d e8 7f a0 41 62 67-9d a7 24 c8 bb e7 ff 97",
"hdd":63,
"public_ip_addr":"",
"domain_id":2,
"installer_name":"None",
"deployment_state":"None",
"fdc_campaign_deployment_status":"None",
"fgt_sn":"None",
"forticlient_id":1,
"uid":"6E23E7C3ABBF4FE797BB811A42F0A6F3",
"fct_version":"7.4.7.2003",
"diskenc":"",
"av_product":"Antivirus Microsoft Defender",
"is_installed":true,
"is_managed":true,
"is_migrating":false,
"is_ems_registered":true,
"can_quarantine":true,
"is_ems_online":true,
"is_ems_onnet":false,
"is_excluded":false,
"is_quarantined":0,
"quarantine_access_code":"None",
"invitation_name":"None",
"invitation_code":"None",
"invitation_version":"None",
"comparable_fct_version":7004007,
"last_seen":"2026-06-29T16:20:38",
"last_seen_fct_user_id":35,
"endpoint_policy_name":"POLICY_API",
"endpoint_policy_id":53,
"ip_list_name":"None",
"orig_groups":[
],
"av_enabled":false,
"rtp_enabled":false,
"ae_enabled":false,
"cs_enabled":false,
"rm_enabled":false,
"fw_enabled":false,
"wf_enabled":false,
"vf_enabled":false,
"vpn_enabled":true,
"vuln_enabled":true,
"ssoma_enabled":false,
"sb_enabled":false,
"sb_cloud_enabled":false,
"fd_enabled":false,
"rs_enabled":false,
"edr_installed":false,
"edr_app_enabled":false,
"edr_feature_enabled":false,
"onboarding_supported":true,
"client_version_up_to_date":true,
"client_av_sig_version_up_to_date":true,
"client_policy_synced":true,
"client_policy_primary_synced":true,
"client_policy_offnet_synced":true,
"client_policy_iplist_synced":true,
"client_policy_onnet_rule_synced":true,
"client_policy_verification_rule_synced":true,
"sys_events_count":110,
"av_events_count":0,
"wf_events_count":0,
"vf_events_count":0,
"fw_events_count":0,
"sb_events_count":0,
"fd_events_count":0,
"ae_events_count":0,
"rm_events_count":0,
"cs_events_count":0,
"unreg_events_count":0,
"rs_events_count":0,
"nwifsc_events_count":101,
"owmsg_events_count":0,
"reg_events_count":0,
"ztna_sign_events_count":0,
"ztna_revoke_events_count":1,
"pam_events_count":0,
"vcm_events_count":0,
"vpn_events_count":0,
"vuln_events_count":1,
"pua_events_count":0,
"vuln_events_max_severity":7.800000190734863,
"fdc_events_count":0,
"forensics_enabled":false,
"groups":[
{
"group_id":195,
"group_name":"CLIENTS",
"group_path":"ad.labdomain.com/CLIENTS"
}
],
"profile_components":{
"malware":{
"id":1,
"name":"Default"
},
"sandbox":{
"id":1,
"name":"Default"
},
"webfilter":{
"id":1,
"name":"Default",
"fp_name":"None"
},
"firewall":{
"id":1,
"name":"Default"
},
"vpn":{
"id":1,
"name":"Default"
},
"vulnerability_scan":{
"id":1,
"name":"Default"
},
"system":{
"id":70,
"name":"SYS_EMS-API"
},
"ztna":{
"id":1,
"name":"Default"
},
"videofilter":{
"id":1,
"name":"Default"
},
"ftdata_scan":{
"id":1,
"name":"Default"
}
},
"off_net_profile_components":{
"malware":{
"id":1,
"name":"Default"
},
"sandbox":{
"id":1,
"name":"Default"
},
"webfilter":{
"id":1,
"name":"Default",
"fp_name":"None"
},
"firewall":{
"id":1,
"name":"Default"
},
"vpn":{
"id":1,
"name":"Default"
},
"vulnerability_scan":{
"id":1,
"name":"Default"
},
"system":{
"id":70,
"name":"SYS_EMS-API"
},
"ztna":{
"id":1,
"name":"Default"
},
"videofilter":{
"id":1,
"name":"Default"
},
"ftdata_scan":{
"id":1,
"name":"Default"
}
},
"health_warning_count":1,
"health_error_count":0,
"fct_users":[
{
"auth_user_name":"None",
"machine_user_name":"labuser",
"auth_user_id":"None",
"machine_user_id":105,
"auth_domain":"None",
"machine_domain":"ad.labdomain.com",
"avatar":"None",
"fct_user_id":35,
"client_id":1,
"last_seen":"2026-06-29T16:20:38",
"is_authenticated":true,
"is_latest":true,
"display_name":"labuser",
"user_email":"labuser@ad.labdomain.com",
"user_phone":"None",
"row_no":1
},
{
"auth_user_name":"None",
"machine_user_name":"adkevin",
"auth_user_id":"None",
"machine_user_id":35,
"auth_domain":"None",
"machine_domain":"ad.labdomain.com",
"avatar":"None",
"fct_user_id":34,
"client_id":1,
"last_seen":"2026-06-28T12:17:49",
"is_authenticated":true,
"is_latest":false,
"display_name":"adkevin",
"user_email":"labuser@ad.labdomain.com",
"user_phone":"None",
"row_no":2
},
{
"auth_user_name":"None",
"machine_user_name":"adkevin",
"auth_user_id":"None",
"machine_user_id":2,
"auth_domain":"None",
"machine_domain":"ad.labdomain.com",
"avatar":"None",
"fct_user_id":1,
"client_id":1,
"last_seen":"2026-06-19T08:38:38",
"is_authenticated":false,
"is_latest":false,
"display_name":"adkevin",
"user_email":"labuser@ad.labdomain.com",
"user_phone":"",
"row_no":3
}
]
},
{
"device_id":56,
"host":"WIN-SERVER",
"name":"WIN-SERVER",
"ip_addr":"None",
"os_version":"Microsoft Windows Server 2022 Datacenter Evaluation",
"model":"",
"vendor":"",
"cpu":"",
"memory":0,
"sn":"",
"hdd":"None",
"public_ip_addr":"None",
"domain_id":2,
"installer_name":"None",
"deployment_state":"None",
"fdc_campaign_deployment_status":0,
"fgt_sn":"None",
"forticlient_id":"None",
"uid":"None",
"fct_version":"None",
"diskenc":"None",
"av_product":"None",
"is_installed":false,
"is_managed":false,
"is_migrating":false,
"is_ems_registered":"None",
"can_quarantine":"None",
"is_ems_online":false,
"is_ems_onnet":"None",
"is_excluded":false,
"is_quarantined":0,
"quarantine_access_code":"None",
"invitation_name":"None",
"invitation_code":"None",
"invitation_version":"None",
"comparable_fct_version":"None",
"last_seen":"None",
"last_seen_fct_user_id":"None",
"endpoint_policy_name":"None",
"endpoint_policy_id":"None",
"ip_list_name":"None",
"orig_groups":[
],
"av_enabled":"None",
"rtp_enabled":"None",
"ae_enabled":"None",
"cs_enabled":"None",
"rm_enabled":"None",
"fw_enabled":"None",
"wf_enabled":"None",
"vf_enabled":"None",
"vpn_enabled":"None",
"vuln_enabled":"None",
"ssoma_enabled":"None",
"sb_enabled":"None",
"sb_cloud_enabled":"None",
"fd_enabled":"None",
"rs_enabled":"None",
"edr_installed":"None",
"edr_app_enabled":"None",
"edr_feature_enabled":"None",
"onboarding_supported":"None",
"client_version_up_to_date":true,
"client_av_sig_version_up_to_date":true,
"client_policy_synced":true,
"client_policy_primary_synced":true,
"client_policy_offnet_synced":true,
"client_policy_iplist_synced":true,
"client_policy_onnet_rule_synced":true,
"client_policy_verification_rule_synced":true,
"sys_events_count":0,
"av_events_count":0,
"wf_events_count":0,
"vf_events_count":0,
"fw_events_count":0,
"sb_events_count":0,
"fd_events_count":0,
"ae_events_count":0,
"rm_events_count":0,
"cs_events_count":0,
"unreg_events_count":0,
"rs_events_count":0,
"nwifsc_events_count":0,
"owmsg_events_count":0,
"reg_events_count":0,
"ztna_sign_events_count":0,
"ztna_revoke_events_count":0,
"pam_events_count":0,
"vcm_events_count":0,
"vpn_events_count":0,
"vuln_events_count":0,
"pua_events_count":0,
"vuln_events_max_severity":"None",
"fdc_events_count":0,
"forensics_enabled":false,
"groups":[
{
"group_id":198,
"group_name":"SERVERS",
"group_path":"ad.labdomain.com/SERVERS"
}
],
"profile_components":{
},
"off_net_profile_components":{
},
"health_warning_count":0,
"health_error_count":0
}
],
"total":3
}
}
Getting endpoint(s) of a named user
If you want to get all endpoints where a user is recorded as the last seen user, you can use this script.
GET endpoint of named user
'''
ems_get_named_user_endpoint.py
Get endpoint for named user using the FortiClient EMS API
'''
import json
import requests
#Disable warnings
requests.urllib3.disable_warnings()
#Set credentials
username = 'apiadmin'
password = 'Start123$'
#Set some variables for the API
ems_server = '192.168.1.208'
user_name = "labuser"
user_id = [] #This is a list, because a user can be associated with multiple endpoints
#Set all used URLs
api_url_prefix = f'https://{ems_server}/api/v1'
login_url = f'{api_url_prefix}/auth/signin'
logout_url = f'{api_url_prefix}/auth/signout'
endpoints_url = f'{api_url_prefix}/endpoints/index'
#Variables for data and headers
auth_data = {"name": f"{username}", "password": f"{password}"}
api_headers = {"Content-type": "application/json"}
#Setup session, login to EMS, and set new headers with CSRF token and referer
session = requests.Session()
login_response = session.post(url=login_url, json=auth_data, headers=api_headers, verify=False, timeout=30)
change_headers = {"Content-type": "application/json", "Referer": f"https://{ems_server}", "X-CSRFToken": f"{session.cookies["csrftoken"]}"}
#Get endpoint data
response = session.get(url=endpoints_url, headers=api_headers, verify=False, timeout=30)
response_decoded = json.loads(response.content.decode('utf-8'))
#Get user FortiClient ID with name match, then get endpoint ID with user ID match
for endpoint in response_decoded['data']['endpoints']:
#This try exists, because fct_users is not available on an endpoint with no last seen user
try:
for fct_user in endpoint['fct_users']:
if fct_user['machine_user_name'] == user_name:
user_id.append(fct_user['fct_user_id'])
except KeyError:
continue
if endpoint['last_seen_fct_user_id'] in user_id:
print(f"{user_name} is a last seen user on endpoint {endpoint['name']}, which has ID {endpoint['device_id']}.")
else:
print(f"{user_name} does not appear in the last seen users of any endpoint.")
#Perform a logout
session.post(url=logout_url, headers=change_headers, verify=False, timeout=30)
Deregistering endpoint(s) by name or ID
If you want to deregister an endpoint because this endpoint should no longer use a license, for example, the following script does just that.
You can add known IDs to the deregister_list or match on the name of one or multiple endpoints using the endpoint_names_list
Deregister endpoint(s) by name or ID
'''
ems_deregister_endpoints.py
Deregister named endpoint(s) or IDs using the FortiClient EMS API
'''
import json
import requests
#Set credentials
username = 'apiadmin'
password = 'Start123$'
#Disable warnings
requests.urllib3.disable_warnings()
#Set some variables for the API
ems_server = '192.168.1.208'
#Add the names of endpoints to this list
endpoint_names_list = ["WIN11-CLIENT", "FCXLAB"]
#Add known endpoint IDs you want to deregister to this list
deregister_list = []
#Set all used URLs
api_url_prefix = f'https://{ems_server}/api/v1'
login_url = f'{api_url_prefix}/auth/signin'
logout_url = f'{api_url_prefix}/auth/signout'
endpoints_url = f'{api_url_prefix}/endpoints/index'
deregister_url = f'{api_url_prefix}/clients/deregister'
#Variables for data and headers
auth_data = {"name": f"{username}", "password": f"{password}"}
api_headers = {"Content-type": "application/json"}
deregister_data = {}
#Setup session, login to EMS, and set new headers with CSRF token and referer
session = requests.Session()
login_response = session.post(url=login_url, json=auth_data, headers=api_headers, verify=False, timeout=30)
change_headers = {"Content-type": "application/json", "Referer": f"https://{ems_server}", "X-CSRFToken": f"{session.cookies["csrftoken"]}"}
response = session.get(url=endpoints_url, headers=api_headers, verify=False, timeout=30)
response_decoded = json.loads(response.content.decode('utf-8'))
#Get endpoint ID with name match and add to deregister list, then format data for API call
for endpoint in response_decoded['data']['endpoints']:
if endpoint['name'] in endpoint_names_list:
deregister_list.append(endpoint['device_id'])
deregister_data = {"ids": deregister_list}
#Deregister endpoint(s)
session.post(url=deregister_url, json=deregister_data, headers=change_headers, verify=False, timeout=30)
#Perform a logout
session.post(url=logout_url, headers=change_headers, verify=False, timeout=30)
Deregister endpoint(s) by name or ID response
{
"result":{
"retval":1,
"message":"Successfully deregistered 2 endpoint(s)."
}
}
Deregister endpoint(s) by user name
If you want to deregister all endpoints where a user name is in the last seen users, use this script.
Deregister endpoint(s) by user name
'''
ems_deregister_endpoint_named_user.py
Deregister endpoint using a user name, which is also the last seen user, on the endpoint using the FortiClient EMS API
'''
import json
import requests
#Disable warnings
requests.urllib3.disable_warnings()
#Set credentials
username = 'apiadmin'
password = 'Start123$'
#Set some variables for the API
ems_server = '192.168.1.208'
user_name = "adkevin"
user_id = [] #This is a list, because a user can be associated with multiple endpoints
deregister_list = []
#Set all used URLs
api_url_prefix = f'https://{ems_server}/api/v1'
login_url = f'{api_url_prefix}/auth/signin'
logout_url = f'{api_url_prefix}/auth/signout'
endpoints_url = f'{api_url_prefix}/endpoints/index'
deregister_url = f'{api_url_prefix}/clients/deregister'
#Variables for data and headers
auth_data = {"name": f"{username}", "password": f"{password}"}
api_headers = {"Content-type": "application/json"}
deregister_data = {}
#Setup session, login to EMS, and set new headers with CSRF token and referer
session = requests.Session()
login_response = session.post(url=login_url, json=auth_data, headers=api_headers, verify=False, timeout=30)
change_headers = {"Content-type": "application/json", "Referer": f"https://{ems_server}", "X-CSRFToken": f"{session.cookies["csrftoken"]}"}
response = session.get(url=endpoints_url, headers=api_headers, verify=False, timeout=30)
response_decoded = json.loads(response.content.decode('utf-8'))
#Get user FortiClient ID with name match, then get endpoint ID with user ID match
for endpoint in response_decoded['data']['endpoints']:
#If there is no recorded user on an endpoint the fct_users key does not exist
try:
for fct_user in endpoint['fct_users']:
if fct_user['machine_user_name'] == user_name:
#All user IDs get added to a list, because the ID can be associated with multiple endpoints
user_id.append(fct_user['fct_user_id'])
#Only if the user ID is the last seen user on the endpoint, the endpoint will be deregistered
if endpoint['last_seen_fct_user_id'] in user_id:
deregister_list.append(endpoint['device_id'])
deregister_data = {"ids": deregister_list}
except KeyError:
continue
#Deregister endpoints that have the user name as the last seen user, which can be multiple
response = session.post(url=deregister_url, json=deregister_data, headers=change_headers, verify=False, timeout=30)
response_decoded = response.content.decode('utf-8')
print(response_decoded)
#Perform a logout
session.post(url=logout_url, headers=change_headers, verify=False, timeout=30)
Deregister endpoint(s) by user name response
{
"result":{
"retval":1,
"message":"Successfully deregistered 2 endpoint(s)."
}
}
What about EMS Settings?
After all this, one topic that is conspicuous by its absence is the EMS settings, i.e. the listen address, keepalives, license timeouts, etc.
You can actually set these things using the API with the /api/v1/settings/server/set using a PATCH, but it doesn’t do anything, despite the response stating “Settings updated successfully”.
Set EMS Settings
'''
ems_set_server_settings.py
Set server data using the FortiClient EMS API
'''
import requests
#Disable warnings
requests.urllib3.disable_warnings()
#Set credentials
username = 'apiadmin'
password = 'Start123$'
#Set some variables for the API
ems_server = '192.168.1.208'
#Set all used URLs
api_url_prefix = f'https://{ems_server}/api/v1'
login_url = f'{api_url_prefix}/auth/signin'
logout_url = f'{api_url_prefix}/auth/signout'
server_settings_url = f'{api_url_prefix}/settings/server/set'
#Variables for data and headers
auth_data = {"name": f"{username}", "password": f"{password}"}
api_headers = {"Content-type": "application/json"}
server_data = {
"chromebooks": {
"enabled": False,
"inactivity_timeout": 24,
"update_interval": 300,
"is_licensed": True,
"service_account": "account-1@forticlientwebfilter.iam.gserviceaccount.com",
"global_enabled": False,
"listen_port": 8443,
"ssl_name": "FCTEMSSERIAL.1.cert",
"ssl_date": "2056-05-26 20:48:33",
"chromebook_cert_id": 35,
"acme_auto_renew": False
},
"scheduledBackup": {
"scheduled_backup_enabled": False,
"scheduled_backup_type": 1,
"scheduled_backup_interval": 1,
"scheduled_backup_start_time": "20:00",
"scheduled_backup_protocol": 2,
"scheduled_backup_remote_server_ip": None,
"scheduled_backup_selected_days": [
"1",
"4"
],
"scheduled_backup_password": None,
"scheduled_backup_compress_type": "database",
"scheduled_backup_path": "/home/ems/exchange",
"scheduled_backup_server_type": "local",
"scheduled_backup_remote_user": None,
"scheduled_backup_remote_user_password": None,
"scheduled_backup_retention_period": 15
},
"reset_deployment_interval": 12,
"sws_enabled": False,
"sws_server": None,
"sws_cert_name": None,
"sws_cert_date": None,
"inv_only_reg_enforcement_type": 0,
"pwd_changed_check_enforced": False,
"onboarding_enforced": False,
"user_auth_period": None,
"fct_repackager_upload_region": "Europe",
"endpoints": {
"key": None,
"keep_alive_interval": 30,
"offline_timeout": 15,
"tag_timeout": 1440,
"delete_timeout": 30,
"license_timeout": 0,
"duplicate_onboarded_user_timeout": 7,
"unauthed_user_timeout": 30,
"password_lockout_attempt": 3,
"password_lockout_period": 60,
"ztna_token_support": True,
"ztna_token_timeout": 1440,
"avatar_upload_enabled": False,
"snapshot_interval": None
},
"unauthed_fct_count": 1,
"unsupported_fct_count": 0,
"telemetry": {
"show_fortigate_server_list": False
},
"ztna_cert_date_created": "2026-06-19T08:11:54.217",
"ztna_cert_expiry_date": "2051-06-13T08:11:54.217",
"ztna_cert_name": "default_ZTNARootCA.pem",
"is_custom_ztna_cert": False,
"custom_hostname": "",
"public_address": "ems.ad.labdomain.com",
"public_port": 443,
"https_enabled": True,
"https_redirect_enabled": True,
"ssl_from_forti_care": False,
"custom_ec_cert": True,
"enable_persistent_connection": True,
"installer_port_enabled": True,
"fos_notify_server_port": 8015,
"webserver_cert_id": 36,
"ec_cert_id": 36,
"auto_upgrade_enabled": True,
"hostname": "EMS",
"is_ip_invalid": False,
"listen_port": 8013,
"fqdn_enabled": True,
"fqdn": "ems.ad.labdomain.com",
"installer_ip": "fcems-server",
"show_fortigate_server_list": False,
"password_lockout_attempt": 3,
"password_lockout_period": 60,
"ha_alert_interval": 60,
"acme_auto_renew": False,
"predefined_hostname": "*,192.168.1.208",
"https_port": 443,
"sites_enabled": False,
"is_fgt_connected": False,
"login_banner": {
"enabled": False,
"message": ""
},
"ips": [
"192.168.1.208"
],
"listen_ip": "0.0.0.0",
"installer_port": 10443
}
#Setup session, login to EMS, and set new headers with CSRF token and referer
session = requests.Session()
login_response = session.post(url=login_url, json=auth_data, headers=api_headers, verify=False, timeout=30)
change_headers = {"Content-type": "application/json", "Referer": f"https://{ems_server}", "X-CSRFToken": f"{session.cookies["csrftoken"]}"}
#Set server settings
session.patch(url=server_settings_url, data=server_data, headers=change_headers, verify=False, timeout=30)
#Perform a logout
session.post(url=logout_url, headers=change_headers, verify=False, timeout=30)
Set EMS Settings response
{
"result":{
"retval":1,
"message":"Settings updated successfully."
}
}
This might be related to the fact that, if you reverse engineer it, you see that this is form-based, like authentication servers.

A list of undocumented API endpoints
This is a non-exhaustive list of API endpoints I noticed are missing from the official documentation while creating all of this. There are more, but I didn’t look very hard, and I didn’t bother configuring some things to check for endpoints.
This list is valid as of 2026-07-01.
- /api/v1/on_net_rules/index
- /api/v1/on_net_rules/create
- /api/v1/on_net_rules/{ID}/rules/get
- /api/v1/on_net_rules/{ID}/rules/update
- /api/v1/on_net_rules/{ID}/rules/delete
- /api/v1/assignable_installers/index
- /api/v1/assignable_installers/create
- /api/v1/assignable_installers/{ID}/get
- /api/v1/assignable_installers/{ID}/update
- /api/v1/assignable_installers/{ID}/delete
- /api/v1/group_containers/domains/index
- /api/v1/idps/index
- /api/v1/idps/{GUID}/delete
- /api/v1/idps/adfs/test
- /api/v1/idps/{GUID}/get
- /api/v1/idps/adfs/{GUID}/update
- /api/v1/idps/adfs/{GUID}/imported_ous
- /api/v1/connectors/index
- /api/v1/server_certificates/index
- /api/v1/settings/server/get
- /api/v1/settings/server/set
- /api/v1/system/cloud/repackager/status
- /api/v1/settings/server/addresses/get
- /api/v1/admins/create
- /api/v1/admins/{ID}/get
- /api/v1/admins/{ID}/update
- /api/v1/admins/{ID}/delete
- /api/v1/profiles/{COMPONENT_TYPE}/index
- /api/v1/profiles/{COMPONENT_TYPE}/{ID}/get
- /api/v1/profiles/{COMPONENT_TYPE}/{ID}/delete
- /api/v1/profiles/ztna/saas_applications
- /api/v1/roles/index
- /api/v1/client_certificates/group_index
- /api/v1/client_certificates/index
- /api/v1/client_certificates/set
- /api/v1/oauth2_fabric_connectors/index
- /api/v1/fabric_device_auth/{FGT_SERIAL}/update
- /api/v1/client_certificates/delete
- /api/v1/ztna_apps/index
- /api/v1/troubleshoot/get
- /api/v1/idps/{GUID}/live_navigate
- /api/v1/system/info
- /api/v1/endpoints/connection/donut
- /api/v1/endpoints/management/donut
- /api/v1/license/get
- /api/v1/forti_care/get
- /api/v1/logs/index
- /api/v1/logs/count
- /api/v1/users/local/index
- /api/v1/users/local/create
- /api/v1/users/local/{ID}/update
- /api/v1/users/local/{ID}/delete
- /api/v1/users/{ID}/endpoints/count
Wrapping up
A lot was covered today, but I still feel it is a light post, since most of the work was scripting and troubleshooting. I know this post comes across as negative, and I don’t feel positive about the API, but it’s not my intention to make anyone feel bad. If the documentation and the API get better because of this, I see that as a great success. If someone feels helped by this post, then I consider that an even greater success.
Dear reader, do you feel helped?
A shoutout to Maximilian Schiffner from Fortinet for this post. He is one of the greatest Fortinet engineers I know, and I don’t say that just because he’s Austrian. I got the idea for this post because of a single sentence in an email he wrote.
And since this is the end and EMS was the topic, why not read about how you can use the certificate management capabilities of FortiClient EMS for VPN, full SSL/TLS inspection and 802.1X or how to connect to an HA EMS cluster from a FortiGate without an external load balancer? The EMS journey doesn’t stop!
Leave a Reply