INNORIX
Transfer BuilderTransfer FinderDevelopersResourcesCustomers
Start Free
INNORIX

LET FILES
MOVE THEMSELVES

INNORIX provides enterprise file infrastructure for moving and automating files across every system and environment.
Trusted by more than 5,000 enterprise and government agencies.

START HERE

  • Build the Transfer You Need
  • Find the Transfer You Need

POPULAR TRANSFERS

  • Sync Team Folders
  • Send Large Files to Clients
  • Explore Files Across Systems
  • Migrate FTP, SFTP, SCP & rsync
  • Add Transfer to Any App
  • Add Web Upload & Download
  • Build AI & Data Workflows
  • Browse All Transfers→

DEVELOPERS

  • Developer Center
  • Examples
  • API Quickstart
  • Developer Guide
  • API Reference
  • GitHub

RESOURCES

  • Resource Center
  • Product Guide
  • Integrations
  • Deploy & Manage
  • Help Center

CUSTOMERS

  • Government
  • Public Sector
  • Manufacturing
  • Engineering
  • Finance
  • Distribution
  • IT/Telecom
  • Media
  • Healthcare
  • Education

PLANS

  • Pricing

COMPANY

About Us

OTHER INNORIX PRODUCT

Al.bert — Smart Traffic AI

GLOBAL OFFICES

  • New York, USA
  • Seoul, South Korea
  • Ho Chi Minh City, Vietnam
  • View Office Locations→

(C)2026 INNORIX. All rights reserved.

  • Security
  • Status
  • Terms
  • Privacy
  • Cookies
  1. Developers
  2. Examples
  3. Send Files to Many

Send Files to Many

Build a one-to-many file transfer with the INNORIX Transfer Builder. Configure one source, multiple targets, and generate the transfer setup.

Deploy & Manage
Exabyter
  • Send Files to One
  • Send Files to Many
  • Collect Files from Many
  • Sync Files Across Systems
  • Web Upload & Download

Send Files 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
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 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)")
// JDK 17+ (no external dependencies)
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.Arrays;
import java.util.List;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
import java.util.stream.IntStream;

public class SendToMany {

    static final String BASE = env("INNORIX_BASE_URL", "https://app.innorix.com");
    static final String API_KEY = env("INNORIX_API_KEY", "");
    static final HttpClient HTTP = HttpClient.newHttpClient();

    // Every setting comes from an environment variable (second argument is the default).
    static final String SOURCE_PATH = env("SOURCE_PATH", "D:/release/current");
    static final List<String> TARGET_IDS = envList("TARGET_IDS", "branch-01,branch-02,branch-03");
    static final List<String> TARGET_PATHS = envList("TARGET_PATHS", "C:/deploy");  // 1 = same for all, N = one each

    /** Reads an environment variable, falling back to the default when it is empty. */
    static String env(String key, String dflt) {
        String v = System.getenv(key);
        return (v == null || v.isBlank()) ? dflt : v;
    }

    /** Splits a comma-separated environment variable into a list. */
    static List<String> envList(String key, String dflt) {
        return Arrays.stream(env(key, dflt).split(","))
                .map(String::trim).filter(x -> !x.isEmpty()).toList();
    }

    /** Extracts one string field from the response. Like the downloaded example, it needs no
     *  external JSON library. Use Jackson or Gson when you need to walk deeper structures. */
    static String jsonString(String json, String key) {
        Matcher m = Pattern.compile("\"" + key + "\"\\s*:\\s*\"([^\"]*)\"").matcher(json);
        return m.find() ? m.group(1) : null;
    }

    /** Extracts one integer field from the response. */
    static int jsonInt(String json, String key, int dflt) {
        Matcher m = Pattern.compile("\"" + key + "\"\\s*:\\s*(-?\\d+)").matcher(json);
        return m.find() ? Integer.parseInt(m.group(1)) : dflt;
    }

    static String call(String method, String path, String body) throws Exception {
        HttpRequest.BodyPublisher pub = body == null
                ? HttpRequest.BodyPublishers.noBody()
                : HttpRequest.BodyPublishers.ofString(body);
        HttpRequest req = HttpRequest.newBuilder(URI.create(BASE + path))
                .header("Content-Type", "application/json")
                .header("x-api-key", API_KEY)
                .method(method, pub)
                .build();
        HttpResponse<String> res = HTTP.send(req, HttpResponse.BodyHandlers.ofString());
        if (res.statusCode() >= 400) throw new RuntimeException("API " + res.statusCode() + ": " + res.body());
        return res.body();
    }

    static List<String> expand(List<String> paths, int count) {
        if (paths.size() == 1) return IntStream.range(0, count).mapToObj(i -> paths.get(0)).toList();
        if (paths.size() == count) return paths;
        throw new IllegalArgumentException("TARGET_PATHS must have 1 entry or exactly " + count);
    }

    public static void main(String[] args) throws Exception {
        String sourceId = env("SOURCE_ID", "");
        String nowIso = Instant.now().truncatedTo(ChronoUnit.SECONDS).toString().replace("Z", ".000Z");
        List<String> paths = expand(TARGET_PATHS, TARGET_IDS.size());

        String details = IntStream.range(0, TARGET_IDS.size())
                .mapToObj(i -> """
                        {
                          "senderId": "%s",
                          "receiverId": "%s",
                          "sourceItem": [{ "filePath": "%s", "isDir": true }],
                          "targetPath": "%s",
                          "step": 1,
                          "transferOptions": { "noSchedule": false, "target-action": "overwrite" }
                        }
                        """.formatted(sourceId, TARGET_IDS.get(i), SOURCE_PATH, paths.get(i)))
                .collect(Collectors.joining(","));

        String body = """
                {
                  "name": "branch-deploy",
                  "flowName": "branch-deploy",
                  "transferType": "normal",
                  "timezone": "Asia/Seoul",
                  "details": [%s],
                  "schedules": [{
                    "type": "none", "startDateType": "now",
                    "startDate": "%s", "timezone": "Asia/Seoul"
                  }],
                  "step": 1,
                  "isUpcoming": false
                }
                """.formatted(details, nowIso);

        String res = call("POST", "/api/automations", body);
        System.out.println("automation created: " + jsonString(res, "automationId")
                + " (" + TARGET_IDS.size() + " targets)");
    }
}
// Node.js 18+ (uses the built-in fetch)
const BASE = process.env.INNORIX_BASE_URL || 'https://app.innorix.com';
const HEADERS = {
  'x-api-key': process.env.INNORIX_API_KEY,
  'Content-Type': 'application/json',
};

// Every setting comes from an environment variable (second argument is the default).
const envList = (key, dflt) =>
  (process.env[key] || dflt).split(',').map((x) => x.trim()).filter(Boolean);

const TZ = process.env.SCHEDULE_TZ || 'Asia/Seoul';
const SOURCE_ID = process.env.SOURCE_ID;
const SOURCE_PATH = process.env.SOURCE_PATH || 'D:/release/current';
const TARGET_IDS = envList('TARGET_IDS', 'branch-01,branch-02,branch-03');
const TARGET_PATHS = envList('TARGET_PATHS', 'C:/deploy');   // 1 = same for all, N = one each

const nowIso = () => new Date().toISOString().replace(/\.\d{3}Z$/, '.000Z');

async function call(method, path, body, params) {
  let url = BASE + path;
  if (params) url += '?' + new URLSearchParams(params).toString();
  const res = await fetch(url, {
    method,
    headers: HEADERS,
    body: body === undefined ? undefined : JSON.stringify(body),
  });
  const payload = await res.json().catch(() => ({}));
  if (!res.ok) throw new Error(`API ${res.status}: ${JSON.stringify(payload)}`);
  return payload.data;
}

function expand(paths, count) {
  if (paths.length === 1) return Array(count).fill(paths[0]);
  if (paths.length === count) return paths;
  throw new Error(`TARGET_PATHS must have 1 entry or exactly ${count}`);
}

const options = { noSchedule: false, 'target-action': 'overwrite' };
const paths = expand(TARGET_PATHS, TARGET_IDS.length);

const details = TARGET_IDS.map((receiverId, i) => ({
  senderId: SOURCE_ID,
  receiverId,
  sourceItem: [{ filePath: SOURCE_PATH, isDir: true }],
  targetPath: paths[i],
  step: 1,
  transferOptions: options,
}));

const { automationId } = await call('POST', '/api/automations', {
  name: 'branch-deploy',
  flowName: 'branch-deploy',
  transferType: 'normal',
  timezone: TZ,
  details,
  schedules: [{ type: 'none', startDateType: 'now', startDate: nowIso(), timezone: TZ }],
  step: 1,
  isUpcoming: false,
});

console.log(`automation created: ${automationId} (${details.length} targets)`);
// .NET 8+ (standard library only)
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net.Http;
using System.Text;
using System.Text.Json;
using System.Threading.Tasks;

static class SendToMany
{
    // Every setting comes from an environment variable (second argument is the default).
    static string Env(string key, string dflt = "") =>
        Environment.GetEnvironmentVariable(key) is { Length: > 0 } v ? v : dflt;

    static string[] EnvList(string key, string dflt) =>
        Env(key, dflt).Split(',').Select(x => x.Trim()).Where(x => x.Length > 0).ToArray();

    static readonly string Base = Env("INNORIX_BASE_URL", "https://app.innorix.com");
    static readonly HttpClient Http = new();

    static readonly string SourcePath = Env("SOURCE_PATH", "D:/release/current");
    static readonly string[] TargetIds = EnvList("TARGET_IDS", "branch-01,branch-02,branch-03");
    static readonly string[] TargetPaths = EnvList("TARGET_PATHS", "C:/deploy");  // 1 = same for all, N = one each

    static async Task<JsonElement> Call(string method, string path, object? body = null)
    {
        var req = new HttpRequestMessage(new HttpMethod(method), Base + path);
        req.Headers.Add("x-api-key", Env("INNORIX_API_KEY"));
        if (body != null)
            req.Content = new StringContent(JsonSerializer.Serialize(body), Encoding.UTF8, "application/json");

        var res = await Http.SendAsync(req);
        var text = await res.Content.ReadAsStringAsync();
        if (!res.IsSuccessStatusCode) throw new Exception(
quot;API {(int)res.StatusCode}: {text}"
); return JsonDocument.Parse(text).RootElement.GetProperty("data"); } static string[] Expand(string[] paths, int count) { if (paths.Length == 1) return Enumerable.Repeat(paths[0], count).ToArray(); if (paths.Length == count) return paths; throw new ArgumentException(
quot;TargetPaths must have 1 entry or exactly {count}"
); } static async Task Main() { var tz = Env("SCHEDULE_TZ", "Asia/Seoul"); var nowIso = DateTime.UtcNow.ToString("yyyy-MM-ddTHH:mm:ss.000Z"); var sourceId = Env("SOURCE_ID"); var paths = Expand(TargetPaths, TargetIds.Length); var options = new Dictionary<string, object> { ["noSchedule"] = false, ["target-action"] = "overwrite", }; var details = TargetIds.Select((receiverId, i) => new { senderId = sourceId, receiverId, sourceItem = new[] { new { filePath = SourcePath, isDir = true } }, targetPath = paths[i], step = 1, transferOptions = options, }).ToArray(); var data = await Call("POST", "/api/automations", new { name = "branch-deploy", flowName = "branch-deploy", transferType = "normal", timezone = tz, details, schedules = new[] { new { type = "none", startDateType = "now", startDate = nowIso, timezone = tz } }, step = 1, isUpcoming = false, }); Console.WriteLine(
quot;automation created: {data.GetProperty("automationId").GetString()} ({details.Length} 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)
// Reuses call(), jsonString() and jsonInt() from the example above.
import java.util.ArrayList;
import java.util.List;
import java.util.Set;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

static final Set<Integer> TERMINAL = Set.of(2, 4, 5, 9, 99);

/** Collects every monitorId in the list response.
 *  Summary rows (type=automation/history/flow) carry no monitorId, so they drop out. */
static List<String> monitorIds(String automationId) throws Exception {
    String json = call("GET", "/api/transfers?automationId=" + automationId, null);
    List<String> ids = new ArrayList<>();
    Matcher m = Pattern.compile("\"monitorId\"\\s*:\\s*\"([^\"]+)\"").matcher(json);
    while (m.find()) if (!ids.contains(m.group(1))) ids.add(m.group(1));
    return ids;
}

/** Transfers start one after another, so keep polling until every target appears. */
static List<String> collectMonitorIds(String automationId, int expected, int appearWaitSeconds)
        throws Exception {
    List<String> seen = new ArrayList<>();
    long deadline = System.currentTimeMillis() + appearWaitSeconds * 1000L;
    while (true) {
        for (String id : monitorIds(automationId)) if (!seen.contains(id)) seen.add(id);
        if (seen.size() >= expected || System.currentTimeMillis() >= deadline) return seen;
        Thread.sleep(3000);
    }
}

/** Polls every 3 seconds until the transfer reaches a terminal status. */
static int waitFor(String monitorId, int timeoutSeconds) throws Exception {
    long deadline = System.currentTimeMillis() + timeoutSeconds * 1000L;
    while (System.currentTimeMillis() < deadline) {
        String json = call("GET", "/api/transfers/" + monitorId, null);
        int status = jsonInt(json, "status", -1);
        if (TERMINAL.contains(status)) return status;
        Thread.sleep(3000);
    }
    throw new RuntimeException(monitorId + " did not finish within " + timeoutSeconds + "s");
}

// Usage
int failed = 0;
for (String monitorId : collectMonitorIds(automationId, TARGET_IDS.size(), 120)) {
    int status = waitFor(monitorId, 3600);
    System.out.println("  " + monitorId + ": status=" + status);
    if (status != 2) failed++;
}
System.out.println("failed targets: " + failed);
const SKIP_ROW_TYPES = new Set(['automation', 'history', 'flow']);
const TERMINAL = new Set([2, 4, 5, 9, 99]);
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));

async function monitorIds(automationId, expected, appearWaitMs = 120_000) {
  const seen = new Set();
  const deadline = Date.now() + appearWaitMs;
  while (true) {
    const result = await call('GET', '/api/transfers', undefined, { automationId });
    const records = Array.isArray(result) ? result : result?.data || [];
    for (const r of records) {
      if (SKIP_ROW_TYPES.has(r.type)) continue;
      const mid = r.monitorId || r.id;
      if (mid) seen.add(mid);
    }
    if (seen.size >= expected || Date.now() >= deadline) return [...seen];
    await sleep(3000);
  }
}

let failed = 0;
for (const mid of await monitorIds(automationId, details.length)) {
  for (;;) {
    const detail = (await call('GET', `/api/transfers/${mid}`)) || {};
    if (detail.isTerminal ?? TERMINAL.has(detail.status)) {
      console.log(`  ${mid}: status=${detail.status}`);
      if (detail.status !== 2) failed += 1;
      break;
    }
    await sleep(3000);
  }
}
console.log('failed targets:', failed);
static readonly HashSet<int> Terminal = new() { 2, 4, 5, 9, 99 };
static readonly HashSet<string> SkipRowTypes = new() { "automation", "history", "flow" };

static async Task<List<string>> MonitorIds(string automationId, int expected, int appearWaitSeconds = 120)
{
    var seen = new List<string>();
    var deadline = DateTime.UtcNow.AddSeconds(appearWaitSeconds);
    while (true)
    {
        var result = await Call("GET", "/api/transfers?automationId=" + automationId);
        var records = result.ValueKind == JsonValueKind.Object && result.TryGetProperty("data", out var inner)
            ? inner : result;

        foreach (var r in records.EnumerateArray())
        {
            var type = r.TryGetProperty("type", out var t) ? t.GetString() : null;
            if (type != null && SkipRowTypes.Contains(type)) continue;
            var mid = r.TryGetProperty("monitorId", out var m) ? m.GetString()
                    : r.TryGetProperty("id", out var i) ? i.GetString() : null;
            if (mid != null && !seen.Contains(mid)) seen.Add(mid);
        }

        if (seen.Count >= expected || DateTime.UtcNow >= deadline) return seen;
        await Task.Delay(3000);
    }
}

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

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

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 .env API
Tab = Send Files 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.
PreviousSend Files to OneNextCollect Files from Many

On this page

  • Getting started
  • Prerequisites
  • Quick start
  • Create a transfer
  • Create a transfer
  • Check progress
  • Set paths by target
  • Transfer options
  • Start time
  • File options
  • After-transfer actions
  • Reference
  • Builder UI ↔ .env ↔ API mapping
  • Common errors