System-to-System File Transfer in an Application

Instant Transfer

Instant transfer is a method that sends files and folders directly from a source device to a target device with a single API call, without scheduling or automation.

Overview

What Is Instant Transfer?

Transfer a specific file/folder from a source device to a designated path on a target device in a single operation. When you create a transfer, a monitorId is issued. Use this ID to check progress and perform controls including pause, resume, cancel, and retry.

Common Setup

Base URL

https://app.innorix.com

Authentication Headers — The following headers are required for every request.

HeaderDescription
Authorization: Bearer {accessToken}Access token (JWT) issued at login
x-workspace-id: {workspaceId}ID of the target workspace
Content-Type: application/jsonRequest body format

Obtain an access token with POST /api/auth/login (email, password) and use data.user.accessToken from the response. When it expires, refresh it with POST /api/auth/token/refresh (the X-Refresh-Token header). For long-term integrations, you can issue an API key with POST /api/auth/api-keys.

Device Concept — Both the source and target of a transfer are devices with an agent installed. Retrieve the list with GET /api/devices to obtain a deviceId. Each device has properties including os (windows·linux·mac) and online status (status).

Specifying Transfer Items — Instant transfer (POST /api/transfers/manual) specifies items to send with sourcePaths (an array of path strings). The automation and synchronization APIs specify them in the sourceItem array with a hash identifier.

FieldTypeDescription
sourcePathsstring[]List of paths to send in an instant transfer
sourceItem[].hashstringAutomation·synchronization item identifier — {deviceId}_ino_{base64(UTF-8 path)}
sourceItem[].isDirbooleanWhether the item is a folder

Use sendAllFolder to send all contents under a folder.

Device Lookup — GET /api/devices/resolve — Immediately look up a deviceId by name·IP·MAC (at least one of name·ip·mac is required).

json
{
  "status_code": 200,
  "message": "OK",
  "data": {
    "matchCount": 1,
    "devices": [
      {
        "deviceId": "dev_01H8...",
        "name": "OfficePC",
        "ipAddress": "192.168.0.9",
        "osType": "windows",
        "state": 1, "stateName": "CONNECTED", "stateLabel": "Connected",
        "isConnected": true
      }
    ]
  }
}

If multiple device names match, the response is 409 + data.candidates[], so use an unambiguous name.

Folder Listing (Non-Streaming) — GET /api/devices/{deviceId}/files — Retrieve the direct contents of a folder as JSON (use files/search SSE for recursive search). The response provides both path and fileToken, and fileToken can be used directly as the item identifier for transfer and file operations.

json
{
  "status_code": 200,
  "message": "OK",
  "data": {
    "path": "/data", "total": 128, "page": 1, "size": 50, "lastPage": 3,
    "items": [
      {
        "name": "report.pdf",
        "path": "/data/report.pdf",
        "fileToken": "L2RhdGEv...",
        "isDir": false, "size": 20480,
        "modifiedAt": "2026-08-01T09:12:00Z"
      }
    ]
  }
}

Enum Value Labels — Enum fields in single-item and detail responses provide the integer value together with the constant name and label (state/stateName/stateLabel). Derived flags such as connection status (isConnected) are also provided.

Key Endpoints

PurposeMethodEndpoint
LoginPOST/api/auth/login
Refresh tokenPOST/api/auth/token/refresh
Device listGET/api/devices
Device lookup (name·IP·MAC)GET/api/devices/resolve
Path capacity previewGET/api/devices/{deviceId}/path-stats
Source file searchPOST/api/devices/{deviceId}/files/search
Folder listing (non-streaming)GET/api/devices/{deviceId}/files
Path pre-validationPOST/api/transfers/validate-path
Create instant transferPOST/api/transfers/manual
Get transfer filesGET/api/transfers/{monitorId}/files
Transfer controlsPOST/api/transfers/{monitorId}/pause · resume · cancel · retry

Basic Flow

  1. Log in to obtain an access token
  2. Use GET /api/devices to identify sourceDevice and targetDevice
  3. (Optional) Build sourcePaths with file search
  4. (Optional) Validate paths with validate-path
  5. Create the transfer with POST /api/transfers/manual → receive monitorId
  6. Use monitorId to monitor and control progress

Shared Client (Java·C#)

The Node.js·Python examples run independently as a single file. The Java·C# examples use helpers from a shared class (InnorixClient, with Json included for Java). The sections below summarize the commonly used parts; the full source is available in the collapsible section.

// InnorixClient — shared helper the examples `import static`

// ── Auth & call (used by nearly every example)
void   login(String email, String password);           // obtain & store the access token
Object api(String method, String path, Object body);    // JSON call -> returns data; throws on 4xx

// ── Path & status
String  encodePath(String deviceId, String path);       // item token: {deviceId}_ino_{base64(path)}
boolean isTerminal(Object detail);                      // finished (complete/failed) status?
// status codes: COMPLETE=2 · PAUSE=3 · TRANSFERRING=6 · PARTIAL=9 · FAIL=99

// ── JSON helpers
Map<String,Object> obj(Object... kv);   List<Object> arr(Object... items);    // builders
String str(Object n, String key);   int intv(Object n, String key, int def);  // value access
List<Object> pageItems(Object page);    // normalize list-or-{items}/{data}

// ── Env & JSON:  env(key[, def]) · requireEnv(keys...)  |  Json.write() · Json.parse()

1:1 Transfer

Description

This is the most basic pattern for immediately transferring a specified file from one source device to one target device. Specify the source, target, and items to send with sourceDevice·targetDevice and sourcePaths (an array of path strings), then use monitorId to check status until completion.

APIs Used

PurposeMethodEndpoint
LoginPOST/api/auth/login
Create transferPOST/api/transfers/manual
Get statusGET/api/transfers/{monitorId}

Request

POST /api/transfers/manual

json
{
  "sourceDevice": "device-source-01",
  "targetDevice": "device-target-01",
  "targetPath": "/data/incoming",
  "sourcePaths": ["/data/report.pdf"],
  "sendAllFolder": false
}
  • sourceDevice·targetDevice can be specified using a deviceId, device name, or IP.
  • sourcePaths is an array of path strings for the files and folders to send (no hash encoding required).
  • sendAllFolder is a boolean.

ℹ️ sourceDevice/targetDevice can be specified using not only a deviceId but also a device name or IP; the server resolves it internally.

Response

json
{
  "status_code": 201,
  "message": "Created",
  "data": { "monitorId": "mon-abc123", "transferId": "tr-abc123" }
}

Processing Steps

  1. Obtain an access token with POST /api/auth/login (data.user.accessToken)
  2. Call POST /api/transfers/manualsourceDevice, targetDevice, targetPath, sourcePaths → receive data.monitorId
  3. Poll GET /api/transfers/{monitorId} to check status2=complete, 4·5·9·99=failure and terminate

Implementation Examples

"""Example 01 - One-to-one transfer.

Send a single file from a source device to a target device, then poll until it finishes.

Required environment variables: INNORIX_EMAIL, INNORIX_PASSWORD, INNORIX_WORKSPACE_ID
See ../.env.example for the full list and defaults.

Run:  python "01-1to1-transfer/example.py"
"""

import os
import sys
import time
from pathlib import Path

import requests
from dotenv import load_dotenv

# Load .env from the example's own folder first (so a single downloaded example works
# with a .env placed next to it), then fall back to the shared python/.env one level up.
# python-dotenv does not overwrite variables that are already set, so the folder-local
# .env wins and the shared one only fills in whatever is missing.
_here = Path(__file__).resolve().parent
load_dotenv(_here / ".env")          # same folder as this script
load_dotenv(_here.parent / ".env")   # shared python/.env (used by the full example set)

BASE_URL = os.getenv("INNORIX_BASE_URL", "https://app.innorix.com")
EMAIL = os.getenv("INNORIX_EMAIL")
PASSWORD = os.getenv("INNORIX_PASSWORD")
WORKSPACE_ID = os.getenv("INNORIX_WORKSPACE_ID")
# INNORIX_SOURCE_DEVICE / TARGET_DEVICE accept a device ID, device name, or IP address.
SOURCE_DEVICE = os.getenv("INNORIX_SOURCE_DEVICE", "device-source-01")
TARGET_DEVICE = os.getenv("INNORIX_TARGET_DEVICE", "device-target-01")
SOURCE_PATH = os.getenv("INNORIX_SOURCE_PATH", "/data/report.pdf")
TARGET_PATH = os.getenv("INNORIX_TARGET_PATH", "/data/incoming")

# Transfer status codes and the subset that means "no longer running".
TRANSFER_STATUS = {
    "transferComplete": 2,
    "transferError": 4,
    "transferCancel": 5,
    "transferPartialComplete": 9,
    "transferFail": 99,
}
TERMINAL_TRANSFER_STATUSES = set(TRANSFER_STATUS.values())

def api(method, path, token=None, body=None):
    """Minimal JSON API helper shared by every example."""
    headers = {"Content-Type": "application/json"}
    if token:
        headers["Authorization"] = f"Bearer {token}"
        headers["x-workspace-id"] = WORKSPACE_ID
    response = requests.request(method, BASE_URL + path, headers=headers, json=body)
    try:
        payload = response.json()
    except ValueError:
        payload = {}
    if not response.ok:
        raise RuntimeError(payload.get("message") or f"HTTP {response.status_code}")
    return payload.get("data")

def require_env():
    for name, value in {
        "INNORIX_EMAIL": EMAIL,
        "INNORIX_PASSWORD": PASSWORD,
        "INNORIX_WORKSPACE_ID": WORKSPACE_ID,
    }.items():
        if not value:
            raise RuntimeError(f"Missing required environment variable: {name}")

def main():
    require_env()

    # Log in once; the same token and workspace header are reused for every call.
    login = api("POST", "/api/auth/login", None, {"email": EMAIL, "password": PASSWORD})
    token = login["user"]["accessToken"]

    # Send one file from the source device to the target device.
    transfer = api("POST", "/api/transfers/manual", token, {
        "sourceDevice": SOURCE_DEVICE,
        "targetDevice": TARGET_DEVICE,
        "targetPath": TARGET_PATH,
        "sourcePaths": [SOURCE_PATH],
        "sendAllFolder": False,
    })
    monitor_id = transfer["monitorId"]
    print("transfer created", {
        "monitorId": monitor_id,
        "status": transfer.get("status"),
        "statusName": transfer.get("statusName"),
    })

    # Poll the combined active/history detail endpoint until the transfer reaches a terminal status.
    while True:
        detail = api("GET", f"/api/transfers/{monitor_id}", token)
        is_terminal = detail.get("isTerminal", detail.get("status") in TERMINAL_TRANSFER_STATUSES)
        print({
            "monitorId": monitor_id,
            "status": detail.get("status"),
            "statusName": detail.get("statusName"),
            "isTerminal": is_terminal,
            "percent": detail.get("percent", 0),
        })
        if is_terminal:
            if detail.get("status") != TRANSFER_STATUS["transferComplete"]:
                raise RuntimeError(detail.get("errorCode") or "Transfer failed")
            print("Transfer completed")
            return
        time.sleep(2)

if __name__ == "__main__":
    try:
        main()
    except Exception as error:  # noqa: BLE001 - surface a clean message to the CLI
        print(error, file=sys.stderr)
        sys.exit(1)

File Collection

Description

This pattern collects folders from multiple sources (branches) into one target path. Search for the target folder at each branch, create transfers, and aggregate completion across all transfers.

APIs Used

PurposeMethodEndpoint
LoginPOST/api/auth/login
Create transferPOST/api/transfers/manual
Get statusGET/api/transfers/{monitorId}

Request

POST /api/transfers/manual (repeat for each source)

json
{
  "sourceDevice": "branch-01",
  "targetDevice": "device-target-01",
  "targetPath": "/collect/logs",
  "sourcePaths": ["/var/log"],
  "sendAllFolder": true,
  "transferOptions": { "target-action": "numbering" }
}
  • Change only sourceDevice for each branch; keep targetDevice·targetPath fixed.
  • Use target-action: numbering in transferOptions to append a number when name conflicts occur while collecting into the same path.
  • Because the entire folder is collected, sendAllFolder is true.

Response

json
{
  "status_code": 201,
  "message": "Created",
  "data": { "monitorId": "mon-abc123", "transferId": "tr-abc123" }
}

Processing Steps

  1. Obtain an access token with POST /api/auth/login
  2. For each branch, call POST /api/transfers/manual (sourceDevice=branch, targetDevice=headquarters, sourcePaths=collection path) → collect monitorId values
  3. Poll every monitorId with GET /api/transfers/{monitorId} and aggregate completion

Implementation Examples

"""Example 02 - File collect.

Collect the same source path from several branch devices into one HQ device,
running the transfers in parallel and waiting for all of them to finish.

INNORIX_SOURCE_DEVICE / TARGET_DEVICE (and branch names) accept a device ID,
device name, or IP address.

Required environment variables: INNORIX_EMAIL, INNORIX_PASSWORD, INNORIX_WORKSPACE_ID
See ../.env.example for the full list and defaults.

Run:  python "02-file-collect/example.py"
"""

import os
import sys
import time
from pathlib import Path

import requests
from dotenv import load_dotenv

# Load .env from the example's own folder first (so a single downloaded example works
# with a .env placed next to it), then fall back to the shared python/.env one level up.
# python-dotenv does not overwrite variables that are already set, so the folder-local
# .env wins and the shared one only fills in whatever is missing.
_here = Path(__file__).resolve().parent
load_dotenv(_here / ".env")          # same folder as this script
load_dotenv(_here.parent / ".env")   # shared python/.env (used by the full example set)

BASE_URL = os.getenv("INNORIX_BASE_URL", "https://app.innorix.com")
EMAIL = os.getenv("INNORIX_EMAIL")
PASSWORD = os.getenv("INNORIX_PASSWORD")
WORKSPACE_ID = os.getenv("INNORIX_WORKSPACE_ID")
# Comma-separated list of branch (source) devices to collect from.
BRANCH_DEVICES = [
    name.strip()
    for name in os.getenv("INNORIX_BRANCH_DEVICES", "branch-01,branch-02,branch-03").split(",")
    if name.strip()
]
# Single HQ (target) device that receives everything.
HQ_DEVICE = os.getenv("INNORIX_HQ_DEVICE", "device-target-01")
SOURCE_PATH = os.getenv("INNORIX_SOURCE_PATH", "/var/log")
TARGET_PATH = os.getenv("INNORIX_TARGET_PATH", "/collect/logs")

TRANSFER_STATUS = {
    "transferComplete": 2,
    "transferError": 4,
    "transferCancel": 5,
    "transferPartialComplete": 9,
    "transferFail": 99,
}
TERMINAL_TRANSFER_STATUSES = set(TRANSFER_STATUS.values())

def api(method, path, token=None, body=None):
    headers = {"Content-Type": "application/json"}
    if token:
        headers["Authorization"] = f"Bearer {token}"
        headers["x-workspace-id"] = WORKSPACE_ID
    response = requests.request(method, BASE_URL + path, headers=headers, json=body)
    try:
        payload = response.json()
    except ValueError:
        payload = {}
    if not response.ok:
        raise RuntimeError(payload.get("message") or f"HTTP {response.status_code}")
    return payload.get("data")

def require_env():
    for name, value in {
        "INNORIX_EMAIL": EMAIL,
        "INNORIX_PASSWORD": PASSWORD,
        "INNORIX_WORKSPACE_ID": WORKSPACE_ID,
    }.items():
        if not value:
            raise RuntimeError(f"Missing required environment variable: {name}")

def main():
    require_env()

    login = api("POST", "/api/auth/login", None, {"email": EMAIL, "password": PASSWORD})
    token = login["user"]["accessToken"]

    # Start one collect transfer per branch: branch (source) -> HQ (target).
    pending = {}  # branch_name -> monitor_id
    for branch_name in BRANCH_DEVICES:
        transfer = api("POST", "/api/transfers/manual", token, {
            "sourceDevice": branch_name,
            "targetDevice": HQ_DEVICE,
            "targetPath": TARGET_PATH,
            "sourcePaths": [SOURCE_PATH],
            "sendAllFolder": True,
            "transferOptions": {"target-action": "numbering"},
        })
        print(branch_name, {
            "monitorId": transfer["monitorId"],
            "status": transfer.get("status"),
            "targetPath": TARGET_PATH,
        })
        pending[branch_name] = transfer["monitorId"]

    # Poll every branch transfer until each one reaches a terminal status.
    total = len(pending)
    while pending:
        for branch_name, monitor_id in list(pending.items()):
            detail = api("GET", f"/api/transfers/{monitor_id}", token)
            print(branch_name, {
                "status": detail.get("status"),
                "statusName": detail.get("statusName"),
                "percent": detail.get("percent", 0),
            })
            if detail.get("isTerminal", detail.get("status") in TERMINAL_TRANSFER_STATUSES):
                if detail.get("status") != TRANSFER_STATUS["transferComplete"]:
                    raise RuntimeError(f"{branch_name}: {detail.get('errorCode') or 'failed'}")
                del pending[branch_name]
        if pending:
            time.sleep(2)

    print(f"Collected logs from {total} branches")

if __name__ == "__main__":
    try:
        main()
    except Exception as error:  # noqa: BLE001
        print(error, file=sys.stderr)
        sys.exit(1)

File Distribution

Description

This pattern distributes one set of source files to multiple target devices. List the distribution folder once on the source, transfer the same package to each target, and aggregate successes and failures.

APIs Used

PurposeMethodEndpoint
LoginPOST/api/auth/login
Device lookup (name)GET/api/devices/resolve
Folder listing (non-streaming)GET/api/devices/{deviceId}/files
Create transferPOST/api/transfers/manual
Get statusGET/api/transfers/{monitorId}

Request

POST /api/transfers/manual (repeat for each target)

json
{
  "sourceDevice": "dev_01H8SRC...",
  "targetDevice": "dev_01H8BRANCH01...",
  "targetPath": "/deploy",
  "sourcePaths": ["/deploy/deployment-package"],
  "sendAllFolder": true,
  "transferOptions": { "target-action": "overwrite" }
}
  • Change only targetDevice for each branch; keep sourceDevice·sourcePaths fixed.
  • Use target-action: overwrite in transferOptions to overwrite existing files on the target.
  • Because the entire folder is distributed, sendAllFolder is true.

Response

json
{
  "status_code": 201,
  "message": "Created",
  "data": { "monitorId": "mon-abc123", "transferId": "tr-abc123" }
}

Processing Steps

  1. Obtain an access token with POST /api/auth/login
  2. Obtain the source deviceId with GET /api/devices/resolve?name=
  3. Use GET /api/devices/{deviceId}/files to search the distribution package folder and obtain its path
  4. For each target, call POST /api/transfers/manual (targetDevice=branch, sourcePaths=package path) → one monitorId per target
  5. Poll each monitorId with GET /api/transfers/{monitorId} and aggregate successes and failures

Implementation Examples

"""Example 03 - File distribution.

Distribute one package from a source device to many target devices in parallel,
then wait for all of them and report which succeeded and which failed.

Targets can be supplied via numbered environment variables:
    INNORIX_TARGET_DEVICE_1 / INNORIX_TARGET_PATH_1, _2, _3, ... (up to 50)
If none are set, the DEFAULT_TARGETS below are used.

Required environment variables: INNORIX_EMAIL, INNORIX_PASSWORD, INNORIX_WORKSPACE_ID
See ../.env.example for the full list and defaults.

Run:  python "03-file-distribution/example.py"
"""

import os
import sys
import time
from pathlib import Path

import requests
from dotenv import load_dotenv

# Load .env from the example's own folder first (so a single downloaded example works
# with a .env placed next to it), then fall back to the shared python/.env one level up.
# python-dotenv does not overwrite variables that are already set, so the folder-local
# .env wins and the shared one only fills in whatever is missing.
_here = Path(__file__).resolve().parent
load_dotenv(_here / ".env")          # same folder as this script
load_dotenv(_here.parent / ".env")   # shared python/.env (used by the full example set)

BASE_URL = os.getenv("INNORIX_BASE_URL", "https://app.innorix.com")
EMAIL = os.getenv("INNORIX_EMAIL")
PASSWORD = os.getenv("INNORIX_PASSWORD")
WORKSPACE_ID = os.getenv("INNORIX_WORKSPACE_ID")
# INNORIX_SOURCE_DEVICE accepts a device ID, device name, or IP address.
SOURCE_DEVICE = os.getenv("INNORIX_SOURCE_DEVICE", "device-source-01")
SOURCE_ROOT = os.getenv("INNORIX_SOURCE_ROOT", "/deploy")
PACKAGE_NAME = os.getenv("INNORIX_PACKAGE_NAME", "deployment-package")

TRANSFER_STATUS = {
    "transferComplete": 2,
    "transferError": 4,
    "transferCancel": 5,
    "transferPartialComplete": 9,
    "transferFail": 99,
}
TERMINAL_TRANSFER_STATUSES = set(TRANSFER_STATUS.values())

# Used only when no INNORIX_TARGET_DEVICE_N variables are configured.
DEFAULT_TARGETS = [
    {"device": "branch-01", "path": "/deploy/branch-01"},
    {"device": "branch-02", "path": "/deploy/branch-02"},
    {"device": "branch-03", "path": "/deploy/branch-03"},
    {"device": "branch-04", "path": "/deploy/branch-04"},
    {"device": "branch-05", "path": "/deploy/branch-05"},
]

def join_path(root, name):
    normalized_root = str(root or "").replace("\\", "/").rstrip("/")
    normalized_name = str(name or "").replace("\\", "/").lstrip("/")
    if not normalized_root:
        return normalized_name or "/"
    if not normalized_name:
        return normalized_root
    return f"{normalized_root}/{normalized_name}"

SOURCE_PATH = os.getenv("INNORIX_SOURCE_PATH") or join_path(SOURCE_ROOT, PACKAGE_NAME)

def api(method, path, token=None, body=None):
    headers = {"Content-Type": "application/json"}
    if token:
        headers["Authorization"] = f"Bearer {token}"
        headers["x-workspace-id"] = WORKSPACE_ID
    response = requests.request(method, BASE_URL + path, headers=headers, json=body)
    try:
        payload = response.json()
    except ValueError:
        payload = {}
    if not response.ok:
        raise RuntimeError(payload.get("message") or f"HTTP {response.status_code}")
    return payload.get("data")

def require_env():
    for name, value in {
        "INNORIX_EMAIL": EMAIL,
        "INNORIX_PASSWORD": PASSWORD,
        "INNORIX_WORKSPACE_ID": WORKSPACE_ID,
    }.items():
        if not value:
            raise RuntimeError(f"Missing required environment variable: {name}")

def distribution_targets():
    """Read INNORIX_TARGET_DEVICE_N / INNORIX_TARGET_PATH_N pairs, or fall back to DEFAULT_TARGETS."""
    targets = []
    for index in range(1, 51):
        device = os.getenv(f"INNORIX_TARGET_DEVICE_{index}")
        path = os.getenv(f"INNORIX_TARGET_PATH_{index}")
        if not device and not path:
            continue
        if not device or not path:
            raise RuntimeError(
                f"INNORIX_TARGET_DEVICE_{index} and INNORIX_TARGET_PATH_{index} must be set together"
            )
        targets.append({"device": device.strip(), "path": path.replace("\\", "/")})
    return targets if targets else DEFAULT_TARGETS

def main():
    require_env()

    login = api("POST", "/api/auth/login", None, {"email": EMAIL, "password": PASSWORD})
    token = login["user"]["accessToken"]

    # Start one transfer per target, reusing the same source package path.
    pending = {}  # target_device -> monitor_id
    for target in distribution_targets():
        transfer = api("POST", "/api/transfers/manual", token, {
            "sourceDevice": SOURCE_DEVICE,
            "targetDevice": target["device"],
            "targetPath": target["path"],
            "sourcePaths": [SOURCE_PATH],
            "sendAllFolder": True,
            "transferOptions": {"target-action": "overwrite"},
        })
        print(target["device"], {
            "monitorId": transfer["monitorId"],
            "status": transfer.get("status"),
            "targetPath": target["path"],
        })
        pending[target["device"]] = transfer["monitorId"]

    # Poll every pending transfer until each one reaches a terminal status.
    results = {}  # target_device -> "success" | error_code
    while pending:
        for target_name, monitor_id in list(pending.items()):
            detail = api("GET", f"/api/transfers/{monitor_id}", token)
            print(target_name, {
                "status": detail.get("status"),
                "statusName": detail.get("statusName"),
                "percent": detail.get("percent", 0),
            })
            if detail.get("isTerminal", detail.get("status") in TERMINAL_TRANSFER_STATUSES):
                results[target_name] = (
                    "success"
                    if detail.get("status") == TRANSFER_STATUS["transferComplete"]
                    else (detail.get("errorCode") or "failed")
                )
                del pending[target_name]
        if pending:
            time.sleep(2)

    # Split the outcome into succeeded devices and failed devices with their reasons.
    succeeded = [name for name, value in results.items() if value == "success"]
    failed = {name: value for name, value in results.items() if value != "success"}
    print({"succeeded": succeeded, "failed": failed})

if __name__ == "__main__":
    try:
        main()
    except Exception as error:  # noqa: BLE001
        print(error, file=sys.stderr)
        sys.exit(1)

Transfer Rules

Description

This section organizes the request components that determine what / where / how a transfer sends data. Source filters and target options provide additional capabilities in automation.

CategoryRelated FieldsDetails
What (source)sourcePaths, sendAllFolderSource Filter
Where (target)targetDevice, targetPathTarget Options
On conflicttransferOptions.target-actionConflict Handling

APIs Used

PurposeMethodEndpoint
Validate pathPOST/api/transfers/validate-path
Create transferPOST/api/transfers/manual

Request

POST /api/transfers/validate-path

json
{
  "sourceDevice": "device-src-001",
  "targetDevice": "device-dst-002",
  "targetPath": "/data/incoming",
  "sourcePaths": ["/data/report.pdf"],
  "sendAllFolder": false
}

Response

json
{
  "status_code": 200,
  "message": "success",
  "data": { "valid": true }
}

Processing Steps

  1. Validate the source·target·paths with POST /api/transfers/validate-path
  2. Configure the rules (sourcePaths/sendAllFolder/transferOptions.target-action) and call POST /api/transfers/manual
  3. Use data.monitorId from the response for subsequent processing

Implementation Examples

"""Example 04 - Transfer rules.

Validate the source/target paths first, then start a manual transfer and wait for it to finish.

Required environment variables: INNORIX_EMAIL, INNORIX_PASSWORD, INNORIX_WORKSPACE_ID
See ../.env.example for the full list and defaults.

Run:  python "04-transfer-rules/example.py"
"""

import os
import sys
import time
from pathlib import Path

import requests
from dotenv import load_dotenv

# Load .env from the example's own folder first (so a single downloaded example works
# with a .env placed next to it), then fall back to the shared python/.env one level up.
# python-dotenv does not overwrite variables that are already set, so the folder-local
# .env wins and the shared one only fills in whatever is missing.
_here = Path(__file__).resolve().parent
load_dotenv(_here / ".env")          # same folder as this script
load_dotenv(_here.parent / ".env")   # shared python/.env (used by the full example set)

BASE_URL = os.getenv("INNORIX_BASE_URL", "https://app.innorix.com")
EMAIL = os.getenv("INNORIX_EMAIL")
PASSWORD = os.getenv("INNORIX_PASSWORD")
WORKSPACE_ID = os.getenv("INNORIX_WORKSPACE_ID")
# INNORIX_SOURCE_DEVICE / TARGET_DEVICE accept a device ID, device name, or IP address.
SOURCE_DEVICE = os.getenv("INNORIX_SOURCE_DEVICE", "device-source-01")
TARGET_DEVICE = os.getenv("INNORIX_TARGET_DEVICE", "device-target-01")
SOURCE_PATH = os.getenv("INNORIX_SOURCE_PATH", "/data/report.pdf").replace("\\", "/")
TARGET_PATH = os.getenv("INNORIX_TARGET_PATH", "/data/incoming")

TRANSFER_STATUS = {
    "transferComplete": 2,
    "transferError": 4,
    "transferCancel": 5,
    "transferPartialComplete": 9,
    "transferFail": 99,
}
TERMINAL_TRANSFER_STATUSES = set(TRANSFER_STATUS.values())

def api(method, path, token=None, body=None):
    headers = {"Content-Type": "application/json"}
    if token:
        headers["Authorization"] = f"Bearer {token}"
        headers["x-workspace-id"] = WORKSPACE_ID
    response = requests.request(method, BASE_URL + path, headers=headers, json=body)
    try:
        payload = response.json()
    except ValueError:
        payload = {}
    if not response.ok:
        raise RuntimeError(payload.get("message") or f"HTTP {response.status_code}")
    return payload.get("data")

def require_env():
    for name, value in {
        "INNORIX_EMAIL": EMAIL,
        "INNORIX_PASSWORD": PASSWORD,
        "INNORIX_WORKSPACE_ID": WORKSPACE_ID,
    }.items():
        if not value:
            raise RuntimeError(f"Missing required environment variable: {name}")

def wait_for_completion(monitor_id, token):
    while True:
        detail = api("GET", f"/api/transfers/{monitor_id}", token)
        is_terminal = detail.get("isTerminal", detail.get("status") in TERMINAL_TRANSFER_STATUSES)
        print({
            "monitorId": monitor_id,
            "status": detail.get("status"),
            "statusName": detail.get("statusName"),
            "isTerminal": is_terminal,
            "percent": detail.get("percent", 0),
        })
        if is_terminal:
            if detail.get("status") != TRANSFER_STATUS["transferComplete"]:
                raise RuntimeError(detail.get("errorCode") or "Transfer failed")
            return detail
        time.sleep(2)

def main():
    require_env()

    login = api("POST", "/api/auth/login", None, {"email": EMAIL, "password": PASSWORD})
    token = login["user"]["accessToken"]

    request = {
        "sourceDevice": SOURCE_DEVICE,
        "targetDevice": TARGET_DEVICE,
        "targetPath": TARGET_PATH,
        "sourcePaths": [SOURCE_PATH],
        "sendAllFolder": False,
    }

    # 1) Validate the transfer request before running it (checks devices and paths).
    validation = api("POST", "/api/transfers/validate-path", token, request)
    print("validate-path", validation)

    # 2) Start the manual transfer using the same request.
    transfer = api("POST", "/api/transfers/manual", token, request)
    print("transfer created", {"monitorId": transfer["monitorId"], "status": transfer.get("status")})

    # 3) Wait until the transfer reaches a terminal status.
    wait_for_completion(transfer["monitorId"], token)
    print("Transfer completed")

if __name__ == "__main__":
    try:
        main()
    except Exception as error:  # noqa: BLE001
        print(error, file=sys.stderr)
        sys.exit(1)

Conflict Handling

Description

This controls what happens when a file with the same name already exists in the target path. Specify one of numbering (numbering), skip (nosend), or overwrite (overwrite) with target-action in transferOptions. Files that fail during transfer can be resumed through retry.

APIs Used

PurposeMethodEndpoint
Create transferPOST/api/transfers/manual
Retry failuresPOST/api/transfers/{monitorId}/retry

transferOptions.target-action values:

ValueDescription
numberingOn a name conflict, append a number and preserve both ((1), (2))
nosendIf the file already exists, do not send it and skip it
overwriteOverwrite the existing file

Request

POST /api/transfers/manual

json
{
  "sourceDevice": "device-source-01",
  "targetDevice": "device-target-01",
  "targetPath": "/data/incoming",
  "sourcePaths": ["/data/report.pdf"],
  "transferOptions": { "target-action": "numbering" }
}

Response

json
{
  "status_code": 201,
  "message": "Created",
  "data": { "monitorId": "mon-abc123", "transferId": "tr-abc123" }
}

Use data.monitorId from the response for subsequent status queries (GET /api/transfers/{monitorId}/files) and controls (pause·resume·cancel·retry).

Processing Steps

  1. Call POST /api/transfers/manual with transferOptions.target-action
  2. Receive data.monitorId from the response
  3. Retransfer failed files with POST /api/transfers/{monitorId}/retry

Implementation Examples

"""Example 05 - Conflict handling.

Send the same file to the same location under each conflict policy and compare the
file-level results.

Required environment variables: INNORIX_EMAIL, INNORIX_PASSWORD, INNORIX_WORKSPACE_ID
See ../.env.example for the full list and defaults.

Run:  python "05-conflict-handling/example.py"
"""

import os
import sys
import time
from pathlib import Path

import requests
from dotenv import load_dotenv

# Load .env from the example's own folder first (so a single downloaded example works
# with a .env placed next to it), then fall back to the shared python/.env one level up.
# python-dotenv does not overwrite variables that are already set, so the folder-local
# .env wins and the shared one only fills in whatever is missing.
_here = Path(__file__).resolve().parent
load_dotenv(_here / ".env")          # same folder as this script
load_dotenv(_here.parent / ".env")   # shared python/.env (used by the full example set)

BASE_URL = os.getenv("INNORIX_BASE_URL", "https://app.innorix.com")
EMAIL = os.getenv("INNORIX_EMAIL")
PASSWORD = os.getenv("INNORIX_PASSWORD")
WORKSPACE_ID = os.getenv("INNORIX_WORKSPACE_ID")
# INNORIX_SOURCE_DEVICE / TARGET_DEVICE accept a device ID, device name, or IP address.
SOURCE_DEVICE = os.getenv("INNORIX_SOURCE_DEVICE", "device-source-01")
TARGET_DEVICE = os.getenv("INNORIX_TARGET_DEVICE", "device-target-01")
SOURCE_PATH = os.getenv("INNORIX_SOURCE_PATH", "/data/report.pdf")
TARGET_PATH = os.getenv("INNORIX_TARGET_PATH", "/data/incoming")

# Conflict policies to compare: add a number suffix, skip, or overwrite on conflict.
CONFLICT_POLICIES = ["numbering", "nosend", "overwrite"]

TRANSFER_STATUS = {
    "transferComplete": 2,
    "transferError": 4,
    "transferCancel": 5,
    "transferPartialComplete": 9,
    "transferFail": 99,
}
TERMINAL_TRANSFER_STATUSES = set(TRANSFER_STATUS.values())

def api(method, path, token=None, body=None):
    headers = {"Content-Type": "application/json"}
    if token:
        headers["Authorization"] = f"Bearer {token}"
        headers["x-workspace-id"] = WORKSPACE_ID
    response = requests.request(method, BASE_URL + path, headers=headers, json=body)
    try:
        payload = response.json()
    except ValueError:
        payload = {}
    if not response.ok:
        raise RuntimeError(payload.get("message") or f"HTTP {response.status_code}")
    return payload.get("data")

def require_env():
    for name, value in {
        "INNORIX_EMAIL": EMAIL,
        "INNORIX_PASSWORD": PASSWORD,
        "INNORIX_WORKSPACE_ID": WORKSPACE_ID,
    }.items():
        if not value:
            raise RuntimeError(f"Missing required environment variable: {name}")

def wait_for_terminal(monitor_id, token):
    """The 'nosend'/'fail' policies may end without success, so wait for any terminal status."""
    while True:
        detail = api("GET", f"/api/transfers/{monitor_id}", token)
        if detail.get("isTerminal", detail.get("status") in TERMINAL_TRANSFER_STATUSES):
            return detail
        time.sleep(2)

def main():
    require_env()

    login = api("POST", "/api/auth/login", None, {"email": EMAIL, "password": PASSWORD})
    token = login["user"]["accessToken"]

    for policy in CONFLICT_POLICIES:
        # Same file, same destination - only the conflict policy changes.
        transfer = api("POST", "/api/transfers/manual", token, {
            "sourceDevice": SOURCE_DEVICE,
            "targetDevice": TARGET_DEVICE,
            "targetPath": TARGET_PATH,
            "sourcePaths": [SOURCE_PATH],
            "sendAllFolder": False,
            "transferOptions": {"target-action": policy},
        })
        detail = wait_for_terminal(transfer["monitorId"], token)

        # Inspect the per-file result for this policy. The /files response returns the rows
        # under `children`, and each row names the file/state as sourceFileName / statusName.
        files = api("GET", f"/api/transfers/{transfer['monitorId']}/files?state=any&size=100", token)
        rows = (files or {}).get("children") or (files or {}).get("items") or []
        print(
            f"policy={policy} status={detail.get('status')}",
            [
                {
                    "name": f.get("sourceFileName") or f.get("targetFileName") or f.get("sourceFilePath"),
                    "state": f.get("statusName") or f.get("status"),
                }
                for f in rows
            ],
        )

if __name__ == "__main__":
    try:
        main()
    except Exception as error:  # noqa: BLE001
        print(error, file=sys.stderr)
        sys.exit(1)

Transfer Automation

Configure transfers to run automatically according to schedules, file events, or workflows without a person calling them each time.

Recurring Automation

Description

Run the same transfer repeatedly according to a schedule. When you create an automation, an automationId is issued; use it to pause the automation and retrieve details for operations.

APIs Used

PurposeMethodEndpoint
Create automationPOST/api/automations
List automationsGET/api/automations
Automation detailsGET/api/automations/{automationId}/details
Pause automationPOST/api/automations/{automationId}/pause

Request

POST /api/automations

json
{
  "name": "Daily Settlement Transfer",
  "transferType": "normal",
  "timezone": "Asia/Seoul",
  "details": [
    {
      "senderId": "device-src-001",
      "receiverId": "device-hq-001",
      "targetPath": "/collect/logs",
      "sourceItem": [{ "hash": "device-src-001_ino_...", "isDir": false }],
      "step": 1,
      "transferOptions": { "noSchedule": false, "target-action": "numbering", "send-fileoption": {} }
    }
  ],
  "schedules": [
    { "type": "day", "startDateType": "now", "hour": "02", "minute": "00", "ampm": "am", "startDate": "2026-08-25T00:00:00Z", "timezone": "Asia/Seoul" }
  ]
}

Response

json
{
  "status_code": 201,
  "message": "Created",
  "data": { "automationId": "auto-abc123" }
}

Processing Steps

  1. Call POST /api/automationsname, schedules (array of schedule objects), details (array of transfer definitions), timezone, transferType
  2. Receive data.automationId from the response
  3. Check details and progress with GET /api/automations/{automationId}/details
  4. If needed, pause with POST /api/automations/{automationId}/pause

Implementation Examples

"""Example 06 - Recurring automation.

Full lifecycle of a scheduled automation: create, read, update the schedule,
list past executions, pause, then delete.

INNORIX_SOURCE_ID / TARGET_ID must be the exact device IDs - they are encoded into the
path token, so a device name will not work here (unlike the manual-transfer examples).

Required environment variables: INNORIX_EMAIL, INNORIX_PASSWORD, INNORIX_WORKSPACE_ID
See ../.env.example for the full list and defaults.

Run:  python "06-Recurring Automation/example.py"
"""

import base64
import os
import sys
from datetime import datetime, timezone
from pathlib import Path

import requests
from dotenv import load_dotenv

# Load .env from the example's own folder first (so a single downloaded example works
# with a .env placed next to it), then fall back to the shared python/.env one level up.
# python-dotenv does not overwrite variables that are already set, so the folder-local
# .env wins and the shared one only fills in whatever is missing.
_here = Path(__file__).resolve().parent
load_dotenv(_here / ".env")          # same folder as this script
load_dotenv(_here.parent / ".env")   # shared python/.env (used by the full example set)

BASE_URL = os.getenv("INNORIX_BASE_URL", "https://app.innorix.com")
EMAIL = os.getenv("INNORIX_EMAIL")
PASSWORD = os.getenv("INNORIX_PASSWORD")
WORKSPACE_ID = os.getenv("INNORIX_WORKSPACE_ID")
SOURCE_ID = os.getenv("INNORIX_SOURCE_ID", "device-source-01")
TARGET_ID = os.getenv("INNORIX_TARGET_ID", "device-target-01")
SOURCE_PATH = os.getenv("INNORIX_SOURCE_PATH", "/data/report.pdf").replace("\\", "/")
TARGET_PATH = os.getenv("INNORIX_TARGET_PATH", "/data/incoming")
TIMEZONE = os.getenv("INNORIX_TIMEZONE", "UTC")

def api(method, path, token=None, body=None):
    headers = {"Content-Type": "application/json"}
    if token:
        headers["Authorization"] = f"Bearer {token}"
        headers["x-workspace-id"] = WORKSPACE_ID
    response = requests.request(method, BASE_URL + path, headers=headers, json=body)
    try:
        payload = response.json()
    except ValueError:
        payload = {}
    if not response.ok:
        raise RuntimeError(payload.get("message") or f"HTTP {response.status_code}")
    return payload.get("data")

def require_env():
    for name, value in {
        "INNORIX_EMAIL": EMAIL,
        "INNORIX_PASSWORD": PASSWORD,
        "INNORIX_WORKSPACE_ID": WORKSPACE_ID,
    }.items():
        if not value:
            raise RuntimeError(f"Missing required environment variable: {name}")

def encode_path(device_id, raw_path):
    """Build the `<deviceId>_ino_<base64(path)>` token used by the automation API."""
    normalized = str(raw_path or "").replace("\\", "/")
    token = base64.b64encode(normalized.encode("utf-8")).decode("ascii")
    return f"{device_id}_ino_{token}"

def now_iso():
    """UTC timestamp in the same ISO 8601 format Node's Date#toISOString() produces."""
    return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + "Z"

def main():
    require_env()

    login = api("POST", "/api/auth/login", None, {"email": EMAIL, "password": PASSWORD})
    token = login["user"]["accessToken"]

    # Schedule: run every day at 02:00 AM to send the previous day's settlement file.
    schedule = {
        "type": "day",
        "startDateType": "now",
        "hour": "02",
        "minute": "00",
        "ampm": "am",
        "startDate": now_iso(),
        "timezone": TIMEZONE,
    }

    # 1) Create the automation.
    created = api("POST", "/api/automations", token, {
        "name": "Daily Settlement Transfer",
        "details": [
            {
                "sourceItem": [{"hash": encode_path(SOURCE_ID, SOURCE_PATH), "isDir": False}],
                "targetPath": TARGET_PATH,
                "senderId": SOURCE_ID,
                "receiverId": TARGET_ID,
                "step": 1,
                "transferOptions": {
                    "noSchedule": False,
                    "target-action": "numbering",
                    "send-fileoption": {},
                },
            }
        ],
        "transferType": "normal",
        "timezone": TIMEZONE,
        "schedules": [schedule],
    })
    automation_id = created["automationId"]
    print("automation created", automation_id)

    # 2) Read the automation.
    print("detail", api("GET", f"/api/automations/{automation_id}", token))

    # 3) Update the schedule.
    api("PATCH", f"/api/automations/{automation_id}", token, {
        "name": "Daily Settlement Transfer",
        "isUpdateSchedule": True,
        "schedules": [schedule],
    })

    # 4) List past executions.
    print("executions", api("GET", f"/api/automations/{automation_id}/executions", token))

    # 5) Pause the automation.
    api("POST", f"/api/automations/{automation_id}/pause", token, {"pause": True})

    # 6) Delete the automation (example cleanup).
    api("DELETE", f"/api/automations/{automation_id}", token)
    print("automation deleted", automation_id)

if __name__ == "__main__":
    try:
        main()
    except Exception as error:  # noqa: BLE001
        print(error, file=sys.stderr)
        sys.exit(1)

Webhook

Description

Notify an external system about transfer and automation executions. If you add a webhook processor (processors) to an automation, the server calls the specified URL when the automation runs (events: "Run").

APIs Used

PurposeMethodEndpoint
Create automation with webhook processorPOST/api/automations
Automation details (including processors)GET/api/automations/{automationId}/details

processors[] fields:

FieldTypeDescription
eventsstringTrigger event (e.g., Run)
typestringProcessor type (e.g., https)
methodstringHTTP method (e.g., POST)
urlstringURL to call
bodystringRequest body (optional)

Request

POST /api/automations

json
{
  "name": "Webhook Automation",
  "flowName": "Webhook Automation",
  "transferType": "normal",
  "isUpcoming": false,
  "timezone": "Asia/Seoul",
  "details": [
    {
      "sourceItem": [{ "hash": "device-source-01_ino_...", "isDir": false }],
      "targetPath": "/data/incoming",
      "senderId": "device-source-01",
      "receiverId": "device-target-01",
      "step": 1,
      "transferOptions": { "noSchedule": false, "target-action": "numbering", "send-fileoption": {} }
    }
  ],
  "schedules": [{ "type": "none", "startDateType": "now", "hour": "00", "minute": "00", "ampm": "am", "startDate": "2026-08-25T00:00:00Z", "timezone": "Asia/Seoul" }],
  "processors": [
    { "events": "Run", "type": "https", "method": "POST", "url": "https://example.com/webhook", "body": "" }
  ]
}

Response

json
{
  "status_code": 201,
  "message": "Created",
  "data": { "automationId": "auto-abc123" }
}

Processing Steps

  1. Create an automation with POST /api/automations, including the processors (webhook) array
  2. Receive data.automationId from the response
  3. When the automation runs, the server calls processors[].url
  4. Verify the configured webhook processor with GET /api/automations/{automationId}/details

Implementation Examples

"""Example 07 - Webhook.

Create an automation that fires a webhook when it runs. The webhook is defined as a
processor on the automation (events: "Run"), so the server calls your URL on execution.

INNORIX_SOURCE_ID / TARGET_ID must be the exact device IDs - they are encoded into the
path token, so a device name will not work here (unlike the manual-transfer examples).

Required environment variables: INNORIX_EMAIL, INNORIX_PASSWORD, INNORIX_WORKSPACE_ID
See ../.env.example for the full list and defaults.

Run:  python "07-Webhook/example.py"
"""

import base64
import os
import sys
from datetime import datetime, timezone
from pathlib import Path

import requests
from dotenv import load_dotenv

# Load .env from the example's own folder first (so a single downloaded example works
# with a .env placed next to it), then fall back to the shared python/.env one level up.
# python-dotenv does not overwrite variables that are already set, so the folder-local
# .env wins and the shared one only fills in whatever is missing.
_here = Path(__file__).resolve().parent
load_dotenv(_here / ".env")          # same folder as this script
load_dotenv(_here.parent / ".env")   # shared python/.env (used by the full example set)

BASE_URL = os.getenv("INNORIX_BASE_URL", "https://app.innorix.com")
EMAIL = os.getenv("INNORIX_EMAIL")
PASSWORD = os.getenv("INNORIX_PASSWORD")
WORKSPACE_ID = os.getenv("INNORIX_WORKSPACE_ID")
SOURCE_ID = os.getenv("INNORIX_SOURCE_ID", "device-source-01")
TARGET_ID = os.getenv("INNORIX_TARGET_ID", "device-target-01")
SOURCE_PATH = os.getenv("INNORIX_SOURCE_PATH", "/data/report.pdf").replace("\\", "/")
TARGET_PATH = os.getenv("INNORIX_TARGET_PATH", "/data/incoming")
TIMEZONE = os.getenv("INNORIX_TIMEZONE", "UTC")
# Webhook processor settings.
WEBHOOK_TYPE = os.getenv("INNORIX_WEBHOOK_TYPE", "https")
WEBHOOK_URL = os.getenv("INNORIX_WEBHOOK_URL", "https://example.com/webhook")
WEBHOOK_METHOD = os.getenv("INNORIX_WEBHOOK_METHOD", "POST")
WEBHOOK_BODY = os.getenv("INNORIX_WEBHOOK_BODY", "")

def api(method, path, token=None, body=None):
    headers = {"Content-Type": "application/json"}
    if token:
        headers["Authorization"] = f"Bearer {token}"
        headers["x-workspace-id"] = WORKSPACE_ID
    response = requests.request(method, BASE_URL + path, headers=headers, json=body)
    try:
        payload = response.json()
    except ValueError:
        payload = {}
    if not response.ok:
        raise RuntimeError(payload.get("message") or f"HTTP {response.status_code}")
    return payload.get("data")

def require_env():
    for name, value in {
        "INNORIX_EMAIL": EMAIL,
        "INNORIX_PASSWORD": PASSWORD,
        "INNORIX_WORKSPACE_ID": WORKSPACE_ID,
    }.items():
        if not value:
            raise RuntimeError(f"Missing required environment variable: {name}")

def encode_path(device_id, raw_path):
    """Build the `<deviceId>_ino_<base64(path)>` token used by the automation API."""
    normalized = str(raw_path or "").replace("\\", "/")
    token = base64.b64encode(normalized.encode("utf-8")).decode("ascii")
    return f"{device_id}_ino_{token}"

def now_iso():
    return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + "Z"

def main():
    require_env()

    login = api("POST", "/api/auth/login", None, {"email": EMAIL, "password": PASSWORD})
    token = login["user"]["accessToken"]

    # Create an automation with a webhook processor that fires on run.
    automation = api("POST", "/api/automations", token, {
        "name": "Webhook Automation",
        "flowName": "Webhook Automation",
        "details": [
            {
                "sourceItem": [{"hash": encode_path(SOURCE_ID, SOURCE_PATH), "isDir": False}],
                "targetPath": TARGET_PATH,
                "senderId": SOURCE_ID,
                "receiverId": TARGET_ID,
                "step": 1,
                "transferOptions": {
                    "noSchedule": False,
                    "target-action": "numbering",
                    "send-fileoption": {},
                },
            }
        ],
        "transferType": "normal",
        "isUpcoming": False,
        "timezone": TIMEZONE,
        "schedules": [
            {
                "type": "none",
                "startDateType": "now",
                "hour": "00",
                "minute": "00",
                "ampm": "am",
                "startDate": now_iso(),
                "timezone": TIMEZONE,
            }
        ],
        # Webhook: the server calls this URL when the automation runs.
        "processors": [
            {
                "events": "Run",
                "type": WEBHOOK_TYPE,
                "method": WEBHOOK_METHOD,
                "url": WEBHOOK_URL,
                "body": WEBHOOK_BODY,
            }
        ],
    })
    automation_id = automation["automationId"]
    print("automation created", automation_id)

    # Read the automation details, including the configured webhook processor.
    print("details", api("GET", f"/api/automations/{automation_id}/details", token))

if __name__ == "__main__":
    try:
        main()
    except Exception as error:  # noqa: BLE001
        print(error, file=sys.stderr)
        sys.exit(1)

Workflow

Description

Connect multiple stages into one flow. Create each hop as an automation and group them under the same flowId; connect the next hop to the previous hop with triggerAutomation. When A→B completes, the server automatically runs B→C (the client does not assemble it).

APIs Used

PurposeMethodEndpoint
Create automation (per hop)POST/api/automations
Automation detailsGET/api/automations/{automationId}/details
Execution historyGET/api/automations/{automationId}/executions

Request

POST /api/automations (for each hop, sharing the same flowId)

json
{
  "name": "B to C Workflow",
  "flowName": "B-C Workflow",
  "transferType": "normal",
  "isUpcoming": false,
  "timezone": "Asia/Seoul",
  "flowId": "shared-flow-uuid",
  "details": [
    {
      "sourceItem": [{ "hash": "device-middle-01_ino_...", "isDir": false }],
      "targetPath": "/data/incoming",
      "senderId": "device-middle-01",
      "receiverId": "device-target-01",
      "step": 1,
      "transferOptions": { "noSchedule": false, "target-action": "numbering", "send-fileoption": {} }
    }
  ],
  "schedules": [
    { "type": "none", "startDateType": "now", "hour": "00", "minute": "00", "ampm": "am", "startDate": "2026-08-25T00:00:00Z", "timezone": "Asia/Seoul", "triggerAutomation": { "value": "auto-ab-id" } }
  ]
}
  • All hops belong to one workflow under the same flowId.
  • If you specify the previous hop's automationId in schedules[].triggerAutomation.value for the next hop, it runs automatically after the previous hop completes.

Response

json
{
  "status_code": 201,
  "message": "Created",
  "data": { "automationId": "auto-flow-abc123" }
}

Processing Steps

  1. Create a shared flowId
  2. Create the A→B automation → receive automationId
  3. When creating the B→C automation, set triggerAutomation.value=A→B automationId and specify the same flowId
  4. Check stages and executions with GET /api/automations/{automationId}/details·/executions

Implementation Examples

"""Example 08 - Workflow (A -> B -> C chain).

Define a relayed transfer as two automations sharing one flowId: A -> B, then B -> C.
Both are marked isUpcoming; B -> C is chained to A -> B via `triggerAutomation`, so the
server runs it automatically once A -> B completes (the client does not orchestrate the
hand-off).

INNORIX_SOURCE_ID / MIDDLE_ID / TARGET_ID must be the exact device IDs - they are encoded
into the path token, so a device name will not work here.

Required environment variables: INNORIX_EMAIL, INNORIX_PASSWORD, INNORIX_WORKSPACE_ID
See ../.env.example for the full list and defaults.

Run:  python "08-Workflow/example.py"
"""

import base64
import os
import random
import sys
import time
from datetime import datetime, timezone
from pathlib import Path

import requests
from dotenv import load_dotenv

# Load .env from the example's own folder first (so a single downloaded example works
# with a .env placed next to it), then fall back to the shared python/.env one level up.
# python-dotenv does not overwrite variables that are already set, so the folder-local
# .env wins and the shared one only fills in whatever is missing.
_here = Path(__file__).resolve().parent
load_dotenv(_here / ".env")          # same folder as this script
load_dotenv(_here.parent / ".env")   # shared python/.env (used by the full example set)

BASE_URL = os.getenv("INNORIX_BASE_URL", "https://app.innorix.com")
EMAIL = os.getenv("INNORIX_EMAIL")
PASSWORD = os.getenv("INNORIX_PASSWORD")
WORKSPACE_ID = os.getenv("INNORIX_WORKSPACE_ID")
# A -> B -> C: source -> middle (relay) -> target.
SOURCE_ID = os.getenv("INNORIX_SOURCE_ID", "device-source-01")
MIDDLE_ID = os.getenv("INNORIX_MIDDLE_ID", "device-middle-01")
TARGET_ID = os.getenv("INNORIX_TARGET_ID", "device-target-01")
SOURCE_PATH = os.getenv("INNORIX_SOURCE_PATH", "/data/report.pdf").replace("\\", "/")
MIDDLE_PATH = os.getenv("INNORIX_MIDDLE_PATH", SOURCE_PATH).replace("\\", "/")
TARGET_PATH = os.getenv("INNORIX_TARGET_PATH", "/data/incoming")
TIMEZONE = os.getenv("INNORIX_TIMEZONE", "UTC")

def api(method, path, token=None, body=None):
    headers = {"Content-Type": "application/json"}
    if token:
        headers["Authorization"] = f"Bearer {token}"
        headers["x-workspace-id"] = WORKSPACE_ID
    response = requests.request(method, BASE_URL + path, headers=headers, json=body)
    try:
        payload = response.json()
    except ValueError:
        payload = {}
    if not response.ok:
        raise RuntimeError(payload.get("message") or f"HTTP {response.status_code}")
    return payload.get("data")

def require_env():
    for name, value in {
        "INNORIX_EMAIL": EMAIL,
        "INNORIX_PASSWORD": PASSWORD,
        "INNORIX_WORKSPACE_ID": WORKSPACE_ID,
    }.items():
        if not value:
            raise RuntimeError(f"Missing required environment variable: {name}")

def encode_path(device_id, raw_path):
    """Build the `<deviceId>_ino_<base64(path)>` token used by the automation API."""
    normalized = str(raw_path or "").replace("\\", "/")
    token = base64.b64encode(normalized.encode("utf-8")).decode("ascii")
    return f"{device_id}_ino_{token}"

def now_iso():
    return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + "Z"

def generate_transfer_id():
    """Generate a transfer id like 'T1234-5678-9012' from time, pid, and randomness."""
    ts = str(int(time.time() * 1000))
    pid = f"{os.getpid() % 1000:03d}"
    rand = f"{random.randint(0, 999):03d}"
    raw = (ts + pid + rand)[-12:]
    return f"T{raw[0:4]}-{raw[4:8]}-{raw[8:12]}"

def generate_flow_id():
    """Flow id derived from a transfer id, e.g. 'F-1234-5678-9012'."""
    return f"F-{generate_transfer_id()[1:]}"

def build_automation(name, flow_name, sender_id, sender_path, receiver_id, flow_id,
                     trigger_automation_id=None):
    """Build one automation body for a single hop (sender -> receiver).

    Pass trigger_automation_id to chain this automation after another one completes.
    """
    schedule = {
        "type": "none",
        "startDateType": "now",
        "hour": "00",
        "minute": "00",
        "ampm": "am",
        "startDate": now_iso(),
        "timezone": TIMEZONE,
    }
    if trigger_automation_id:
        schedule["triggerAutomation"] = {"value": trigger_automation_id}

    return {
        "name": name,
        "flowName": flow_name,
        "details": [
            {
                "sourceItem": [{"hash": encode_path(sender_id, sender_path), "isDir": False}],
                "targetPath": TARGET_PATH,
                "senderId": sender_id,
                "receiverId": receiver_id,
                "step": 1,
                "transferOptions": {
                    "noSchedule": False,
                    "target-action": "numbering",
                    "send-fileoption": {},
                },
            }
        ],
        "transferType": "normal",
        "isUpcoming": True,
        "timezone": TIMEZONE,
        "schedules": [schedule],
        "flowId": flow_id,
    }

def main():
    require_env()

    login = api("POST", "/api/auth/login", None, {"email": EMAIL, "password": PASSWORD})
    token = login["user"]["accessToken"]

    # Both hops share one flowId so they belong to the same workflow.
    flow_id = generate_flow_id()

    # 1) A -> B.
    transfer_ab = api("POST", "/api/automations", token, build_automation(
        name="A to B Workflow",
        flow_name="A-B Workflow",
        sender_id=SOURCE_ID,
        sender_path=SOURCE_PATH,
        receiver_id=MIDDLE_ID,
        flow_id=flow_id,
    ))
    automation_id = transfer_ab["automationId"]
    print("A->B automation created", automation_id)

    # 2) B -> C, triggered automatically after the A -> B automation completes.
    transfer_bc = api("POST", "/api/automations", token, build_automation(
        name="B to C Workflow",
        flow_name="B-C Workflow",
        sender_id=MIDDLE_ID,
        sender_path=MIDDLE_PATH,
        receiver_id=TARGET_ID,
        flow_id=flow_id,
        trigger_automation_id=automation_id,
    ))
    print("B->C automation created", transfer_bc["automationId"])

    # 3) Inspect the per-step definition and execution history of the A -> B automation.
    print("details", api("GET", f"/api/automations/{automation_id}/details", token))
    print("executions", api("GET", f"/api/automations/{automation_id}/executions", token))

if __name__ == "__main__":
    try:
        main()
    except Exception as error:  # noqa: BLE001
        print(error, file=sys.stderr)
        sys.exit(1)

Source Filter

Description

Select items to send from the source based on conditions. Specify filter options in transferOptions for an automation (POST /api/automations) to filter server-side by extension, size, or name before transfer. If a folder is specified as the source, matching contents below it are collected recursively.

APIs Used

PurposeMethodEndpoint
(Optional) File searchPOST/api/devices/{deviceId}/files/search
Create filtered automationPOST/api/automations

Filter-related transferOptions fields:

FieldTypeDescription
send-filetype-cusstringRegular expression for file types to include (e.g., \\.(log|csv)$)
send-fileoption.fileSizeobjectSize criteria { size, over, equal } (bytes)
send-fileoption.fileNameobjectName pattern { name, allow, isMatchCase } — excluded when allow:false
savepath / optionPathboolean / stringPreserve source folder structure (optionPath: "relative")

Request

POST /api/automations

json
{
  "name": "Source Filter Transfer",
  "flowName": "Source Filter Transfer",
  "transferType": "no_schedule",
  "timezone": "Asia/Seoul",
  "callbackURL": "",
  "details": [
    {
      "sourceItem": [{ "hash": "device-source-01_ino_L3Zhci9sb2c=", "isDir": true }],
      "targetPath": "device-target-01_ino_L2NvbGxlY3QvbG9ncw==",
      "senderId": "device-source-01",
      "receiverId": "device-target-01",
      "step": 1,
      "transferOptions": {
        "noSchedule": true,
        "send-filetype-cus": "\\.(log|csv)$",
        "savepath": true,
        "optionPath": "relative",
        "target-action": "numbering",
        "send-fileoption": {
          "fileSize": { "size": 1024, "over": true, "equal": true },
          "fileName": { "name": "*.tmp", "allow": false, "isMatchCase": false }
        }
      }
    }
  ],
  "schedules": [{ "type": "none", "startDateType": "now", "hour": "05", "minute": "00", "ampm": "pm", "startDate": "2026-08-25T00:00:00Z", "timezone": "Asia/Seoul" }]
}
  • The hash and targetPath in sourceItem are {deviceId}_ino_{base64(path)} tokens. Specify the exact deviceId in senderId·receiverId.
  • send-filetype-cus is a regular expression for types to include; allow:false in send-fileoption.fileName is an exclusion pattern.
  • Use over·equal in send-fileoption.fileSize to define a minimum size criterion.

Response

json
{
  "status_code": 201,
  "message": "Created",
  "data": { "automationId": "auto-abc123" }
}

Processing Steps

  1. (Optional) Check candidate items with POST /api/devices/{deviceId}/files/search
  2. Call POST /api/automations with filter transferOptions
  3. Use data.automationId from the response to check automation details and progress

Implementation Examples

"""Example 09 - Source filter.

Create an automation that transfers only files matching a filter (e.g. *.log / *.csv),
while excluding others (e.g. *.tmp) and applying size rules.

INNORIX_SOURCE_ID / TARGET_ID must be the exact device IDs - they are encoded into the
path token, so a device name will not work here (unlike the manual-transfer examples).

Required environment variables: INNORIX_EMAIL, INNORIX_PASSWORD, INNORIX_WORKSPACE_ID
See ../.env.example for the full list and defaults.

Run:  python "09-source-filter/example.py"
"""

import base64
import os
import sys
from datetime import datetime, timezone
from pathlib import Path

import requests
from dotenv import load_dotenv

# Load .env from the example's own folder first (so a single downloaded example works
# with a .env placed next to it), then fall back to the shared python/.env one level up.
# python-dotenv does not overwrite variables that are already set, so the folder-local
# .env wins and the shared one only fills in whatever is missing.
_here = Path(__file__).resolve().parent
load_dotenv(_here / ".env")          # same folder as this script
load_dotenv(_here.parent / ".env")   # shared python/.env (used by the full example set)

BASE_URL = os.getenv("INNORIX_BASE_URL", "https://app.innorix.com")
EMAIL = os.getenv("INNORIX_EMAIL")
PASSWORD = os.getenv("INNORIX_PASSWORD")
WORKSPACE_ID = os.getenv("INNORIX_WORKSPACE_ID")
SOURCE_ID = os.getenv("INNORIX_SOURCE_ID", "device-source-01")
TARGET_ID = os.getenv("INNORIX_TARGET_ID", "device-target-01")
SOURCE_PATH = os.getenv("INNORIX_SOURCE_PATH", "/var/log").replace("\\", "/")
TARGET_PATH = os.getenv("INNORIX_TARGET_PATH", "/collect/logs").replace("\\", "/")
TIMEZONE = os.getenv("INNORIX_TIMEZONE", "UTC")

def api(method, path, token=None, body=None):
    headers = {"Content-Type": "application/json"}
    if token:
        headers["Authorization"] = f"Bearer {token}"
        headers["x-workspace-id"] = WORKSPACE_ID
    response = requests.request(method, BASE_URL + path, headers=headers, json=body)
    try:
        payload = response.json()
    except ValueError:
        payload = {}
    if not response.ok:
        raise RuntimeError(payload.get("message") or f"HTTP {response.status_code}")
    return payload.get("data")

def require_env():
    for name, value in {
        "INNORIX_EMAIL": EMAIL,
        "INNORIX_PASSWORD": PASSWORD,
        "INNORIX_WORKSPACE_ID": WORKSPACE_ID,
    }.items():
        if not value:
            raise RuntimeError(f"Missing required environment variable: {name}")

def encode_path(device_id, raw_path):
    """Build the `<deviceId>_ino_<base64(path)>` token used by the automation API."""
    normalized = str(raw_path or "").replace("\\", "/")
    token = base64.b64encode(normalized.encode("utf-8")).decode("ascii")
    return f"{device_id}_ino_{token}"

def now_iso():
    return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + "Z"

def main():
    require_env()

    login = api("POST", "/api/auth/login", None, {"email": EMAIL, "password": PASSWORD})
    token = login["user"]["accessToken"]

    automation = api("POST", "/api/automations", token, {
        "name": "Source Filter Transfer",
        "flowName": "Source Filter Transfer",
        "details": [
            {
                "sourceItem": [{"hash": encode_path(SOURCE_ID, SOURCE_PATH), "isDir": True}],
                "targetPath": encode_path(TARGET_ID, TARGET_PATH),
                "senderId": SOURCE_ID,
                "receiverId": TARGET_ID,
                "step": 1,
                "transferOptions": {
                    "noSchedule": True,
                    "send-filetype-cus": r"\.(log|csv)$",  # include only .log and .csv files
                    "savepath": True,
                    "optionPath": "relative",  # keep the folder structure under the target path
                    "target-action": "numbering",  # on conflict, append a number
                    "send-fileoption": {
                        # only files >= 1024 bytes
                        "fileSize": {"size": 1024, "over": True, "equal": True},
                        # exclude *.tmp
                        "fileName": {"name": "*.tmp", "allow": False, "isMatchCase": False},
                    },
                },
            }
        ],
        "transferType": "no_schedule",
        "timezone": TIMEZONE,
        "callbackURL": "",
        "schedules": [
            {
                "type": "none",
                "startDateType": "now",
                "hour": "05",
                "minute": "00",
                "ampm": "pm",
                "startDate": now_iso(),
                "timezone": TIMEZONE,
            }
        ],
    })

    print("automation created", {
        "automationId": automation.get("automationId"),
        "flowName": automation.get("flowName") or automation.get("automationName"),
    })

if __name__ == "__main__":
    try:
        main()
    except Exception as error:  # noqa: BLE001
        print(error, file=sys.stderr)
        sys.exit(1)

Target Options

Description

Control how files are placed on the target. Use transferOptions for an automation (POST /api/automations) to specify a subfolder below the destination path (savePath), the number of source path levels to preserve (optionPath), and conflict handling (target-action).

APIs Used

PurposeMethodEndpoint
Create target-options automationPOST/api/automations
Get automationGET/api/automations/{automationId}

Target-related transferOptions fields:

FieldTypeDescription
savePathstringRelative subfolder to create below the destination path (targetPath) (e.g., /incoming/2026/08)
optionPathnumberNumber of source path levels to preserve
target-actionstringConflict handling policy (e.g., numbering)
globalobjectApply the same options to the entire automation

Request

POST /api/automations

json
{
  "name": "Target Options Transfer",
  "flowName": "Target Options Transfer",
  "transferType": "no_schedule",
  "timezone": "Asia/Seoul",
  "callbackURL": "",
  "details": [
    {
      "sourceItem": [{ "hash": "device-source-01_ino_L3Zhci9sb2c=", "isDir": true }],
      "targetPath": "device-target-01_ino_L2NvbGxlY3QvbG9ncw==",
      "senderId": "device-source-01",
      "receiverId": "device-target-01",
      "step": 1,
      "transferOptions": { "noSchedule": true, "send-fileoption": {}, "target-action": "numbering", "savePath": "/incoming/2026/08", "optionPath": 3 }
    }
  ],
  "global": { "noSchedule": true, "send-fileoption": {}, "target-action": "numbering", "savePath": "/incoming/2026/08", "optionPath": 3 },
  "schedules": [{ "type": "none", "startDateType": "now", "hour": "05", "minute": "00", "ampm": "pm", "startDate": "2026-08-25T00:00:00Z", "timezone": "Asia/Seoul" }]
}
  • savePath is not a full path; it is a relative subfolder created below targetPath.
  • optionPath is the number of source path levels to preserve (integer).
  • Specify the same options in both details[].transferOptions and global.

Response

json
{
  "status_code": 201,
  "message": "Created",
  "data": { "automationId": "auto-abc123" }
}

Processing Steps

  1. Call POST /api/automations with target transferOptions (+global)
  2. Receive data.automationId from the response
  3. Verify the configuration with GET /api/automations/{automationId}

Implementation Examples

"""Example 10 - Target options.

Create an automation that controls how files land on the target: a fixed save path,
conflict numbering, and how much of the source path structure to keep.

INNORIX_SOURCE_ID / TARGET_ID must be the exact device IDs - they are encoded into the
path token, so a device name will not work here (unlike the manual-transfer examples).

Required environment variables: INNORIX_EMAIL, INNORIX_PASSWORD, INNORIX_WORKSPACE_ID
See ../.env.example for the full list and defaults.

Run:  python "10-target-options/example.py"
"""

import base64
import os
import sys
from datetime import datetime, timezone
from pathlib import Path

import requests
from dotenv import load_dotenv

# Load .env from the example's own folder first (so a single downloaded example works
# with a .env placed next to it), then fall back to the shared python/.env one level up.
# python-dotenv does not overwrite variables that are already set, so the folder-local
# .env wins and the shared one only fills in whatever is missing.
_here = Path(__file__).resolve().parent
load_dotenv(_here / ".env")          # same folder as this script
load_dotenv(_here.parent / ".env")   # shared python/.env (used by the full example set)

BASE_URL = os.getenv("INNORIX_BASE_URL", "https://app.innorix.com")
EMAIL = os.getenv("INNORIX_EMAIL")
PASSWORD = os.getenv("INNORIX_PASSWORD")
WORKSPACE_ID = os.getenv("INNORIX_WORKSPACE_ID")
SOURCE_ID = os.getenv("INNORIX_SOURCE_ID", "device-source-01")
TARGET_ID = os.getenv("INNORIX_TARGET_ID", "device-target-01")
SOURCE_PATH = os.getenv("INNORIX_SOURCE_PATH", "/var/log").replace("\\", "/")
TARGET_PATH = os.getenv("INNORIX_TARGET_PATH", "/collect/logs").replace("\\", "/")
# Relative subfolder created under the target path (not a full path), e.g. "/incoming/2026/08".
SAVE_PATH = os.getenv("INNORIX_SAVE_PATH", "/incoming/2026/08")
TIMEZONE = os.getenv("INNORIX_TIMEZONE", "UTC")

def api(method, path, token=None, body=None):
    headers = {"Content-Type": "application/json"}
    if token:
        headers["Authorization"] = f"Bearer {token}"
        headers["x-workspace-id"] = WORKSPACE_ID
    response = requests.request(method, BASE_URL + path, headers=headers, json=body)
    try:
        payload = response.json()
    except ValueError:
        payload = {}
    if not response.ok:
        raise RuntimeError(payload.get("message") or f"HTTP {response.status_code}")
    return payload.get("data")

def require_env():
    for name, value in {
        "INNORIX_EMAIL": EMAIL,
        "INNORIX_PASSWORD": PASSWORD,
        "INNORIX_WORKSPACE_ID": WORKSPACE_ID,
    }.items():
        if not value:
            raise RuntimeError(f"Missing required environment variable: {name}")

def encode_path(device_id, raw_path):
    """Build the `<deviceId>_ino_<base64(path)>` token used by the automation API."""
    normalized = str(raw_path or "").replace("\\", "/")
    token = base64.b64encode(normalized.encode("utf-8")).decode("ascii")
    return f"{device_id}_ino_{token}"

def now_iso():
    return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + "Z"

def main():
    require_env()

    login = api("POST", "/api/auth/login", None, {"email": EMAIL, "password": PASSWORD})
    token = login["user"]["accessToken"]

    # Target options: fixed save path, conflict numbering, and how many path levels to keep.
    target_options = {
        "noSchedule": True,
        "send-fileoption": {},
        "target-action": "numbering",  # on conflict, append a number
        "savePath": SAVE_PATH,  # subfolder created under the target path (relative)
        "optionPath": 3,  # keep this many levels of the source path
    }

    automation = api("POST", "/api/automations", token, {
        "name": "Target Options Transfer",
        "flowName": "Target Options Transfer",
        "details": [
            {
                "sourceItem": [{"hash": encode_path(SOURCE_ID, SOURCE_PATH), "isDir": True}],
                "targetPath": encode_path(TARGET_ID, TARGET_PATH),
                "senderId": SOURCE_ID,
                "receiverId": TARGET_ID,
                "step": 1,
                "transferOptions": target_options,
            }
        ],
        "global": target_options,
        "transferType": "no_schedule",
        "timezone": TIMEZONE,
        "callbackURL": "",
        "schedules": [
            {
                "type": "none",
                "startDateType": "now",
                "hour": "05",
                "minute": "00",
                "ampm": "pm",
                "startDate": now_iso(),
                "timezone": TIMEZONE,
            }
        ],
    })

    print("automation created", {
        "automationId": automation.get("automationId"),
        "flowName": automation.get("flowName") or automation.get("automationName"),
    })

if __name__ == "__main__":
    try:
        main()
    except Exception as error:  # noqa: BLE001
        print(error, file=sys.stderr)
        sys.exit(1)

File Synchronization

Continuously keep the file states of two locations consistent. Use a hot folder for real-time monitoring and automation for periodic synchronization.

Synchronization Overview

Description

Configure synchronization by creating an automation with transferType: "sync". In each details item, specify isSync: true and transferOptions.syncType (1=one-way, 2=two-way) to keep the source and target states consistent.

APIs Used

PurposeMethodEndpoint
Create synchronization automationPOST/api/automations
Get automationGET/api/automations/{automationId}
Delete automationDELETE/api/automations/{automationId}

Request

POST /api/automations

json
{
  "name": "Synchronization Overview",
  "transferType": "sync",
  "timezone": "Asia/Seoul",
  "isUpcoming": false,
  "schedules": [],
  "details": [
    {
      "sourceItem": [{ "hash": "device-source-01_ino_L2RhdGEvc2hhcmVk", "filePath": "/data/shared", "isDir": true, "fileSize": 0 }],
      "targetPath": "device-target-01_ino_L2RhdGEvbWlycm9y",
      "senderId": "device-source-01",
      "receiverId": "device-target-01",
      "step": 1,
      "fileCount": 0,
      "folderCount": 1,
      "sizeCount": 0,
      "isSync": true,
      "transferOptions": { "syncType": 1 }
    }
  ]
}

Response

json
{
  "status_code": 201,
  "message": "Created",
  "data": { "automationId": "auto-abc123" }
}

Processing Steps

  1. Call POST /api/automations with transferType: "sync", details[].isSync: true, and transferOptions.syncType
  2. Receive data.automationId from the response
  3. Check synchronization status with GET /api/automations/{automationId} and DELETE it when no longer needed

Implementation Examples

"""Example 11 - Synchronization overview.

A synchronization is an automation whose detail has isSync=true (syncType 1 = one-way).
This example creates one, reads it back, then deletes it.

INNORIX_SOURCE_ID / TARGET_ID must be the exact device IDs - they are encoded into the
path token, so a device name will not work here (unlike the manual-transfer examples).

Required environment variables: INNORIX_EMAIL, INNORIX_PASSWORD, INNORIX_WORKSPACE_ID
See ../.env.example for the full list and defaults.

Run:  python "11-Synchronization-Overview/example.py"
"""

import base64
import os
import sys
from pathlib import Path
from urllib.parse import quote

import requests
from dotenv import load_dotenv

# Load .env from the example's own folder first (so a single downloaded example works
# with a .env placed next to it), then fall back to the shared python/.env one level up.
# python-dotenv does not overwrite variables that are already set, so the folder-local
# .env wins and the shared one only fills in whatever is missing.
_here = Path(__file__).resolve().parent
load_dotenv(_here / ".env")          # same folder as this script
load_dotenv(_here.parent / ".env")   # shared python/.env (used by the full example set)

BASE_URL = os.getenv("INNORIX_BASE_URL", "https://app.innorix.com")
EMAIL = os.getenv("INNORIX_EMAIL")
PASSWORD = os.getenv("INNORIX_PASSWORD")
WORKSPACE_ID = os.getenv("INNORIX_WORKSPACE_ID")
SOURCE_ID = os.getenv("INNORIX_SOURCE_ID", "device-source-01")
TARGET_ID = os.getenv("INNORIX_TARGET_ID", "device-target-01")
SOURCE_PATH = os.getenv("INNORIX_SOURCE_PATH", "/data/shared").replace("\\", "/")
TARGET_PATH = os.getenv("INNORIX_TARGET_PATH", "/data/mirror").replace("\\", "/")
TIMEZONE = os.getenv("INNORIX_TIMEZONE", "UTC")

def api(method, path, token=None, body=None):
    headers = {"Content-Type": "application/json"}
    if token:
        headers["Authorization"] = f"Bearer {token}"
        headers["x-workspace-id"] = WORKSPACE_ID
    response = requests.request(method, BASE_URL + path, headers=headers, json=body)
    try:
        payload = response.json()
    except ValueError:
        payload = {}
    if not response.ok:
        raise RuntimeError(payload.get("message") or f"HTTP {response.status_code}")
    return payload.get("data")

def require_env():
    for name, value in {
        "INNORIX_EMAIL": EMAIL,
        "INNORIX_PASSWORD": PASSWORD,
        "INNORIX_WORKSPACE_ID": WORKSPACE_ID,
    }.items():
        if not value:
            raise RuntimeError(f"Missing required environment variable: {name}")

def encode_path(device_id, raw_path):
    """Build the `<deviceId>_ino_<base64(path)>` token used by the automation API."""
    normalized = str(raw_path or "").replace("\\", "/")
    token = base64.b64encode(normalized.encode("utf-8")).decode("ascii")
    return f"{device_id}_ino_{token}"

def main():
    require_env()

    login = api("POST", "/api/auth/login", None, {"email": EMAIL, "password": PASSWORD})
    token = login["user"]["accessToken"]

    # 1) Create a one-way synchronization automation (isSync=true, syncType=1).
    print("Create automation")
    automation = api("POST", "/api/automations", token, {
        "name": "Synchronization Overview",
        "transferType": "sync",
        "timezone": TIMEZONE,
        "isUpcoming": False,
        "schedules": [],
        "details": [
            {
                "sourceItem": [
                    {
                        "hash": encode_path(SOURCE_ID, SOURCE_PATH),
                        "filePath": SOURCE_PATH,
                        "isDir": True,
                        "fileSize": 0,
                    }
                ],
                "targetPath": encode_path(TARGET_ID, TARGET_PATH),
                "senderId": SOURCE_ID,
                "receiverId": TARGET_ID,
                "step": 1,
                "fileCount": 0,
                "folderCount": 1,
                "sizeCount": 0,
                "isSync": True,
                "transferOptions": {"syncType": 1},  # 1 = one-way, 2 = two-way
            }
        ],
    })
    automation_id = automation.get("automationId")
    if not automation_id:
        raise RuntimeError("Automation creation response did not include an automationId")
    print("automation created", automation_id)

    # 2) Read the automation back; its status reflects the current sync state.
    print("Get automation")
    print(api("GET", f"/api/automations/{quote(str(automation_id))}", token))

    # 3) Delete the automation (example cleanup).
    print("Delete automation")
    api("DELETE", f"/api/automations/{quote(str(automation_id))}", token)
    print("automation deleted", automation_id)

if __name__ == "__main__":
    try:
        main()
    except Exception as error:  # noqa: BLE001
        print(error, file=sys.stderr)
        sys.exit(1)

Real-Time

Description

When you create a watch folder as a synchronization automation, synchronization is triggered as soon as a file enters the folder. File insertion can be simulated with the Explorer copy API (POST /api/explorer/copyFile/{deviceId}), which is the same call used by paste in the UI Explorer.

APIs Used

PurposeMethodEndpoint
Create watch-folder automationPOST/api/automations
Copy file (trigger)POST/api/explorer/copyFile/{deviceId}

Processing Steps

  1. Create a watch-folder automation with POST /api/automations using transferType: "sync", syncType: 1 (one-way)
  2. When a file is created or copied into the watch folder, synchronization is applied automatically
  3. Trigger it by pasting a file into the watch folder with POST /api/explorer/copyFile/{deviceId}

Implementation Examples

"""Example 12 - Real-time (watch folder).

Create a one-way watch-folder sync automation, then trigger it by copying a file
into the watched source folder (the same call the UI explorer makes on paste).

INNORIX_SOURCE_ID / TARGET_ID must be the exact device IDs - they are encoded into the
path token, so a device name will not work here (unlike the manual-transfer examples).

Required environment variables: INNORIX_EMAIL, INNORIX_PASSWORD, INNORIX_WORKSPACE_ID
See ../.env.example for the full list and defaults.

Run:  python "12-Real-Time/example.py"
"""

import base64
import os
import re
import sys
import time
from pathlib import Path

import requests
from dotenv import load_dotenv

# Load .env from the example's own folder first (so a single downloaded example works
# with a .env placed next to it), then fall back to the shared python/.env one level up.
# python-dotenv does not overwrite variables that are already set, so the folder-local
# .env wins and the shared one only fills in whatever is missing.
_here = Path(__file__).resolve().parent
load_dotenv(_here / ".env")          # same folder as this script
load_dotenv(_here.parent / ".env")   # shared python/.env (used by the full example set)

BASE_URL = os.getenv("INNORIX_BASE_URL", "https://app.innorix.com")
EMAIL = os.getenv("INNORIX_EMAIL")
PASSWORD = os.getenv("INNORIX_PASSWORD")
WORKSPACE_ID = os.getenv("INNORIX_WORKSPACE_ID")
SOURCE_ID = os.getenv("INNORIX_SOURCE_ID", "device-source-01")
TARGET_ID = os.getenv("INNORIX_TARGET_ID", "device-target-01")
SOURCE_PATH = os.getenv("INNORIX_SOURCE_PATH", "/data/watch")
TARGET_PATH = os.getenv("INNORIX_TARGET_PATH", "/data/incoming")
COPY_SOURCE_PATH = os.getenv("INNORIX_COPY_SOURCE_PATH", "/data/report.pdf")
COPY_SOURCE_IS_DIR = os.getenv("INNORIX_COPY_SOURCE_IS_DIR") == "true"
TIMEZONE = os.getenv("INNORIX_TIMEZONE", "UTC")

def api(method, path, token=None, body=None):
    headers = {"Content-Type": "application/json"}
    if token:
        headers["Authorization"] = f"Bearer {token}"
        headers["x-workspace-id"] = WORKSPACE_ID
    response = requests.request(method, BASE_URL + path, headers=headers, json=body)
    try:
        payload = response.json()
    except ValueError:
        payload = {}
    if not response.ok:
        raise RuntimeError(payload.get("message") or f"HTTP {response.status_code}")
    return payload.get("data")

def require_env():
    for name, value in {
        "INNORIX_EMAIL": EMAIL,
        "INNORIX_PASSWORD": PASSWORD,
        "INNORIX_WORKSPACE_ID": WORKSPACE_ID,
    }.items():
        if not value:
            raise RuntimeError(f"Missing required environment variable: {name}")

def encode_path(device_id, raw_path):
    """Build the `<deviceId>_ino_<base64(path)>` token used by the automation and explorer APIs."""
    if not raw_path:
        return ""
    if "_ino_" in raw_path:
        return raw_path  # already encoded
    normalized = str(raw_path).replace("\\", "/")
    token = base64.b64encode(normalized.encode("utf-8")).decode("ascii")
    return f"{device_id}_ino_{token}"

def basename_of(file_path):
    parts = [p for p in re.split(r"[\\/]", file_path) if p]
    return parts[-1] if parts else "item"

def parent_path_of(file_path):
    normalized = file_path.replace("\\", "/")
    index = normalized.rfind("/")
    if index <= 0:
        return f"{normalized[:2]}/" if re.match(r"^[A-Za-z]:", normalized) else "/"
    return normalized[:index]

def now_ms():
    return int(time.time() * 1000)

def main():
    require_env()

    # 1) Log in.
    login = api("POST", "/api/auth/login", None, {"email": EMAIL, "password": PASSWORD})
    token = login["user"]["accessToken"]
    print("Logged in successfully")

    # 2) Create a one-way watch-folder sync automation (syncType 1 = one-way).
    automation = api("POST", "/api/automations", token, {
        "transferType": "sync",
        "timezone": TIMEZONE,
        "isUpcoming": False,
        "schedules": [],
        "details": [
            {
                "sourceItem": [
                    {
                        "hash": encode_path(SOURCE_ID, SOURCE_PATH),
                        "filePath": SOURCE_PATH,
                        "isDir": True,
                        "fileSize": 0,
                    }
                ],
                "targetPath": encode_path(TARGET_ID, TARGET_PATH),
                "senderId": SOURCE_ID,
                "receiverId": TARGET_ID,
                "step": 1,
                "fileCount": 0,
                "folderCount": 1,
                "sizeCount": 0,
                "transferOptions": {"syncType": 1},  # 1 = one-way, 2 = two-way
            }
        ],
    })
    automation_id = automation.get("automationId") or automation.get("id")
    print("Watch folder automation created:", automation_id)

    # 3) Trigger the sync by copying a file into the watched folder.
    #    This mirrors the payload the UI explorer sends on paste.
    file_name = basename_of(COPY_SOURCE_PATH)
    copy_source_parent_path = parent_path_of(COPY_SOURCE_PATH)
    copy_file_payload = {
        "uuid": f"user{now_ms()}",
        "path": encode_path(SOURCE_ID, SOURCE_PATH),  # destination folder to paste into
        "overwrite": False,
        "listFiles": [
            {
                "name": file_name,
                "size": 0,
                "modificationTime": now_ms(),
                "hash": encode_path(SOURCE_ID, COPY_SOURCE_PATH),
                "filePath": COPY_SOURCE_PATH,
                "isDir": COPY_SOURCE_IS_DIR,
                "isTransfer": False,
                "isWait": False,
                "nextTransfer": None,
                "isNew": True,
                "isAccessPermission": True,
                "phash": encode_path(SOURCE_ID, copy_source_parent_path),
            }
        ],
        "statusInfoUrl": f"/explorer/copyFile/{SOURCE_ID}",
    }

    copy_result = api("POST", f"/api/explorer/copyFile/{SOURCE_ID}", token, copy_file_payload)
    print("copyFile result:", copy_result)

if __name__ == "__main__":
    try:
        main()
    except Exception as error:  # noqa: BLE001
        print(error, file=sys.stderr)
        sys.exit(1)

Incremental

Description

Instead of sending everything every time, transfer only files changed since the last synchronization. Configure this by adding change criteria to a recurring automation.

ℹ️ incremental is a preview flag. The request format is finalized, but the actual delta calculation is performed by the agent and may not be enabled depending on the server.

APIs Used

PurposeMethodEndpoint
Create incremental transferPOST/api/transfers/manual (incremental: true)
Periodic synchronizationPOST/api/automations
Get statusGET/api/transfers/{monitorId}

Processing Steps

  1. Send only changes by calling POST /api/transfers/manual with incremental: true (use POST /api/automations for periodic execution)
  2. Select and transfer only files changed since the last synchronization
  3. Verify the applied result with GET /api/transfers/{monitorId}

Implementation Examples

"""Example 13 - Incremental transfer.

Sync a large folder but send only the files that changed since the last run.

NOTE: `incremental` is a preview flag. The request interface is finalized, but the
actual delta calculation is performed by the agent and may not be active on every server.

Required environment variables: INNORIX_EMAIL, INNORIX_PASSWORD, INNORIX_WORKSPACE_ID
See ../.env.example for the full list and defaults.

Run:  python "13-Incremental/example.py"
"""

import os
import sys
import time
from pathlib import Path

import requests
from dotenv import load_dotenv

# Load .env from the example's own folder first (so a single downloaded example works
# with a .env placed next to it), then fall back to the shared python/.env one level up.
# python-dotenv does not overwrite variables that are already set, so the folder-local
# .env wins and the shared one only fills in whatever is missing.
_here = Path(__file__).resolve().parent
load_dotenv(_here / ".env")          # same folder as this script
load_dotenv(_here.parent / ".env")   # shared python/.env (used by the full example set)

BASE_URL = os.getenv("INNORIX_BASE_URL", "https://app.innorix.com")
EMAIL = os.getenv("INNORIX_EMAIL")
PASSWORD = os.getenv("INNORIX_PASSWORD")
WORKSPACE_ID = os.getenv("INNORIX_WORKSPACE_ID")
# INNORIX_SOURCE_DEVICE / TARGET_DEVICE accept a device ID, device name, or IP address.
SOURCE_DEVICE = os.getenv("INNORIX_SOURCE_DEVICE", "device-source-01")
TARGET_DEVICE = os.getenv("INNORIX_TARGET_DEVICE", "device-target-01")
SOURCE_PATH = os.getenv("INNORIX_SOURCE_PATH", "/data/bigfolder")
TARGET_PATH = os.getenv("INNORIX_TARGET_PATH", "/data/incoming")

TRANSFER_STATUS = {
    "transferComplete": 2,
    "transferError": 4,
    "transferCancel": 5,
    "transferPartialComplete": 9,
    "transferFail": 99,
}
TERMINAL_TRANSFER_STATUSES = set(TRANSFER_STATUS.values())

def api(method, path, token=None, body=None):
    headers = {"Content-Type": "application/json"}
    if token:
        headers["Authorization"] = f"Bearer {token}"
        headers["x-workspace-id"] = WORKSPACE_ID
    response = requests.request(method, BASE_URL + path, headers=headers, json=body)
    try:
        payload = response.json()
    except ValueError:
        payload = {}
    if not response.ok:
        raise RuntimeError(payload.get("message") or f"HTTP {response.status_code}")
    return payload.get("data")

def require_env():
    for name, value in {
        "INNORIX_EMAIL": EMAIL,
        "INNORIX_PASSWORD": PASSWORD,
        "INNORIX_WORKSPACE_ID": WORKSPACE_ID,
    }.items():
        if not value:
            raise RuntimeError(f"Missing required environment variable: {name}")

def wait_for_completion(monitor_id, token):
    while True:
        detail = api("GET", f"/api/transfers/{monitor_id}", token)
        is_terminal = detail.get("isTerminal", detail.get("status") in TERMINAL_TRANSFER_STATUSES)
        print({"monitorId": monitor_id, "status": detail.get("status"), "percent": detail.get("percent", 0)})
        if is_terminal:
            if detail.get("status") != TRANSFER_STATUS["transferComplete"]:
                raise RuntimeError(detail.get("errorCode") or "Transfer failed")
            return detail
        time.sleep(2)

def main():
    require_env()

    login = api("POST", "/api/auth/login", None, {"email": EMAIL, "password": PASSWORD})
    token = login["user"]["accessToken"]

    # Sync the whole folder daily, but send only changed files via the `incremental` flag.
    transfer = api("POST", "/api/transfers/manual", token, {
        "sourceDevice": SOURCE_DEVICE,
        "targetDevice": TARGET_DEVICE,
        "targetPath": TARGET_PATH,
        "sourcePaths": [SOURCE_PATH],
        "sendAllFolder": True,
        "incremental": True,  # send only the delta
    })
    print("transfer created", {"monitorId": transfer["monitorId"], "status": transfer.get("status")})

    wait_for_completion(transfer["monitorId"], token)
    print("Transfer completed")

if __name__ == "__main__":
    try:
        main()
    except Exception as error:  # noqa: BLE001
        print(error, file=sys.stderr)
        sys.exit(1)

Advanced Usage

These use cases combine instant transfer, automation, and synchronization for real development scenarios.

Files Outside Git

Description

Transfer and synchronize files that are difficult to manage with Git, including large binaries, media, and datasets, between devices.

APIs Used

PurposeMethodEndpoint
Create transferPOST/api/transfers/manual
Periodic synchronizationPOST/api/automations
Get statusGET/api/transfers/{monitorId}/files

Processing Steps

  1. Specify the managed folder/file as the source path
  2. Use POST /api/transfers/manual for one-time transfer and POST /api/automations for ongoing management
  3. Verify the applied result with GET /api/transfers/{monitorId}/files

Build Artifacts

Description

Deliver build artifacts produced by a CI/CD pipeline to deployment target devices. Trigger the transfer from the pipeline and receive completion notification by webhook.

APIs Used

PurposeMethodEndpoint
Create transferPOST/api/transfers/manual
Automated deploymentPOST/api/automations
Completion notification (webhook)POST/api/automations (processors)

Processing Steps

  1. After the build completes, call POST /api/transfers/manual from the pipeline (for multiple targets, use the File Distribution pattern)
  2. Receive data.monitorId from the response
  3. Receive deployment completion notification through automation processors (webhook) (the Webhook pattern)

AI·Data

Description

Move large data including training datasets and inference results to collection servers or processing nodes. Use transfer completion as a trigger to connect a subsequent pipeline.

APIs Used

PurposeMethodEndpoint
Data collectionPOST/api/transfers/manual
Periodic collectionPOST/api/automations
Subsequent trigger (webhook)POST/api/automations (processors)

Processing Steps

  1. Specify the data location in sourcePaths and call POST /api/transfers/manual (for collection, use the File Collection pattern)
  2. After completion, check status with data.monitorId
  3. Trigger subsequent processing (training·inference) with automation processors (webhook) (the Webhook pattern)

Results·Operations

This section covers status checks, transfer controls, error handling, monitoring, and record management after a transfer is created.

Status·Results

Description

Retrieve per-file status for transfers in progress and results for completed transfers.

APIs Used

PurposeMethodEndpoint
Transfer status·progressGET/api/transfers/{monitorId}
Get in-progress filesGET/api/transfers/{monitorId}/files
Completed history detailsGET/api/transfer-history/{monitorId}
Completed history filesGET/api/transfers/{monitorId}/files?state=history

Response

json
{
  "status_code": 200,
  "message": "success",
  "data": {
    "monitorId": "mon-abc123",
    "status": 2, "statusName": "COMPLETED", "statusLabel": "Complete",
    "files": [
      {
        "path": "/data/report.pdf",
        "fileToken": "L2RhdGEv...",
        "status": 2, "statusName": "DONE", "statusLabel": "Complete",
        "size": 20480
      }
    ]
  }
}

Processing Steps

  1. Check transfer status·progress with GET /api/transfers/{monitorId}status 2=complete, 4·5·9·99=failure and terminate. For per-file progress, use GET /api/transfers/{monitorId}/files
  2. After completion, check the result summary with GET /api/transfer-history/{monitorId}
  3. For per-file details, use GET /api/transfers/{monitorId}/files?state=history

Implementation Examples

"""Example 14 - Status & results.

List failed/error transfers for a device over the last 7 days, walking cursor-based pages.

INNORIX_TARGET_ID must be the exact device ID used by the transfer-history query.

Required environment variables: INNORIX_EMAIL, INNORIX_PASSWORD, INNORIX_WORKSPACE_ID
See ../.env.example for the full list and defaults.

Run:  python "14-status-results/example.py"
"""

import json
import os
import sys
from datetime import datetime, timedelta, timezone
from pathlib import Path
from urllib.parse import urlencode

import requests
from dotenv import load_dotenv

# Load .env from the example's own folder first (so a single downloaded example works
# with a .env placed next to it), then fall back to the shared python/.env one level up.
# python-dotenv does not overwrite variables that are already set, so the folder-local
# .env wins and the shared one only fills in whatever is missing.
_here = Path(__file__).resolve().parent
load_dotenv(_here / ".env")          # same folder as this script
load_dotenv(_here.parent / ".env")   # shared python/.env (used by the full example set)

BASE_URL = os.getenv("INNORIX_BASE_URL", "https://app.innorix.com")
EMAIL = os.getenv("INNORIX_EMAIL")
PASSWORD = os.getenv("INNORIX_PASSWORD")
WORKSPACE_ID = os.getenv("INNORIX_WORKSPACE_ID")
DEVICE_ID = os.getenv("INNORIX_TARGET_ID", "device-target-01")
PAGE_SIZE = int(os.getenv("INNORIX_PAGE_SIZE", "50"))

# Failure statuses (4 = error, 99 = fail).
TRANSFER_STATUS = {"transferError": 4, "transferFail": 99}

def api(method, path, token=None, body=None):
    headers = {"Content-Type": "application/json"}
    if token:
        headers["Authorization"] = f"Bearer {token}"
        headers["x-workspace-id"] = WORKSPACE_ID
    response = requests.request(method, BASE_URL + path, headers=headers, json=body)
    try:
        payload = response.json()
    except ValueError:
        payload = {}
    if not response.ok:
        raise RuntimeError(payload.get("message") or f"HTTP {response.status_code}")
    return payload.get("data")

def require_env():
    for name, value in {
        "INNORIX_EMAIL": EMAIL,
        "INNORIX_PASSWORD": PASSWORD,
        "INNORIX_WORKSPACE_ID": WORKSPACE_ID,
    }.items():
        if not value:
            raise RuntimeError(f"Missing required environment variable: {name}")

def page_items(page):
    """The history endpoint may return a list or an {items}/{data} wrapper - normalize it."""
    if isinstance(page, list):
        return page
    if isinstance(page, dict):
        if isinstance(page.get("items"), list):
            return page["items"]
        if isinstance(page.get("data"), list):
            return page["data"]
    return []

def next_cursor(page):
    if isinstance(page, dict):
        return (page.get("pagination") or {}).get("nextCursor")
    return None

def iso_days_ago(days):
    dt = datetime.now(timezone.utc) - timedelta(days=days)
    return dt.strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + "Z"

def build_history_path(start_date, status_filter, cursor):
    params = {
        "deviceId": DEVICE_ID,
        "statusFilter": status_filter,
        "startDate": start_date,
        "limit": str(PAGE_SIZE),
    }
    if cursor:
        params["cursor"] = json.dumps(cursor)
    return "/api/transfer-history?" + urlencode(params)

def main():
    require_env()

    login = api("POST", "/api/auth/login", None, {"email": EMAIL, "password": PASSWORD})
    token = login["user"]["accessToken"]

    # Look at failed/error transfers for this device over the last 7 days.
    start_date = iso_days_ago(7)
    status_filter = ",".join(str(v) for v in [TRANSFER_STATUS["transferError"], TRANSFER_STATUS["transferFail"]])

    # Walk cursor-based pages and collect every failed transfer.
    failures = []
    cursor = None
    while True:
        page = api("GET", build_history_path(start_date, status_filter, cursor), token)
        items = page_items(page)
        failures.extend(items)
        cursor = next_cursor(page)
        if len(items) < PAGE_SIZE:
            break
        if not cursor:
            break

    print(f"Failed transfers in last 7 days: {len(failures)}")
    for item in failures:
        print({
            "monitorId": item.get("monitorId"),
            "status": item.get("status"),
            "statusName": item.get("statusName"),
            "targetPath": item.get("targetPath"),
            "finishedAt": item.get("finishedAt") or item.get("createdAt") or item.get("sortKey"),
        })

if __name__ == "__main__":
    try:
        main()
    except Exception as error:  # noqa: BLE001
        print(error, file=sys.stderr)
        sys.exit(1)

Transfer Control

Description

Control an in-progress transfer by pausing, resuming, or canceling it. When you start a transfer, a monitorId is issued; use this ID to check status and request pause/resume/cancel. Pause and resume are asynchronous, so pause when the transfer is actually in progress (transferring=6), then resume after the pause has taken effect (transferPause=3).

APIs Used

PurposeMethodEndpoint
Start transferPOST/api/transfers/manual
Get statusGET/api/transfers/{monitorId}/automation-detail
Pause·resume·cancelPOST/api/transfers/{monitorId}/pause · resume · cancel
  • Control actions (pause·resume·cancel) are POST requests with no body.

Processing Steps

  1. Start the transfer with POST /api/transfers/manualmonitorId
  2. Wait until transferring (6), then pause with POST .../pause
  3. Wait until transferPause (3), then resume with POST .../resume (retry a few times if the state has not settled yet)
  4. If needed, cancel with POST .../cancel

Implementation Examples

"""Example 15 - Transfer control.

Create a transfer, then pause it, resume it, and once it is running again, cancel it.

Timing note: pause/resume are asynchronous. Send pause only after the transfer is
actually running (status transferring=6), and resume only after the pause has settled
(status transferPause=3). Resuming too early may be rejected while the state is still
settling, so we poll for the right state and retry resume a few times.

Endpoints:
    POST /api/transfers/manual
    GET  /api/transfers/{monitorId}            (transfer status)
    POST /api/transfers/{monitorId}/pause
    POST /api/transfers/{monitorId}/resume
    POST /api/transfers/{monitorId}/cancel

Required environment variables: INNORIX_EMAIL, INNORIX_PASSWORD, INNORIX_WORKSPACE_ID
See ../.env.example for the full list and defaults.

Run:  python "15-transfer-control/example.py"
"""

import os
import sys
import time
from pathlib import Path

import requests
from dotenv import load_dotenv

# Load .env from the example's own folder first (so a single downloaded example works
# with a .env placed next to it), then fall back to the shared python/.env one level up.
# python-dotenv does not overwrite variables that are already set, so the folder-local
# .env wins and the shared one only fills in whatever is missing.
_here = Path(__file__).resolve().parent
load_dotenv(_here / ".env")          # same folder as this script
load_dotenv(_here.parent / ".env")   # shared python/.env (used by the full example set)

BASE_URL = os.getenv("INNORIX_BASE_URL", "https://app.innorix.com")
EMAIL = os.getenv("INNORIX_EMAIL")
PASSWORD = os.getenv("INNORIX_PASSWORD")
WORKSPACE_ID = os.getenv("INNORIX_WORKSPACE_ID")
# INNORIX_SOURCE_DEVICE / TARGET_DEVICE accept a device ID, device name, or IP address.
SOURCE_DEVICE = os.getenv("INNORIX_SOURCE_DEVICE", "device-source-01")
TARGET_DEVICE = os.getenv("INNORIX_TARGET_DEVICE", "device-target-01")
SOURCE_PATH = os.getenv("INNORIX_SOURCE_PATH", "/data/report.pdf").replace("\\", "/")
TARGET_PATH = os.getenv("INNORIX_TARGET_PATH", "/data/incoming")

TRANSFER_STATUS = {
    "transferComplete": 2,
    "transferPause": 3,
    "transferError": 4,
    "transferCancel": 5,
    "transferring": 6,
    "transferPartialComplete": 9,
    "transferFail": 99,
}
TERMINAL_TRANSFER_STATUSES = {
    TRANSFER_STATUS["transferComplete"],
    TRANSFER_STATUS["transferError"],
    TRANSFER_STATUS["transferCancel"],
    TRANSFER_STATUS["transferPartialComplete"],
    TRANSFER_STATUS["transferFail"],
}

def api(method, path, token=None, body=None):
    headers = {"Content-Type": "application/json"}
    if token:
        headers["Authorization"] = f"Bearer {token}"
        headers["x-workspace-id"] = WORKSPACE_ID
    response = requests.request(method, BASE_URL + path, headers=headers, json=body)
    try:
        payload = response.json()
    except ValueError:
        payload = {}
    if not response.ok:
        raise RuntimeError(payload.get("message") or f"HTTP {response.status_code}")
    return payload.get("data")

def require_env():
    for name, value in {
        "INNORIX_EMAIL": EMAIL,
        "INNORIX_PASSWORD": PASSWORD,
        "INNORIX_WORKSPACE_ID": WORKSPACE_ID,
    }.items():
        if not value:
            raise RuntimeError(f"Missing required environment variable: {name}")

def is_terminal(detail):
    return detail.get("isTerminal", detail.get("status") in TERMINAL_TRANSFER_STATUSES)

def wait_for_status(monitor_id, token, wanted_status):
    """Poll the transfer detail until it reaches wanted_status or any terminal status."""
    while True:
        detail = api("GET", f"/api/transfers/{monitor_id}", token)
        terminal = is_terminal(detail)
        print({
            "monitorId": monitor_id,
            "status": detail.get("status"),
            "statusName": detail.get("statusName"),
            "isTerminal": terminal,
            "percent": detail.get("percent", 0),
        })
        if detail.get("status") == wanted_status or terminal:
            return detail
        time.sleep(2)

def main():
    require_env()

    login = api("POST", "/api/auth/login", None, {"email": EMAIL, "password": PASSWORD})
    token = login["user"]["accessToken"]

    # Create the transfer (sourcePaths is the plain-string convenience form of sourceItem).
    transfer = api("POST", "/api/transfers/manual", token, {
        "sourceDevice": SOURCE_DEVICE,
        "targetDevice": TARGET_DEVICE,
        "targetPath": TARGET_PATH,
        "sourcePaths": [SOURCE_PATH],
    })
    monitor_id = transfer["monitorId"]
    print("transfer created", {"monitorId": monitor_id, "status": transfer.get("status")})

    # Wait until the transfer is actually running before pausing.
    detail = wait_for_status(monitor_id, token, TRANSFER_STATUS["transferring"])
    if is_terminal(detail):
        print("transfer finished before it could be paused (too small/fast)")
        return

    # Pause, then wait until the pause has settled.
    api("POST", f"/api/transfers/{monitor_id}/pause", token)
    detail = wait_for_status(monitor_id, token, TRANSFER_STATUS["transferPause"])
    if is_terminal(detail):
        print("transfer already finished; nothing to resume")
        return

    # Resume, retrying while the server still reports the pause hasn't settled.
    attempt = 0
    while True:
        try:
            api("POST", f"/api/transfers/{monitor_id}/resume", token)
            break
        except Exception as error:  # noqa: BLE001
            if attempt >= 4:
                raise
            print(f"resume not ready yet ({error}); retrying...")
            attempt += 1
            time.sleep(2)

    # Once the resumed transfer is running again, cancel it.
    detail = wait_for_status(monitor_id, token, TRANSFER_STATUS["transferring"])
    if not is_terminal(detail):
        api("POST", f"/api/transfers/{monitor_id}/cancel", token)

    # Wait until the transfer reaches a terminal status.
    detail = wait_for_status(monitor_id, token, TRANSFER_STATUS["transferCancel"])
    print("final", {"status": detail.get("status"), "statusName": detail.get("statusName")})

if __name__ == "__main__":
    try:
        main()
    except Exception as error:  # noqa: BLE001
        print(error, file=sys.stderr)
        sys.exit(1)

Errors·Retry

Description

Classify and inspect failure causes by code, then retransfer only failed files or cancel multiple transfers at once.

APIs Used

PurposeMethodEndpoint
Retry failuresPOST/api/transfers/{monitorId}/retry
Bulk cancelPOST/api/transfers/bulk/cancel
Cancel single transferPOST/api/transfers/{monitorId}/cancel

Request

POST /api/transfers/{monitorId}/retry

json
{
  "filesRetry": ["/data/report.pdf", "/data/image.png"]
}

Response

json
{
  "status_code": 200,
  "message": "success",
  "data": { "monitorId": "mon-abc123", "retried": 2 }
}

Processing Steps

  1. Check failed files with GET /api/transfers/{monitorId}/files
  2. Retransfer with filesRetry in POST /api/transfers/{monitorId}/retry
  3. To clean up multiple transfers, use POST /api/transfers/bulk/cancel (monitorIds)

Implementation Examples

"""Example 16 - Errors.

Find the most recent failed transfer, read its error code, and list the per-file
failure reasons, mapping error codes to a human-readable category.

Required environment variables: INNORIX_EMAIL, INNORIX_PASSWORD, INNORIX_WORKSPACE_ID
See ../.env.example for the full list and defaults.

Run:  python "16-Errors/example.py"
"""

import os
import sys
from pathlib import Path

import requests
from dotenv import load_dotenv

# Load .env from the example's own folder first (so a single downloaded example works
# with a .env placed next to it), then fall back to the shared python/.env one level up.
# python-dotenv does not overwrite variables that are already set, so the folder-local
# .env wins and the shared one only fills in whatever is missing.
_here = Path(__file__).resolve().parent
load_dotenv(_here / ".env")          # same folder as this script
load_dotenv(_here.parent / ".env")   # shared python/.env (used by the full example set)

BASE_URL = os.getenv("INNORIX_BASE_URL", "https://app.innorix.com")
EMAIL = os.getenv("INNORIX_EMAIL")
PASSWORD = os.getenv("INNORIX_PASSWORD")
WORKSPACE_ID = os.getenv("INNORIX_WORKSPACE_ID")

# Failure statuses (4 = error, 99 = fail).
TRANSFER_STATUS = {"transferError": 4, "transferFail": 99}

# Map an error-code prefix to a human-readable cause.
ERROR_CATEGORY = {
    "NETWORK": "Network",
    "PERMISSION": "Permission",
    "CAPACITY": "Capacity",
}

def api(method, path, token=None, body=None):
    headers = {"Content-Type": "application/json"}
    if token:
        headers["Authorization"] = f"Bearer {token}"
        headers["x-workspace-id"] = WORKSPACE_ID
    response = requests.request(method, BASE_URL + path, headers=headers, json=body)
    try:
        payload = response.json()
    except ValueError:
        payload = {}
    if not response.ok:
        raise RuntimeError(payload.get("message") or f"HTTP {response.status_code}")
    return payload.get("data")

def require_env():
    for name, value in {
        "INNORIX_EMAIL": EMAIL,
        "INNORIX_PASSWORD": PASSWORD,
        "INNORIX_WORKSPACE_ID": WORKSPACE_ID,
    }.items():
        if not value:
            raise RuntimeError(f"Missing required environment variable: {name}")

def rows(page):
    """Responses may wrap rows as a list or as {items}/{data}/{children} - normalize it."""
    if isinstance(page, list):
        return page
    if isinstance(page, dict):
        for key in ("items", "data", "children"):
            if isinstance(page.get(key), list):
                return page[key]
    return []

def categorize(error_code):
    if not error_code:
        return "Other"
    return ERROR_CATEGORY.get(error_code.split("_")[0], "Other")

def main():
    require_env()

    login = api("POST", "/api/auth/login", None, {"email": EMAIL, "password": PASSWORD})
    token = login["user"]["accessToken"]

    # 1) Find the single most recent failed transfer.
    status_filter = ",".join(str(v) for v in [TRANSFER_STATUS["transferError"], TRANSFER_STATUS["transferFail"]])
    history = api("GET", f"/api/transfer-history?statusFilter={status_filter}&limit=1", token)
    failures = rows(history)
    if not failures:
        print("No failed transfers found")
        return
    monitor_id = failures[0]["monitorId"]

    # 2) Read the transfer detail and classify its error code.
    detail = api("GET", f"/api/transfers/{monitor_id}", token)
    print({
        "monitorId": monitor_id,
        "status": detail.get("status"),
        "errorCode": detail.get("errorCode"),
        "cause": categorize(detail.get("errorCode")),
    })

    # 3) List the failed files and each file's failure reason.
    files = api("GET", f"/api/transfers/{monitor_id}/files?state=any&size=100", token)
    for file in rows(files):
        print({
            "name": file.get("sourceFileName") or file.get("targetFileName") or file.get("sourceFilePath"),
            "state": file.get("statusName") or file.get("status"),
            "errorCode": file.get("errorCode"),
            "cause": categorize(file.get("errorCode")),
        })

if __name__ == "__main__":
    try:
        main()
    except Exception as error:  # noqa: BLE001
        print(error, file=sys.stderr)
        sys.exit(1)

Integrity Verification

Description

Verify that transferred files are identical to the source. If you specify checkIntegrity: true when creating a transfer, the server verifies the file checksum after transfer.

APIs Used

PurposeMethodEndpoint
Integrity-verified transferPOST/api/transfers/manual (checkIntegrity: true)
Get statusGET/api/transfers/{monitorId}

Request

POST /api/transfers/manual

json
{
  "sourceDevice": "device-source-01",
  "targetDevice": "device-target-01",
  "targetPath": "/data/incoming",
  "sourcePaths": ["/data/report.pdf"],
  "checkIntegrity": true
}

Response

json
{
  "status_code": 201,
  "message": "Created",
  "data": { "monitorId": "mon-abc123", "transferId": "tr-abc123" }
}

Processing Steps

  1. Create a transfer with POST /api/transfers/manual, including checkIntegrity: true
  2. Check status until completion with GET /api/transfers/{monitorId}
  3. After completion, check the verification result (integrityVerified) — if there is a mismatch, correct it by retransferring

Implementation Examples

"""Example 17 - Integrity verification.

Transfer a file with integrity checking enabled, then wait until it completes.
With `checkIntegrity`, the server verifies the file checksum after transfer.

Required environment variables: INNORIX_EMAIL, INNORIX_PASSWORD, INNORIX_WORKSPACE_ID
See ../.env.example for the full list and defaults.

Run:  python "17-Integrity Verification/example.py"
"""

import os
import sys
import time
from pathlib import Path

import requests
from dotenv import load_dotenv

# Load .env from the example's own folder first (so a single downloaded example works
# with a .env placed next to it), then fall back to the shared python/.env one level up.
# python-dotenv does not overwrite variables that are already set, so the folder-local
# .env wins and the shared one only fills in whatever is missing.
_here = Path(__file__).resolve().parent
load_dotenv(_here / ".env")          # same folder as this script
load_dotenv(_here.parent / ".env")   # shared python/.env (used by the full example set)

BASE_URL = os.getenv("INNORIX_BASE_URL", "https://app.innorix.com")
EMAIL = os.getenv("INNORIX_EMAIL")
PASSWORD = os.getenv("INNORIX_PASSWORD")
WORKSPACE_ID = os.getenv("INNORIX_WORKSPACE_ID")
# INNORIX_SOURCE_DEVICE / TARGET_DEVICE accept a device ID, device name, or IP address.
SOURCE_DEVICE = os.getenv("INNORIX_SOURCE_DEVICE", "device-source-01")
TARGET_DEVICE = os.getenv("INNORIX_TARGET_DEVICE", "device-target-01")
SOURCE_PATH = os.getenv("INNORIX_SOURCE_PATH", "/data/report.pdf").replace("\\", "/")
TARGET_PATH = os.getenv("INNORIX_TARGET_PATH", "/data/incoming")

TRANSFER_STATUS = {
    "transferComplete": 2,
    "transferError": 4,
    "transferCancel": 5,
    "transferPartialComplete": 9,
    "transferFail": 99,
}
TERMINAL_TRANSFER_STATUSES = set(TRANSFER_STATUS.values())

def api(method, path, token=None, body=None):
    headers = {"Content-Type": "application/json"}
    if token:
        headers["Authorization"] = f"Bearer {token}"
        headers["x-workspace-id"] = WORKSPACE_ID
    response = requests.request(method, BASE_URL + path, headers=headers, json=body)
    try:
        payload = response.json()
    except ValueError:
        payload = {}
    if not response.ok:
        raise RuntimeError(payload.get("message") or f"HTTP {response.status_code}")
    return payload.get("data")

def require_env():
    for name, value in {
        "INNORIX_EMAIL": EMAIL,
        "INNORIX_PASSWORD": PASSWORD,
        "INNORIX_WORKSPACE_ID": WORKSPACE_ID,
    }.items():
        if not value:
            raise RuntimeError(f"Missing required environment variable: {name}")

def wait_for_completion(monitor_id, token):
    while True:
        detail = api("GET", f"/api/transfers/{monitor_id}", token)
        is_terminal = detail.get("isTerminal", detail.get("status") in TERMINAL_TRANSFER_STATUSES)
        print({
            "monitorId": monitor_id,
            "status": detail.get("status"),
            "statusName": detail.get("statusName"),
            "isTerminal": is_terminal,
            "percent": detail.get("percent", 0),
        })
        if is_terminal:
            if detail.get("status") != TRANSFER_STATUS["transferComplete"]:
                raise RuntimeError(detail.get("errorCode") or "Transfer failed")
            return detail
        time.sleep(2)

def main():
    require_env()

    login = api("POST", "/api/auth/login", None, {"email": EMAIL, "password": PASSWORD})
    token = login["user"]["accessToken"]

    # Start a manual transfer with integrity checking enabled.
    transfer = api("POST", "/api/transfers/manual", token, {
        "sourceDevice": SOURCE_DEVICE,
        "targetDevice": TARGET_DEVICE,
        "targetPath": TARGET_PATH,
        "sourcePaths": [SOURCE_PATH],
        "checkIntegrity": True,  # verify the file checksum after transfer
    })
    print("transfer created", {"monitorId": transfer["monitorId"], "status": transfer.get("status")})

    # Wait until the transfer reaches a terminal status.
    detail = wait_for_completion(transfer["monitorId"], token)
    verified = detail.get("integrityVerified", detail.get("checkIntegrity", True))
    print("Transfer completed", {"integrityVerified": verified})

if __name__ == "__main__":
    try:
        main()
    except Exception as error:  # noqa: BLE001
        print(error, file=sys.stderr)
        sys.exit(1)

Monitoring

Description

Continuously observe transfer, automation, and device status.

APIs Used

PurposeMethodEndpoint
Transfer progressGET/api/transfers/{monitorId}/files
Automation detailsGET/api/automations/{automationId}/details
Device connectivity statusGET/api/devices/{deviceId}/connectivity

Processing Steps

  1. Periodically poll in-progress transfers with GET /api/transfers/{monitorId}/files
  2. Check automation progress with GET /api/automations/{automationId}/details
  3. Check whether devices are online with GET /api/devices/{deviceId}/connectivity

Implementation Examples

"""Example 18 - Monitoring.

Start a transfer, then poll the active-transfers list and print each one's progress,
speed, and estimated remaining time until the transfer we started finishes.

INNORIX_SOURCE_DEVICE / TARGET_DEVICE accept a device ID, device name, or IP address.

Required environment variables: INNORIX_EMAIL, INNORIX_PASSWORD, INNORIX_WORKSPACE_ID
See ../.env.example for the full list and defaults.

Run:  python "18-Monitoring/example.py"
"""

import os
import sys
import time
from pathlib import Path

import requests
from dotenv import load_dotenv

# Load .env from the example's own folder first (so a single downloaded example works
# with a .env placed next to it), then fall back to the shared python/.env one level up.
# python-dotenv does not overwrite variables that are already set, so the folder-local
# .env wins and the shared one only fills in whatever is missing.
_here = Path(__file__).resolve().parent
load_dotenv(_here / ".env")          # same folder as this script
load_dotenv(_here.parent / ".env")   # shared python/.env (used by the full example set)

BASE_URL = os.getenv("INNORIX_BASE_URL", "https://app.innorix.com")
EMAIL = os.getenv("INNORIX_EMAIL")
PASSWORD = os.getenv("INNORIX_PASSWORD")
WORKSPACE_ID = os.getenv("INNORIX_WORKSPACE_ID")
# INNORIX_SOURCE_DEVICE / TARGET_DEVICE accept a device ID, device name, or IP address.
SOURCE_DEVICE = os.getenv("INNORIX_SOURCE_DEVICE", "device-source-01")
TARGET_DEVICE = os.getenv("INNORIX_TARGET_DEVICE", "device-target-01")
SOURCE_PATH = os.getenv("INNORIX_SOURCE_PATH", "/data/report.pdf").replace("\\", "/")
TARGET_PATH = os.getenv("INNORIX_TARGET_PATH", "/data/incoming")
POLL_COUNT = int(os.getenv("INNORIX_POLL_COUNT", "10"))
POLL_INTERVAL_MS = int(os.getenv("INNORIX_POLL_INTERVAL_MS", "2000"))

# Status 6 = transferring (currently active); the rest are terminal (no longer running).
TRANSFER_STATUS = {
    "transferComplete": 2,
    "transferError": 4,
    "transferCancel": 5,
    "transferring": 6,
    "transferPartialComplete": 9,
    "transferFail": 99,
}
TERMINAL_TRANSFER_STATUSES = {
    TRANSFER_STATUS["transferComplete"],
    TRANSFER_STATUS["transferError"],
    TRANSFER_STATUS["transferCancel"],
    TRANSFER_STATUS["transferPartialComplete"],
    TRANSFER_STATUS["transferFail"],
}

def api(method, path, token=None, body=None):
    headers = {"Content-Type": "application/json"}
    if token:
        headers["Authorization"] = f"Bearer {token}"
        headers["x-workspace-id"] = WORKSPACE_ID
    response = requests.request(method, BASE_URL + path, headers=headers, json=body)
    try:
        payload = response.json()
    except ValueError:
        payload = {}
    if not response.ok:
        raise RuntimeError(payload.get("message") or f"HTTP {response.status_code}")
    return payload.get("data")

def require_env():
    for name, value in {
        "INNORIX_EMAIL": EMAIL,
        "INNORIX_PASSWORD": PASSWORD,
        "INNORIX_WORKSPACE_ID": WORKSPACE_ID,
    }.items():
        if not value:
            raise RuntimeError(f"Missing required environment variable: {name}")

def page_items(page):
    """The list endpoint may return a list or an {items}/{data} wrapper - normalize it."""
    if isinstance(page, list):
        return page
    if isinstance(page, dict):
        if isinstance(page.get("items"), list):
            return page["items"]
        if isinstance(page.get("data"), list):
            return page["data"]
    return []

def is_terminal(detail):
    return detail.get("isTerminal", detail.get("status") in TERMINAL_TRANSFER_STATUSES)

def main():
    require_env()

    login = api("POST", "/api/auth/login", None, {"email": EMAIL, "password": PASSWORD})
    token = login["user"]["accessToken"]

    # 1) Start a transfer so there is something to monitor.
    transfer = api("POST", "/api/transfers/manual", token, {
        "sourceDevice": SOURCE_DEVICE,
        "targetDevice": TARGET_DEVICE,
        "targetPath": TARGET_PATH,
        "sourcePaths": [SOURCE_PATH],
        "sendAllFolder": False,
    })
    monitor_id = transfer["monitorId"]
    print("transfer created", {"monitorId": monitor_id, "status": transfer.get("status")})

    # 2) Poll the active-transfers list until the transfer we started reaches a terminal status.
    for tick in range(1, POLL_COUNT + 1):
        active = api("GET", f"/api/transfers?statusFilter={TRANSFER_STATUS['transferring']}&limit=50", token)
        transfers = page_items(active)

        print(f"--- poll {tick}/{POLL_COUNT}: {len(transfers)} active transfers ---")
        for item in transfers:
            print({
                "monitorId": item.get("monitorId"),
                "status": item.get("status"),
                "statusName": item.get("statusName"),
                "percent": item.get("percent", item.get("progress", 0)),
                "transferSpeed": item.get("transferSpeed", 0),
                "estimateTime": item.get("estimateTime", 0),
            })

        # Stop once the transfer we started has finished.
        detail = api("GET", f"/api/transfers/{monitor_id}", token)
        if is_terminal(detail):
            print("monitored transfer finished", {
                "status": detail.get("status"),
                "statusName": detail.get("statusName"),
                "percent": detail.get("percent", 0),
            })
            break

        if tick < POLL_COUNT:
            time.sleep(POLL_INTERVAL_MS / 1000)

if __name__ == "__main__":
    try:
        main()
    except Exception as error:  # noqa: BLE001
        print(error, file=sys.stderr)
        sys.exit(1)

Audit History

Description

Retrieve and export transfer history to retain it as an operational record.

APIs Used

PurposeMethodEndpoint
History detailsGET/api/transfer-history/{monitorId}
Export history (CSV)GET/api/transfer-history/export

Processing Steps

  1. Retrieve an individual transfer record with GET /api/transfer-history/{monitorId}
  2. Filter by period·status·keyword and export CSV with GET /api/transfer-history/export
    • Query: periodDays, status, searchKeyword, page, size, sort

Implementation Examples

"""Example 19 - Audit history.

Resolve a device, list its transfer history for the last 30 days, then read the
file-level audit detail for the most recent record.

INNORIX_SOURCE_DEVICE accepts a device ID, device name, or IP address; the resolve
endpoint turns it into the exact device ID used by the history query.

Required environment variables: INNORIX_EMAIL, INNORIX_PASSWORD, INNORIX_WORKSPACE_ID
See ../.env.example for the full list and defaults.

Run:  python "19-Audit History/example.py"
"""

import os
import sys
from datetime import datetime, timedelta, timezone
from pathlib import Path
from urllib.parse import quote

import requests
from dotenv import load_dotenv

# Load .env from the example's own folder first (so a single downloaded example works
# with a .env placed next to it), then fall back to the shared python/.env one level up.
# python-dotenv does not overwrite variables that are already set, so the folder-local
# .env wins and the shared one only fills in whatever is missing.
_here = Path(__file__).resolve().parent
load_dotenv(_here / ".env")          # same folder as this script
load_dotenv(_here.parent / ".env")   # shared python/.env (used by the full example set)

BASE_URL = os.getenv("INNORIX_BASE_URL", "https://app.innorix.com")
EMAIL = os.getenv("INNORIX_EMAIL")
PASSWORD = os.getenv("INNORIX_PASSWORD")
WORKSPACE_ID = os.getenv("INNORIX_WORKSPACE_ID")
SOURCE_DEVICE = os.getenv("INNORIX_SOURCE_DEVICE", "device-source-01")
HISTORY_DAYS = int(os.getenv("INNORIX_HISTORY_DAYS", "30"))

def api(method, path, token=None, body=None):
    headers = {"Content-Type": "application/json"}
    if token:
        headers["Authorization"] = f"Bearer {token}"
        headers["x-workspace-id"] = WORKSPACE_ID
    response = requests.request(method, BASE_URL + path, headers=headers, json=body)
    try:
        payload = response.json()
    except ValueError:
        payload = {}
    if not response.ok:
        raise RuntimeError(payload.get("message") or f"HTTP {response.status_code}")
    return payload.get("data")

def require_env():
    for name, value in {
        "INNORIX_EMAIL": EMAIL,
        "INNORIX_PASSWORD": PASSWORD,
        "INNORIX_WORKSPACE_ID": WORKSPACE_ID,
    }.items():
        if not value:
            raise RuntimeError(f"Missing required environment variable: {name}")

def page_items(page):
    """The history endpoint may return a list or an {items}/{data} wrapper - normalize it."""
    if isinstance(page, list):
        return page
    if isinstance(page, dict):
        if isinstance(page.get("items"), list):
            return page["items"]
        if isinstance(page.get("data"), list):
            return page["data"]
    return []

def audit_view(records):
    """Keep only the audit-relevant fields for readable output."""
    view = []
    for item in records[:10]:
        view.append({
            "monitorId": item.get("monitorId"),
            "transferId": item.get("transferId") or item.get("id"),
            "sourceDeviceName": item.get("sourceDeviceName"),
            "targetDeviceName": item.get("targetDeviceName"),
            "sourcePath": item.get("sourcePath") or item.get("name") or item.get("path"),
            "targetPath": item.get("targetPath"),
            "status": item.get("status"),
            "statusName": item.get("statusName"),
            "createdAt": item.get("createdAt"),
        })
    return view

def iso_days_ago(days):
    dt = datetime.now(timezone.utc) - timedelta(days=days)
    return dt.strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + "Z"

def main():
    require_env()

    login = api("POST", "/api/auth/login", None, {"email": EMAIL, "password": PASSWORD})
    token = login["user"]["accessToken"]

    # 1) Resolve a device ID / name / IP into its exact device ID.
    device = api("GET", f"/api/devices/resolve?name={quote(SOURCE_DEVICE)}", token)
    # resolve returns {matchCount, devices: [{deviceId, ...}]}; fall back to a flat deviceId.
    devices = (device or {}).get("devices") or []
    device_id = devices[0].get("deviceId") if devices else (device or {}).get("deviceId")
    if not device_id:
        raise RuntimeError(f'No device matched "{SOURCE_DEVICE}"')
    print("resolved device", {"input": SOURCE_DEVICE, "deviceId": device_id})

    # 2) List the device's transfer history for the last N days.
    start_date = iso_days_ago(HISTORY_DAYS)
    history = api(
        "GET",
        f"/api/transfer-history?deviceId={quote(str(device_id))}&startDate={start_date}&limit=50",
        token,
    )
    records = page_items(history)
    print(f"history records in last {HISTORY_DAYS} days: {len(records)}")
    print(audit_view(records))

    # 3) Read file-level audit detail for the most recent record (if any).
    monitor_id = records[0].get("monitorId") if records else None
    if not monitor_id:
        print("No transfer history record was found; nothing to audit.")
        return
    files = api("GET", f"/api/transfers/{monitor_id}/files?state=any&size=100", token)
    # The /files response returns rows under `children` (sourceFileName / statusName fields).
    file_rows = (files or {}).get("children") or page_items(files)
    print(
        f"file-level audit for {monitor_id}",
        [
            {
                "name": f.get("sourceFileName") or f.get("targetFileName") or f.get("sourceFilePath"),
                "state": f.get("statusName") or f.get("status"),
                "status": f.get("status"),
            }
            for f in file_rows
        ],
    )

if __name__ == "__main__":
    try:
        main()
    except Exception as error:  # noqa: BLE001
        print(error, file=sys.stderr)
        sys.exit(1)