Collect from many — Collecting from multiple locations into one

Collect from many collects files from multiple devices into a single location. Typical use cases include collecting end-of-day data from all branches at headquarters or gathering logs from each production line on an analysis server.

The API request is a single POST /api/automations call. For each item in details[], keep receiverId and targetPath the same, and vary only senderId and sourceItem, and files from multiple locations will be collected into one place.

Collection is often used with Repeat start conditions (daily or weekly), and because collected file names can easily overlap, the Save Path (savepath · optionPath) and Duplicated Name (target-action) settings are especially important.

Getting started

Requirements

  1. API Key — Generate it from the Developer menu under the profile menu at the bottom left of the product. The Workspace ID is also displayed on the same screen. To generate it through the API, use POST /api/auth/api-keys (Bearer access token, no request body) → data.apiKey.
  2. deviceId — N sending devices and 1 receiving device. In Devices, select a device and use the ID displayed at the top right.
  3. Paths — The source path (sourceItem[].filePath) and destination path (targetPath). Use absolute paths separated by slashes (/) for both. targetPath must not be empty or /.

Use one of the following two authentication methods.

http
x-api-key: <API Key>                  # long-lived key (recommended)
Authorization: Bearer <accessToken>   # short-lived token from login

Add one more header only when you need to specify the workspace. This header is not an authentication method; it identifies the target workspace.

http
x-workspace-id: <Workspace ID>        # optional

The base URL is https://app.innorix.com.

Quick start

Use the following steps to run the bundle exactly as downloaded through Get API Code in the builder.

  1. In the Transfer Builder, choose the options, then select Get API Code → choose a language → download the zip file
  2. Extract the archive, open .env, and fill in INNORIX_API_KEY, SOURCE_IDS, TARGET_ID, and the paths (SOURCE_PATHS · TARGET_PATH)
  3. Run it with the command below
  4. Use the returned automationId to check the transfer status
LanguageRequirementsRun
PythonPython 3.8+pip install requestspython combo_builder.py
Node.jsNode.js 18+ (no dependencies)node combo_builder.js
JavaJDK 11+ (no dependencies)java ComboBuilder.java or javac ComboBuilder.java && java ComboBuilder
C#.NET 8+dotnet run

ℹ️ The requirements above apply to the downloaded bundle example. For readability, the Java excerpt in this document uses text blocks (""") and therefore requires JDK 17+. The bundle's ComboBuilder.java runs on JDK 11+.

ℹ️ The bundle's combo_builder.* reads the .env file directly from the same folder (without an additional library). The excerpts in this document, however, read values from environment variables, so if you copy and run them as-is, export the values first as shown below.

macOS · Linux

bash
export INNORIX_API_KEY=your-api-key
export SOURCE_IDS=branch-01,branch-02,branch-03
export SOURCE_PATHS=C:/out             # 1 entry = same for all, N = one per source
export TARGET_ID=device-target-01
export TARGET_PATH=D:/collected

Windows PowerShell (in CMD, use the format set INNORIX_API_KEY=your-api-key)

powershell
$env:INNORIX_API_KEY="your-api-key"
$env:SOURCE_IDS="branch-01,branch-02,branch-03"
$env:SOURCE_PATHS="C:/out"
$env:TARGET_ID="device-target-01"
$env:TARGET_PATH="D:/collected"

Create a transfer

Create a transfer

This request collects data from three branch locations into one headquarters server.

json
{
  "name": "branch-collect",
  "flowName": "branch-collect",
  "transferType": "normal",
  "timezone": "Asia/Seoul",
  "details": [
    {
      "senderId": "<branch-01>",
      "receiverId": "<hqDeviceId>",
      "sourceItem": [{ "filePath": "C:/out", "isDir": true }],
      "targetPath": "D:/collected",
      "step": 1,
      "transferOptions": {
        "noSchedule": false,
        "target-action": "numbering",
        "savepath": true,
        "optionPath": 2
      }
    },
    {
      "senderId": "<branch-02>",
      "receiverId": "<hqDeviceId>",
      "sourceItem": [{ "filePath": "C:/out", "isDir": true }],
      "targetPath": "D:/collected",
      "step": 1,
      "transferOptions": {
        "noSchedule": false,
        "target-action": "numbering",
        "savepath": true,
        "optionPath": 2
      }
    },
    {
      "senderId": "<branch-03>",
      "receiverId": "<hqDeviceId>",
      "sourceItem": [{ "filePath": "C:/out", "isDir": true }],
      "targetPath": "D:/collected",
      "step": 1,
      "transferOptions": {
        "noSchedule": false,
        "target-action": "numbering",
        "savepath": true,
        "optionPath": 2
      }
    }
  ],
  "schedules": [
    { "type": "day", "hour": "02", "minute": "00", "ampm": "am",
      "startDateType": "specific", "startDate": "2026-09-15T02:00:00", "timezone": "Asia/Seoul" }
  ],
  "step": 1,
  "isUpcoming": false
}
  • savepath controls whether the original folder structure is preserved, while optionPath defines the subfolder rule to create beneath it. 1 = date (YYMMDD), 2 = device name, 3 = custom folder (put the folder name in savepath). optionPath does not work on its own and must be sent together with savepath.
  • transferOptions.target-action defines what happens when file names conflict. Use one of overwrite (overwrite), numbering (append a number to the name), or nosend (skip).
  • startDate is an example value. Because this request repeats every day at 02:00 (Asia/Seoul), specify the actual date and time when the recurrence should begin.

In the request above, optionPath: 2 creates a device-name subfolder. Files are separated by branch into folders like D:/collected/branch-01/, D:/collected/branch-02/, and D:/collected/branch-03/, which eliminates file-name conflicts.

# pip install requests
import os, time, requests

BASE = os.getenv("INNORIX_BASE_URL", "https://app.innorix.com")
HEADERS = {"x-api-key": os.environ["INNORIX_API_KEY"], "Content-Type": "application/json"}
# Every setting comes from an environment variable (second argument is the default).
TZ           = os.getenv("SCHEDULE_TZ", "Asia/Seoul")
SOURCE_IDS   = [x.strip() for x in os.getenv("SOURCE_IDS", "branch-01,branch-02,branch-03").split(",") if x.strip()]
SOURCE_PATHS = [x.strip() for x in os.getenv("SOURCE_PATHS", "C:/out").split(",") if x.strip()]
TARGET_ID    = os.environ["TARGET_ID"]
TARGET_PATH  = os.getenv("TARGET_PATH", "D:/collected")
# SOURCE_PATHS: 1 entry = same for every device, N = one per SOURCE_IDS entry

def now_iso():
    return time.strftime("%Y-%m-%dT%H:%M:%S.000Z", time.gmtime())

def call(method, path, body=None, params=None):
    r = requests.request(method, BASE + path, headers=HEADERS,
                         json=body, params=params, timeout=30)
    if not r.ok:
        raise RuntimeError(f"API {r.status_code}: {r.text[:500]}")
    return (r.json() or {}).get("data")

def expand(paths, count):
    if len(paths) == 1:
        return paths * count
    if len(paths) == count:
        return paths
    raise ValueError(f"SOURCE_PATHS must have 1 entry or exactly {count}")

# Collecting hits name conflicts often - a device-name subfolder (optionPath=2) plus rename is recommended.
options = {
    "noSchedule": False,
    "target-action": "numbering",      # Skip=nosend, Overwrite=overwrite
    "savepath": True,
    "optionPath": 2,                   # 1=date (YYMMDD), 2=device name, 3=custom
}

paths = expand(SOURCE_PATHS, len(SOURCE_IDS))
details = [{
    "senderId": source_id,
    "receiverId": TARGET_ID,
    "sourceItem": [{"filePath": paths[i], "isDir": True}],
    "targetPath": TARGET_PATH,
    "step": 1,
    "transferOptions": options,
} for i, source_id in enumerate(SOURCE_IDS)]

body = {
    "name": "branch-collect",
    "flowName": "branch-collect",
    "transferType": "normal",
    "timezone": TZ,
    "details": details,
    # Repeats daily at 02:00 - switch this to a "now" schedule to run immediately.
    "schedules": [{
        "type": "day", "hour": "02", "minute": "00", "ampm": "am",
        "startDateType": "specific", "startDate": "2026-09-15T02:00:00", "timezone": TZ,
    }],
    "step": 1,
    "isUpcoming": False,
}

automation_id = call("POST", "/api/automations", body)["automationId"]
print(f"automation created: {automation_id}  ({len(details)} sources)")

Check progress

If there are N collection sources, there will also be N transfers.

http
GET /api/transfers?automationId=<automationId>     -> rows in data.data[] whose type is not automation|history|flow
GET /api/transfers/<monitorId>                     → status, percent, isTerminal

A repeating (Repeat) automation creates a new transfer each time it runs, so the list may be empty depending on when you query it. Check the automation execution log for results from previous runs.

SKIP_ROW_TYPES = {"automation", "history", "flow"}
TERMINAL = {2, 4, 5, 9, 99}
STATUS = {-1: "queued", 0: "waiting", 1: "started", 2: "complete", 3: "paused",
          4: "error", 5: "cancelled", 6: "transferring", 7: "skipped", 8: "retry",
          9: "partial-complete", 11: "virus-scanning", 12: "syncing", 99: "fail"}

def active_transfers(automation_id):
    result = call("GET", "/api/transfers", params={"automationId": automation_id})
    records = result.get("data") if isinstance(result, dict) else result
    return [r for r in (records or []) if r.get("type") not in SKIP_ROW_TYPES]

for row in active_transfers(automation_id):
    mid = row.get("monitorId") or row.get("id")
    detail = call("GET", f"/api/transfers/{mid}") or {}
    status = detail.get("status")
    print(f"  {mid}: {STATUS.get(status, status)} ({detail.get('percent', 0)}%)")

Transfer options

Start time and recurrence

The execution timing is controlled by a single schedules[0] object. Leave details unchanged and modify only this object.

Execution timingschedules[0]
Run now{ "type": "none", "startDateType": "now", "startDate": "<current ISO>", "timezone": "Asia/Seoul" }
Run once at a specified time{ "type": "none", "startDateType": "specific", "startDate": "2026-09-20T01:00:00", "timezone": "Asia/Seoul" }
Repeat on a scheduleSee the recurrence table below
After the previous automation finishes{ "type": "none", "startDateType": "now", "triggerAutomation": { "value": "<previous automationId>" }, ... } — add flowId to the body
Triggered by an external request{ "type": "none", "startDateType": "now", ... } + body transferType: "command"

To start through an external request, two preliminary calls are required. POST /api/command/generate-codedata.code, GET /api/command/generate-api-keydata.apiKey. Put those two values into code and apiKey in the automation body and create the automation, then call POST https://app.innorix.com/command/<code> with the x-api-key: <apiKey> header to start the collection.

Collection typically repeats at a fixed time. In that case, configure schedules[0] as follows.

Recurrenceschedules[0]
Every hour{ "type": "hour", "startDateType": "specific", "startDate": "...", "timezone": "Asia/Seoul" }
Every day at 02:00{ "type": "day", "hour": "02", "minute": "00", "ampm": "am", ... }
Every Monday{ "type": "week", "dayInWeek": ["monday"], "hour": "02", "minute": "00", "ampm": "am", ... }
On the 1st of every month{ "type": "month", "dayInMonth": ["1"], "hour": "02", "minute": "00", "ampm": "am", ... }
  • hour uses 1–12, and ampm uses am / pm. For type: "hour", the hour value is ignored.
  • dayInWeek and dayInMonth are arrays, so you can provide multiple values like ["monday","wednesday"] or ["1","15"].
  • 0 in dayInMonth means the last day of the month.
  • With startDateType: "now", it runs once immediately when created and then follows the recurrence schedule. With "specific", it starts from the first execution time specified in startDate.

File-name conflicts

The most common collection issue is multiple branches sending files with the same name (daily.csv). There are three options.

MethodSettingResult
Separate by device-name folder (recommended)savepath: true, optionPath: 2D:/collected/branch-01/daily.csv
Separate by date foldersavepath: true, optionPath: 1D:/collected/260915/daily.csv
Rename within the same foldertarget-action: "numbering"daily.csv, daily (1).csv

The target-action values are overwrite (overwrite), numbering (append a number to the name), and nosend (skip).

To preserve only the original folder structure without creating a subfolder, set only savepath: true and omit optionPath.

File options

Place file-processing options inside details[].transferOptions.

OptionKeyValue
Extension filtersend-fileoption.extension{ "extension": ["pdf","mp4"], "allow": true } — use allow: false for a block list
Size filtersend-fileoption.fileSize{ "size": <bytes>, "over": true, "equal": true } — use over: true for a lower bound or over: false for an upper bound (only one can be specified)
Name filtersend-fileoption.fileName{ "name": "temp", "allow": false } — exclude files whose names contain the value
Preserve folder structuresavepathtrue
Date subfoldersavepath + optionPathtrue + 1
Device-name subfoldersavepath + optionPathtrue + 2
Custom subfoldersavepath + optionPath"<folder name>" + 3
Duplicate name — overwritetarget-action"overwrite"
Duplicate name — append a numbertarget-action"numbering"
Duplicate name — skiptarget-action"nosend"
Integrity verificationcheckIntegritytrue
json
{
  "noSchedule": false,
  "target-action": "numbering",
  "checkIntegrity": true,
  "savepath": true,
  "optionPath": 1,
  "send-fileoption": {
    "extension": { "extension": ["pdf", "xlsx"], "allow": true },
    "fileSize": { "size": 1048576, "over": true, "equal": true },
    "fileName": { "name": "tmp", "allow": false }
  }
}

ℹ️ optionPath (date · device name · custom subfolder) applies only to automations. Because every transfer in this document is created with POST /api/automations, it still applies when using Start → Now.

Actions after transfer

Actions that run after a transfer completes fall into two categories.

① Processors attached to the automationprocessors[] in the body

json
{
  "processors": [
    { "events": "Run", "type": "https", "method": "POST",
      "url": "https://api.example.com/webhook", "body": "{\"event\":\"done\"}" },
    { "category": "monitoring", "type": "grafana", "name": "builder-grafana",
      "config": { "baseUrl": "https://grafana.company.com", "apiToken": "***" },
      "notificationConfig": { "events": { "completed": true, "error": true } } }
  ]
}
  • Run API — An HTTP hook called for each transfer.
  • Monitoring (Grafana · Datadog · Prometheus, etc.) — Attached to this automation, not globally to the workspace. Available events are started · completed · paused · recovered · deviceConnected · deviceDisconnected.

② Workspace-level integrationsPOST /api/integrations

Message (Slack · Teams · Discord …), Virus scan (ClamAV · Microsoft Defender …), and Email (SES · SendGrid) are registered at the workspace level rather than on an individual transfer.

json
{
  "name": "builder-slack",
  "type": "slack",
  "category": "notification",
  "config": { "webhookUrl": "https://hooks.slack.com/services/XXX", "channel": "#transfers" },
  "notificationConfig": { "events": { "completed": true, "error": true } }
}

category is Message → notification, Virus scan → security, and Email → email. You can check provider-specific required settings with GET /api/integrations/rules/{type}. Event names are started · completed · paused · resumed · recovered · canceled · error · skipped.

Reference

Builder UI ↔ .env ↔ API mapping

Builder UI.envAPI
Tab = Collect from manyTRANSFER_TYPE=collectN details entries
From device listSOURCE_IDS (comma-separated)details[].senderId
From pathSOURCE_PATHS (1 entry or N entries)details[].sourceItem[].filePath
To deviceTARGET_IDall details[].receiverId values (shared)
To pathTARGET_PATHall details[].targetPath values (shared)
StartSTART_WHEN · REPEAT_*schedules[0]
Save PathSAVE_PATHsavepath · optionPath
Duplicated NameDUPLICATE_ACTIONtarget-action
After transferON_*processors[] · POST /api/integrations

If SOURCE_IDS is empty, the example uses a single SOURCE_ID as a one-item list instead.

Common errors

SymptomCause and solution
Files overwrite one anotherThe default target-action is overwrite. Change it to numbering or separate files with optionPath: 2.
Subfolders are not createdoptionPath does not work on its own. You must also send savepath: true.
Only some branches are collectedThe relevant agent is offline, or sourceItem[].filePath does not exist on that device.
A repeating job runs only onceIf you send isUpcoming: true, the server converts it into a one-time scheduled job. Keep it false.
The first run starts immediatelystartDateType: "now" runs once immediately when created. If you do not want that, use "specific" and provide the first execution time.