Send to many distributes the same file/folder from a single sending device to multiple receiving devices. It is used for cases like distributing from headquarters to all branches, or from a master server to all edge servers.
The API requires a single POST /api/automations request. Add one item to details[] for each receiving device, keeping a single sender, and the files are sent to multiple destinations simultaneously.
Getting started
Prerequisites
- API Key — Generate it from the profile menu at the bottom left of the product → Developer. The Workspace ID is also displayed on the same screen. To generate one via API:
POST /api/auth/api-keys(Bearer access token, no body) →data.apiKey. - deviceId — One sending device and N receiving devices. Select a device under Devices in the product, and its ID is displayed at the top right.
- Paths — The source path (
sourceItem[].filePath) and destination path (targetPath). Both must be absolute paths separated by slashes (/).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 a workspace. This header is not an authentication method; it specifies the target workspace.
x-workspace-id: <Workspace ID> # optional
The base URL is https://app.innorix.com.
Quick start
Follow these steps to run the bundle downloaded through Get API Code in the builder.
- Choose options in the Transfer Builder → Get API Code → select a language → download the zip
- Extract the archive, open
.env, and fill inINNORIX_API_KEY,SOURCE_ID,TARGET_IDS, and the paths (SOURCE_PATH·TARGET_PATHS) - 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 bundled examples. The Java excerpt in this document uses text blocks (
""") for readability, so it requires JDK 17+. The bundledComboBuilder.javaworks with JDK 11+.
ℹ️ The bundled
combo_builder.*reads.envdirectly 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 directly, export the values as shown below before running them.
macOS · Linux
export INNORIX_API_KEY=your-api-key
export SOURCE_ID=device-source-01
export SOURCE_PATH=D:/release/current
export TARGET_IDS=branch-01,branch-02,branch-03
export TARGET_PATHS=C:/deploy # 1 entry = same for all, N = one per target
Windows PowerShell (in CMD, use the format set INNORIX_API_KEY=your-api-key)
$env:INNORIX_API_KEY="your-api-key"
$env:SOURCE_ID="device-source-01"
$env:SOURCE_PATH="D:/release/current"
$env:TARGET_IDS="branch-01,branch-02,branch-03"
$env:TARGET_PATHS="C:/deploy"
Create a transfer
Create a transfer
This request distributes to three receiving devices. In each details item, senderId and sourceItem remain the same, while only receiverId · targetPath differ.
{
"name": "branch-deploy",
"flowName": "branch-deploy",
"transferType": "normal",
"timezone": "Asia/Seoul",
"details": [
{
"senderId": "<sourceDeviceId>",
"receiverId": "<branch-01>",
"sourceItem": [{ "filePath": "D:/release/current", "isDir": true }],
"targetPath": "C:/deploy",
"step": 1,
"transferOptions": { "noSchedule": false, "target-action": "overwrite" }
},
{
"senderId": "<sourceDeviceId>",
"receiverId": "<branch-02>",
"sourceItem": [{ "filePath": "D:/release/current", "isDir": true }],
"targetPath": "C:/deploy",
"step": 1,
"transferOptions": { "noSchedule": false, "target-action": "overwrite" }
},
{
"senderId": "<sourceDeviceId>",
"receiverId": "<branch-03>",
"sourceItem": [{ "filePath": "D:/release/current", "isDir": true }],
"targetPath": "C:/deploy",
"step": 1,
"transferOptions": { "noSchedule": false, "target-action": "overwrite" }
}
],
"schedules": [
{ "type": "none", "startDateType": "now", "startDate": "2026-09-14T02:00:00.000Z", "timezone": "Asia/Seoul" }
],
"step": 1,
"isUpcoming": false
}
transferOptions.target-actioncontrols what happens when names conflict. Use one ofoverwrite(overwrite) ·numbering(append a number to the name) ·nosend(skip).startDateis an example value. When running with Now, use the current UTC time at the time of the request (the example code below calculates the current time each time it runs).
The example below expands the list so that one destination path is used for all devices, while N paths are matched in device order. This follows the same TARGET_IDS / TARGET_PATHS rule as the downloaded example (combo_builder.*).
# 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_ID = os.environ["SOURCE_ID"]
SOURCE_PATH = os.getenv("SOURCE_PATH", "D:/release/current")
TARGET_IDS = [x.strip() for x in os.getenv("TARGET_IDS", "branch-01,branch-02,branch-03").split(",") if x.strip()]
TARGET_PATHS = [x.strip() for x in os.getenv("TARGET_PATHS", "C:/deploy").split(",") if x.strip()]
# TARGET_PATHS: 1 entry = same for every device, N = one per TARGET_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):
"""1 path = same for every device, N paths = one per device."""
if len(paths) == 1:
return paths * count
if len(paths) == count:
return paths
raise ValueError(f"TARGET_PATHS must have 1 entry or exactly {count}")
options = {"noSchedule": False, "target-action": "overwrite"}
paths = expand(TARGET_PATHS, len(TARGET_IDS))
details = [{
"senderId": SOURCE_ID,
"receiverId": target_id,
"sourceItem": [{"filePath": SOURCE_PATH, "isDir": True}],
"targetPath": paths[i],
"step": 1,
"transferOptions": options,
} for i, target_id in enumerate(TARGET_IDS)]
body = {
"name": "branch-deploy",
"flowName": "branch-deploy",
"transferType": "normal",
"timezone": TZ,
"details": details,
"schedules": [{"type": "none", "startDateType": "now",
"startDate": now_iso(), "timezone": TZ}],
"step": 1,
"isUpcoming": False,
}
automation_id = call("POST", "/api/automations", body)["automationId"]
print(f"automation created: {automation_id} ({len(details)} targets)")Check progress
If there are N target devices, N transfers are created. Use GET /api/transfers?automationId=<automationId> to collect all monitorId values, then poll each one.
GET /api/transfers?automationId=<automationId> -> rows in data.data[] whose type is not automation|history|flow
GET /api/transfers/<monitorId> → status, percent, isTerminal
Because the remaining transfers continue even if one device fails, it is best to track terminal statuses (2 complete / 4 error / 5 cancelled / 9 partial-complete / 99 fail) separately for each device.
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 monitor_ids(automation_id, expected, appear_wait=120):
"""Polls the list until the transfers start, collecting their monitorIds."""
seen, deadline = [], time.time() + appear_wait
while True:
result = call("GET", "/api/transfers", params={"automationId": automation_id})
records = result.get("data") if isinstance(result, dict) else result
for r in records or []:
if r.get("type") in SKIP_ROW_TYPES:
continue
mid = r.get("monitorId") or r.get("id")
if mid and mid not in seen:
seen.append(mid)
if len(seen) >= expected or time.time() >= deadline:
return seen
time.sleep(3)
failed = 0
for mid in monitor_ids(automation_id, len(details)):
while True:
detail = call("GET", f"/api/transfers/{mid}") or {}
status = detail.get("status")
if detail.get("isTerminal", status in TERMINAL):
print(f" {mid}: {STATUS.get(status, status)} ({detail.get('fileCount', 0)} files)")
if status != 2:
failed += 1
break
time.sleep(3)
print("failed targets:", failed)Set paths by target
If each branch uses a different destination, provide one TARGET_PATHS entry per device.
TARGET_IDS=branch-01,branch-02,branch-03
TARGET_PATHS=C:/deploy,D:/deploy,E:/incoming
The expand() function above matches them in order and assigns them to details[i].targetPath. If the number of paths is neither 1 nor N, it is safer to stop with an error before creating the request.
Transfer options
Start time
The execution time is controlled by schedules[0]. Leave details unchanged and modify only this object.
| Start time | schedules[0] | Notes |
|---|---|---|
| Now | { type: "none", startDateType: "now", startDate: <current ISO> } | Runs immediately after creation |
| Once at a specified time | { type: "none", startDateType: "specific", startDate: "2026-09-20T01:00:00" } | |
| Repeat hourly | { type: "hour", ... } | at the start of every hour |
| Repeat daily | { type: "day", hour, minute, ampm } | |
| Repeat weekly | { type: "week", dayInWeek: ["monday"], hour, minute, ampm } | |
| Repeat monthly | { type: "month", dayInMonth: ["1"], hour, minute, ampm } | 0 means the last day of the month |
| After the previous automation finishes | { type: "none", startDateType: "now", triggerAutomation: { value: "<previous automationId>" } } | Add flowId to the body |
| By external request | { type: "none", startDateType: "now" } + body transferType: "command" | See below |
houris 1–12,ampmisam/pm, andtimezoneuses an IANA name likeAsia/Seoul.dayInWeek·dayInMonthare 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 after creation and then follows the recurrence. With"specific", it starts at the first execution time specified bystartDate.
External request requires two preliminary calls.
POST /api/command/generate-code → data.code
GET /api/command/generate-api-key → data.apiKey
Create the automation with these two values in the body as code · apiKey; each call to the following address then starts the transfer.
POST https://app.innorix.com/command/<code>
x-api-key: <apiKey>
File options
Put file-processing options inside details[].transferOptions.
| Option | Key | Value |
|---|---|---|
| Extension filter | send-fileoption.extension | { "extension": ["pdf","mp4"], "allow": true } — use allow: false for a blocklist |
| Size filter | send-fileoption.fileSize | { "size": <bytes>, "over": true, "equal": true } — use over: true for a lower bound and over: false for an upper bound (only one can be specified) |
| Name filter | send-fileoption.fileName | { "name": "temp", "allow": false } — exclude if the name contains this 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 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. All transfers in this document are created withPOST /api/automations, so it still applies when usingStart → Now.
ℹ️ Tip — For distribution transfers,
target-actionis often set tooverwrite. If files may be modified at a branch, considernumbering(Rename) ornosend(Skip). File options can be configured separately for eachdetails[]item, so you can apply a different policy to specific branches.
After-transfer actions
Actions 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 rather than the entire workspace. Available events are
started·completed·paused·recovered·deviceConnected·deviceDisconnected.
② Workspace-wide 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, Email → email. You can check the required settings for each provider 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 = Send to many | TRANSFER_TYPE=send_many | N details items |
| From device | SOURCE_ID | All details[].senderId (shared) |
| From path | SOURCE_PATH | All details[].sourceItem[].filePath (shared) |
| To device list | TARGET_IDS (comma-separated) | details[].receiverId |
| To path | TARGET_PATHS (1 or N) | details[].targetPath |
| Start | START_WHEN | schedules[0] |
| File options | FILTER_* · SAVE_PATH · DUPLICATE_ACTION · INTEGRITY | details[].transferOptions |
| After transfer | ON_* | processors[] · POST /api/integrations |
If TARGET_IDS is empty, the example uses the single TARGET_ID as a one-item list instead.
Common errors
| Symptom | Cause and solution |
|---|---|
| Only some branches receive the transfer | The corresponding agent is offline. The automation is functioning normally; check the device connection status. |
400 Bad Request | The entire request is rejected if any targetPath in details is empty or /. |
| Fewer monitorIds than targets | Transfers start sequentially. Query the list several times and accumulate the results (see monitorIds above). |
| Path matching is incorrect | The number of TARGET_PATHS entries is neither 1 nor N. Their order must exactly match TARGET_IDS. |
| Slow with many targets | The transfers themselves run in parallel. Increasing the polling interval (3 seconds) can reduce API request load. |