Connectors

client.connectors (ConnectorsAPI) wraps FortiSOAR’s /api/integration surface – discovery, healthcheck, configuration, operation execution, the Connector Studio dev workspace, and install/uninstall. client.agents (AgentsAPI) covers the remote execution agent side: pushing, upgrading, and removing a connector on an agent, plus a liveness heartbeat.

A complete, runnable walkthrough lives in examples/manage_connectors.py – it defaults to read-only and exercises every method below.

Discovery & health

>>> client = demo_client()
>>> conn = client.connectors
>>> installed = conn.list_configured()           # installed + configured connectors
>>> [c.name for c in installed[:3]]
['smtp', 'code-snippet', ...]
>>> conn.resolve_version("mitre-attack")         # the configured version (None if absent)
'2.0.2'
>>> conn.resolve_version("not-installed") is None
True
>>> conn.configurations("mitre-attack")          # [{config_id, name, default}]
[ConnectorConfigSummary(id=7, config_id='01e4e6b4-c34e-4fc1-b692-bb08591f1fe5', name='Demo', default=True)]
>>> hc = conn.healthcheck("mitre-attack")        # status="Available" is green
>>> (hc.status, hc.name, hc.version)
('Available', 'mitre-attack', '2.0.2')

connector_detail fetches a connector’s full record – its operations (each with parameters + output_schema) and configurations. Captured live and trimmed to a doctest-friendly slice (the config dict on each configuration is dropped – it carries connection details):

>>> detail = conn.connector_detail("smtp")
>>> (detail["name"], detail["version"], detail["config_count"])
('smtp', '2.6.0', 1)
>>> [o["operation"] for o in detail["operations"][:3]]
['send_email_new', ...]
>>> [c["name"] for c in detail["configuration"]]
['localhost-postfix']

Executing an operation

execute() returns a typed ExecuteResult – .ok is the status == "Success" check, .data is the connector’s own output (shape varies by connector/operation). Live-verified against cisa-advisory’s get_known_exploited_vulnerability_cves – a public, read-only, parameter-less feed lookup safe to demo against a real vendor connector (the only side effect is CISA’s public catalog serving one GET):

>>> result = conn.execute("cisa-advisory", "get_known_exploited_vulnerability_cves")
>>> result.ok
True
>>> result.data["title"]
'CISA Catalog of Known Exploited Vulnerabilities'
>>> result.data["vulnerabilities"][0]["cveID"]
'CVE-2026-45659'

Operations with input parameters

Most connector operations take inputs. Pass them as params= – a dict keyed by the parameter name (the name field from the operation’s definition, not its display title):

r = conn.execute("nist-nvd", "get_specific_cve_details", params={"cveId": "CVE-2021-44228"})
r.ok          # True
r.data["vulnerabilities"][0]["cve"]["id"]   # "CVE-2021-44228"

To discover what parameters an operation needs before calling it, use action_ui_schema() – it returns each parameter with its name, type, required, and title:

params = conn.action_ui_schema("nist-nvd", "get_specific_cve_details")
for p in params:
    print(f"  {p.name}: type={p.type}  required={p.required}  title={p.title}")
# cveId: type=text  required=True  title=CVE ID

Or iterate the full operation list with operations():

for op in conn.operations("virustotal"):
    required = [p.name for p in op.parameters if p.required]
    print(f"  {op.operation}: required={required}")
# url_re_analyze: required=['id']
# query_url: required=['url']
# query_ip: required=['ip']
# file_reputation: required=['file_hash']
# ...

Selecting a configuration

When a connector has multiple configurations, execute() uses the default one automatically. Pass config= to target a specific one – it accepts either a configuration name or UUID:

# By name:
r = conn.execute("nist-nvd", "get_specific_cve_details",
                 params={"cveId": "CVE-2024-3094"}, config="nvd-public")
r.ok        # True

# By UUID:
r = conn.execute("nist-nvd", "get_specific_cve_details",
                 params={"cveId": "CVE-2024-3094"},
                 config="10580fb2-2693-4d9f-8408-1aa344affbf9")
r.ok        # True

To list available configurations:

for cfg in conn.configurations("nist-nvd"):
    print(f"  {cfg.name}  default={cfg.default}  config_id={cfg.config_id}")
# nvd-public  default=True  config_id=10580fb2-2693-4d9f-8408-1aa344affbf9

⚠️ For an agent-bound connector (see the module warning), execute() is fire-and-forget – it returns immediately with an in-progress status and empty data; the real result is pushed over a websocket, not pollable here.

Predicting the return shape

.data varies by connector and operation. output_schema() returns the operation’s declared output fields so you know what to expect before calling:

schema = conn.output_schema("virustotal", "query_ip")
# {"output_schema": [{"name": "permalink", "type": "text"},
#                     {"name": "positives", "type": "integer"}, ...]}

Not every operation declares an output schema – an empty or missing output_schema key means the connector didn’t specify one.

Dynamic operation parameters (apiOperation)

A select/multiselect parameter can declare apiOperation – the name of a sibling connector operation whose result populates the dropdown at render time. This lets an operation’s choices come from the live remote system (VMs, severities, locations, …) instead of a hardcoded options list. The populating operation is visible: false (hidden from the playbook palette) and receives the connector config so it can authenticate.

action_ui_schema() returns the params with their apiOperation and apiOnchange fields, so a UI or agent can detect which params are dynamic:

params = conn.action_ui_schema("cisco-threatgrid", "submit_sample")
for p in params:
    if p.apiOperation:
        print(f"{p.name}: type={p.type} -> call {p.apiOperation}")
    else:
        print(f"{p.name}: type={p.type} (static)")

To resolve the choices for a dynamic param, call the populating operation via execute() – the result is a plain string list or [{"title": "...", "value": "..."}] objects:

defn = conn.definition("cisco-threatgrid")
op = next(o for o in defn.operations if o.operation == "submit_sample")
vm_param = next(p for p in op.parameters if p.apiOperation == "get_available_vms")

result = conn.execute("cisco-threatgrid", vm_param.apiOperation, config="<config-uuid>")
choices = result.data  # ["Windows 7 64-bit", "Linux 64-bit", ...]

When apiOnchange=True, the populating operation also receives the current values of all sibling parameters in params (for cascading dropdowns like sap-rfc’s pick-a-module-then-its-params-appear). Pass them as the params argument to execute:

result = conn.execute(
    "sap-rfc", "get_rfc_function_params",
    config="<config-uuid>",
    params={"function_name": "RFC_READ_TABLE"},  # sibling value
)
# result.data = {"options": "RFC_READ_TABLE", "onchange": {"RFC_READ_TABLE": [param, ...]}}

See the Connector Building Guide (section “Dynamic Options from an Operation”) for the full info.json declaration + Python handler patterns.

Creating, rotating, and deleting a configuration

create_configuration/update_configuration/delete_configuration write credentials via POST/PUT/DELETE /api/integration/configuration/. Captured live against a throwaway virustotal config (api_key is a placeholder value, never a real credential) – created, rotated, then deleted, leaving the box with 0 virustotal configs afterwards, same as before:

>>> created = conn.create_configuration(
...     "virustotal",
...     {"server": "www.virustotal.com", "api_key": "test-doctest-key", "verify_ssl": True},
...     name="pyfsr-doctest-config",
...     validate=False,   # skip the schema fetch (config_schema) for this offline demo
...     autofill=False,
... )
>>> (created.name, created.config["server"])
('pyfsr-doctest-config', 'www.virustotal.com')

Note config["api_key"] comes back as the literal string "NULL" regardless of what was sent – the server never echoes a stored secret, only this sentinel:

>>> created.config["api_key"]
'NULL'

update_configuration sends the config whole – include every field, not just the one you’re rotating:

>>> updated = conn.update_configuration(
...     "virustotal", created.config_id,
...     {"server": "www.virustotal.com", "api_key": "test-rotated-key", "verify_ssl": True},
...     name="pyfsr-doctest-config",
...     validate=False,
...     autofill=False,
... )
>>> updated.name
'pyfsr-doctest-config'

Note

The PUT response omits connector_name/connector_version – present on create_configuration’s response, absent on update_configuration’s. Don’t rely on either field being there after an update.

>>> conn.delete_configuration(created.config_id) is None
True

Making a configuration the default

A connector whose configuration isn’t marked default fails its healthcheck with Could not find a configuration matching the id get_default_config or the default configuration – the config is there and usable by name, but anything resolving by default gets nothing.

There is no flag-only route: PUT /api/integration/configuration/{config_id}/ replaces the whole record. That makes the obvious fix dangerous, because the listing returns config: null while only the single-record GET carries the real field map – build the PUT body from the listing and you wipe the credentials. set_default_configuration does the read-then-echo for you:

conn.set_default_configuration("fortigate-firewall")             # the only config
conn.set_default_configuration("fortigate-firewall", "a5fb56f2") # by config_id
conn.set_default_configuration("fortigate-firewall", name="fortigate-lab")

It deliberately skips validate/autofill – the stored config is already what the appliance accepted, and materializing it against the schema would rewrite fields this call has no business touching. A remote-agent binding is carried over explicitly; omitting agent on the PUT silently moves execution back to the self-agent. If the appliance returns the "NULL" secret sentinel instead of a stored value, the call raises rather than writing that sentinel over a live credential.

Note

The stored ciphertext for a secret legitimately changes across this call – the appliance re-encrypts on save while the plaintext does not change. Verified on a live 8.0.0 appliance: a FortiGate configuration that could not be health-checked reported Available afterwards, with the upstream still reachable. Don’t read the changed value as corruption.

Warning

The trailing slash on /api/integration/configuration/{config_id}/ is mandatory. Without it the gateway rejects the call with 403 Could not validate HMAC fingerprint, which reads like a permissions or auth problem rather than a URL typo.

Data ingestion

Connectors that support data ingestion (the Data Ingestion page’s connector picker) can be wired up programmatically. data_ingest_wizard() reproduces every write the UI’s Configure Data Ingestion wizard makes – resolve the config, clone the sample ingestion playbooks into a per-configuration collection, activate them, create the schedule, and write the metadata record:

result = conn.data_ingest_wizard(
    "fortinet-fortisiem",
    config="prod",
    cron="*/15 * * * *",          # schedule; omit to build playbooks only
)
result.collection_uuid            # '1f9b6533-...' (the config_id)
result.playbooks                  # [Workflow(...), Workflow(...), Workflow(...)]
result.schedule_id                # 'Ingestion_fortinet-fortisiem_prod_1f9b6533-...'
result.existed                    # False -- the wizard built it

ensure_ingestion() is the idempotent front door – set up only if it isn’t already, without writing anything on the second call:

first = conn.ensure_ingestion("fortinet-fortisiem", config="prod", cron="*/15 * * * *")
first.existed                     # False -- the wizard built it
again = conn.ensure_ingestion("fortinet-fortisiem", config="prod")
again.existed                     # True -- returned as-is, no writes

ingestion_status() is the read-only check – “is ingestion set up for this config, and is its schedule running?” In the demo box nothing is configured:

>>> status = conn.ingestion_status("mitre-attack")
>>> status.configured
False

trigger_ingestion() fires the ingest playbook right now – the Trigger Ingestion Now button. It bypasses the scheduler, so it works even when the schedule is disabled or absent:

conn.trigger_ingestion("fortinet-fortisiem", config="prod")
# {'task_id': '9d4af948-2a04-4d1e-9ab1-b83d3252ce18'}

remove_ingestion() tears it all down – delete the schedule, the metadata record, and (by default) the per-config collection with its cloned playbooks:

conn.remove_ingestion("fortinet-fortisiem", config="prod")
# IngestionTeardownResult(connector='fortinet-fortisiem', config_id='1f9b6533-...',
#   schedule_deleted=True, metadata_deleted=1, collection_deleted=True)

conn.remove_ingestion("fortinet-fortisiem", config="prod",
                       delete_collection=False)   # keep the playbooks

Idempotent configuration helpers

create_configuration 400s on the second call with the same name. upsert_configuration is the idempotent write – create-or-update by name – that the UI’s Save button performs. Safe to re-run from a deploy script:

>>> cfg = conn.upsert_configuration(
...     "virustotal",
...     {"server": "www.virustotal.com", "api_key": "test-doctest-key", "verify_ssl": True},
...     name="pyfsr-doctest-config",
...     validate=False,
...     autofill=False,
... )
>>> (cfg.name, cfg.config["server"])
('pyfsr-doctest-config', 'www.virustotal.com')
>>> cfg2 = conn.upsert_configuration(
...     "virustotal",
...     {"server": "www.virustotal.com", "api_key": "test-rotated-key", "verify_ssl": True},
...     name="pyfsr-doctest-config",
...     validate=False,
...     autofill=False,
... )
>>> cfg2.config_id == cfg.config_id
True
>>> conn.delete_configuration(cfg.config_id) is None
True

default_config() builds a schema-complete starting point – every field filled with its declared default, including onchange-revealed sub-fields – so you only override what you need:

cfg = conn.default_config("code-snippet")   # {"allow_imports": False, "restrict_imports": ""}
cfg["allow_imports"] = True
conn.upsert_configuration("code-snippet", cfg, name="dev")

ensure_configured() is the one-call setup: install from Content Hub if the connector isn’t there yet, then create-or-update the named config:

conn.ensure_configured(
    "servicenow",
    {"server_url": "https://snow.example.com", "username": "api", "password": "<pw>"},
    config_name="prod",
    version="4.4.5",       # only needed if the connector isn't installed yet
    default=True,
)

Connector Studio dev workspace

Edit a checked-out connector’s source, then publish it onto the running appliance – the same flow as the in-product Studio editor.

dev = conn.dev_list()                       # connectors checked out for editing
entity_id = dev[0]["id"]

conn.dev_edit(entity_id)                     # open for editing (Studio "Edit")
conn.dev_read_file(entity_id, "/hello-world_1_0_0_dev/info.json")
conn.dev_write_file(entity_id, {"path": "info.json", "content": "{...}"})
conn.dev_publish(entity_id, replace=True)    # land changes + refresh integrations

Note

dev_publish() is also the supported escape hatch when a same-version .tgz upload left stale code cached in the integrations service – it triggers a service refresh the standard $replace=true install path does not.

Install / uninstall

# Appliance (self-agent):
conn.install("fortinet-fortisiem", "6.1.0", wait=True)   # by name from Content Hub
conn.install_from_file("hello-world-1.0.0.tgz", replace=True)  # upload a .tgz bundle
conn.uninstall("fortinet-fortisiem")

# Remote agent:
client.agents.install_connector(agent_id, name="cyops_utilities", version="3.7.1")
client.agents.upgrade_connector(agent_id, name="cyops_utilities", version="3.8.0")
client.agents.uninstall_connector(agent_id, name="cyops_utilities", version="3.8.0")

client.agents.heartbeat(agent_id)            # liveness over the secure-message bus

Warning

Appliance uninstall (uninstall()) and agent uninstall (uninstall_connector()) are distinct: the first removes the connector from the appliance’s self-agent by integer id, the second removes it from a named remote agent.

A connector’s Python dependencies (from its requirements.txt) are installed automatically as part of the install. If that auto-install fails, operations blow up at runtime even though the configuration health is green. Check with dependencies_status() and retry with install_dependencies():

>>> ds = conn.dependencies_status("mitre-attack")
>>> ds.dependencies_installed
True
conn.install_dependencies("mitre-attack")   # retry the failed auto-install