Collect Files from Many

Build a many-to-one file collection workflow with the INNORIX Transfer Builder. Configure multiple sources, one target, and generate the setup.

Collect Files 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
Language Requirements Run
Python Python 3.8+ pip install requests → python combo_builder.py
Node.js Node.js 18+ (no dependencies) node combo_builder.js
Java JDK 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 timing schedules[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 schedule See 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-code → data.code, GET /api/command/generate-api-key → data.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.

Recurrence schedules[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.

Method Setting Result
Separate by device-name folder (recommended) savepath: true, optionPath: 2 D:/collected/branch-01/daily.csv
Separate by date folder savepath: true, optionPath: 1 D:/collected/260915/daily.csv
Rename within the same folder target-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.

Option Key Value
Extension filter send-fileoption.extension { "extension": ["pdf","mp4"], "allow": true } — use allow: false for a block list
Size filter send-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 filter send-fileoption.fileName { "name": "temp", "allow": false } — exclude files whose names contain the value
Preserve folder structure savepath true
Date subfolder savepath + optionPath true + 1
Device-name subfolder savepath + optionPath true + 2
Custom subfolder savepath + optionPath "<folder name>" + 3
Duplicate name — overwrite target-action "overwrite"
Duplicate name — append a number target-action "numbering"
Duplicate name — skip target-action "nosend"
Integrity verification checkIntegrity true
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 automation — processors[] 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 integrations — POST /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 .env API
Tab = Collect Files from Many TRANSFER_TYPE=collect N details entries
From device list SOURCE_IDS (comma-separated) details[].senderId
From path SOURCE_PATHS (1 entry or N entries) details[].sourceItem[].filePath
To device TARGET_ID all details[].receiverId values (shared)
To path TARGET_PATH all details[].targetPath values (shared)
Start START_WHEN, REPEAT_* schedules[0]
Save Path SAVE_PATH savepath, optionPath
Duplicated Name DUPLICATE_ACTION target-action
After transfer ON_* processors[], POST /api/integrations

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

Common errors#

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