Send to many — Distribute from one location to many

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

  1. 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.
  2. 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.
  3. Paths — The source path (sourceItem[].filePath) and destination path (targetPath). Both must be absolute paths separated by slashes (/). 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 a workspace. This header is not an authentication method; it specifies the target workspace.

http
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.

  1. Choose options in the Transfer Builder → Get API Code → select a language → download the zip
  2. Extract the archive, open .env, and fill in INNORIX_API_KEY, SOURCE_ID, TARGET_IDS, and the paths (SOURCE_PATH · TARGET_PATHS)
  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 bundled examples. The Java excerpt in this document uses text blocks (""") for readability, so it requires JDK 17+. The bundled ComboBuilder.java works with JDK 11+.

ℹ️ The bundled combo_builder.* reads .env 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 directly, export the values as shown below before running them.

macOS · Linux

bash
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)

powershell
$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.

json
{
  "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-action controls what happens when names conflict. Use one of overwrite (overwrite) · numbering (append a number to the name) · nosend (skip).
  • startDate is 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.

http
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.

text
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 timeschedules[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
  • hour is 1–12, ampm is am / pm, and timezone uses an IANA name like Asia/Seoul.
  • dayInWeek · 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 after creation and then follows the recurrence. With "specific", it starts at the first execution time specified by startDate.

External request requires two preliminary calls.

http
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.

http
POST https://app.innorix.com/command/<code>
x-api-key: <apiKey>

File options

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

OptionKeyValue
Extension filtersend-fileoption.extension{ "extension": ["pdf","mp4"], "allow": true } — use allow: false for a blocklist
Size filtersend-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 filtersend-fileoption.fileName{ "name": "temp", "allow": false } — exclude if the name contains this 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 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. All transfers in this document are created with POST /api/automations, so it still applies when using Start → Now.

ℹ️ Tip — For distribution transfers, target-action is often set to overwrite. If files may be modified at a branch, consider numbering (Rename) or nosend (Skip). File options can be configured separately for each details[] 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 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 rather than the entire workspace. Available events are started · completed · paused · recovered · deviceConnected · deviceDisconnected.

② Workspace-wide 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, 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.envAPI
Tab = Send to manyTRANSFER_TYPE=send_manyN details items
From deviceSOURCE_IDAll details[].senderId (shared)
From pathSOURCE_PATHAll details[].sourceItem[].filePath (shared)
To device listTARGET_IDS (comma-separated)details[].receiverId
To pathTARGET_PATHS (1 or N)details[].targetPath
StartSTART_WHENschedules[0]
File optionsFILTER_* · SAVE_PATH · DUPLICATE_ACTION · INTEGRITYdetails[].transferOptions
After transferON_*processors[] · POST /api/integrations

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

Common errors

SymptomCause and solution
Only some branches receive the transferThe corresponding agent is offline. The automation is functioning normally; check the device connection status.
400 Bad RequestThe entire request is rejected if any targetPath in details is empty or /.
Fewer monitorIds than targetsTransfers start sequentially. Query the list several times and accumulate the results (see monitorIds above).
Path matching is incorrectThe number of TARGET_PATHS entries is neither 1 nor N. Their order must exactly match TARGET_IDS.
Slow with many targetsThe transfers themselves run in parallel. Increasing the polling interval (3 seconds) can reduce API request load.