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
- 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. - deviceId — N sending devices and 1 receiving device. In Devices, select a device and use the ID displayed at the top right.
- Paths — The source path (
sourceItem[].filePath) and destination path (targetPath). Use absolute paths separated by slashes (/) for both.targetPathmust not be empty or/.
Use one of the following two authentication methods.
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.
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.
- In the Transfer Builder, choose the options, then select Get API Code → choose a language → download the zip file
- Extract the archive, open
.env, and fill inINNORIX_API_KEY,SOURCE_IDS,TARGET_ID, and the paths (SOURCE_PATHS·TARGET_PATH) - Run it with the command below
- Use the returned
automationIdto 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'sComboBuilder.javaruns on JDK 11+.
ℹ️ The bundle's
combo_builder.*reads the.envfile 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
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)
$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.
{
"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
}
savepathcontrols whether the original folder structure is preserved, whileoptionPathdefines the subfolder rule to create beneath it.1= date (YYMMDD),2= device name,3= custom folder (put the folder name insavepath).optionPathdoes not work on its own and must be sent together withsavepath.transferOptions.target-actiondefines what happens when file names conflict. Use one ofoverwrite(overwrite),numbering(append a number to the name), ornosend(skip).startDateis 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.
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", ... } |
houruses 1–12, andampmusesam/pm. Fortype: "hour", thehourvalue is ignored.dayInWeekanddayInMonthare arrays, so you can provide multiple values like["monday","wednesday"]or["1","15"].0indayInMonthmeans 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 instartDate.
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 |
{
"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 withPOST /api/automations, it still applies when usingStart → Now.
Actions after transfer
Actions that run after a transfer completes fall into two categories.
① Processors attached to the automation — processors[] in the body
{
"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.
{
"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 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. |