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#
- 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 loginAdd 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> # optionalThe 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:/collectedWindows 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)")// 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.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 CollectFromMany {
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 List<String> SOURCE_IDS = envList("SOURCE_IDS", "branch-01,branch-02,branch-03");
static final List<String> SOURCE_PATHS = envList("SOURCE_PATHS", "C:/out"); // 1 = same for all, N = one each
static final String TARGET_PATH = env("TARGET_PATH", "D:/collected");
/** 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("SOURCE_PATHS must have 1 entry or exactly " + count);
}
public static void main(String[] args) throws Exception {
String targetId = env("TARGET_ID", "");
List<String> paths = expand(SOURCE_PATHS, SOURCE_IDS.size());
// Collecting hits name conflicts often - device-name subfolder (optionPath=2) plus rename (numbering)
String options = """
{ "noSchedule": false, "target-action": "numbering", "savepath": true, "optionPath": 2 }
""";
String details = IntStream.range(0, SOURCE_IDS.size())
.mapToObj(i -> """
{
"senderId": "%s",
"receiverId": "%s",
"sourceItem": [{ "filePath": "%s", "isDir": true }],
"targetPath": "%s",
"step": 1,
"transferOptions": %s
}
""".formatted(SOURCE_IDS.get(i), targetId, paths.get(i), TARGET_PATH, options))
.collect(Collectors.joining(","));
String body = """
{
"name": "branch-collect",
"flowName": "branch-collect",
"transferType": "normal",
"timezone": "Asia/Seoul",
"details": [%s],
"schedules": [{
"type": "day", "hour": "02", "minute": "00", "ampm": "am",
"startDateType": "specific", "startDate": "2026-09-15T02:00:00",
"timezone": "Asia/Seoul"
}],
"step": 1,
"isUpcoming": false
}
""".formatted(details);
String res = call("POST", "/api/automations", body);
System.out.println("automation created: " + jsonString(res, "automationId")
+ " (" + SOURCE_IDS.size() + " sources)");
}
}// 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_IDS = envList('SOURCE_IDS', 'branch-01,branch-02,branch-03');
const SOURCE_PATHS = envList('SOURCE_PATHS', 'C:/out'); // 1 = same for all, N = one each
const TARGET_ID = process.env.TARGET_ID;
const TARGET_PATH = process.env.TARGET_PATH || 'D:/collected';
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(`SOURCE_PATHS must have 1 entry or exactly ${count}`);
}
// Collecting hits name conflicts often - device-name subfolder (optionPath=2) plus rename (numbering)
const options = {
noSchedule: false,
'target-action': 'numbering',
savepath: true,
optionPath: 2,
};
const paths = expand(SOURCE_PATHS, SOURCE_IDS.length);
const details = SOURCE_IDS.map((senderId, i) => ({
senderId,
receiverId: TARGET_ID,
sourceItem: [{ filePath: paths[i], isDir: true }],
targetPath: TARGET_PATH,
step: 1,
transferOptions: options,
}));
const { automationId } = await call('POST', '/api/automations', {
name: 'branch-collect',
flowName: 'branch-collect',
transferType: 'normal',
timezone: TZ,
details,
schedules: [{
type: 'day', hour: '02', minute: '00', ampm: 'am',
startDateType: 'specific', startDate: '2026-09-15T02:00:00', timezone: TZ,
}],
step: 1,
isUpcoming: false,
});
console.log(`automation created: ${automationId} (${details.length} sources)`);// .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 CollectFromMany
{
// 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[] SourceIds = EnvList("SOURCE_IDS", "branch-01,branch-02,branch-03");
static readonly string[] SourcePaths = EnvList("SOURCE_PATHS", "C:/out"); // 1 = same for all, N = one each
static readonly string TargetPath = Env("TARGET_PATH", "D:/collected");
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;SourcePaths must have 1 entry or exactly {count}");
}
static async Task Main()
{
var tz = Env("SCHEDULE_TZ", "Asia/Seoul");
var targetId = Env("TARGET_ID");
var paths = Expand(SourcePaths, SourceIds.Length);
// Collecting hits name conflicts often - device-name subfolder (optionPath=2) plus rename (numbering)
var options = new Dictionary<string, object>
{
["noSchedule"] = false,
["target-action"] = "numbering",
["savepath"] = true,
["optionPath"] = 2,
};
var details = SourceIds.Select((senderId, i) => new
{
senderId,
receiverId = targetId,
sourceItem = new[] { new { filePath = paths[i], isDir = true } },
targetPath = TargetPath,
step = 1,
transferOptions = options,
}).ToArray();
var data = await Call("POST", "/api/automations", new
{
name = "branch-collect",
flowName = "branch-collect",
transferType = "normal",
timezone = tz,
details,
schedules = new[]
{
new
{
type = "day", hour = "02", minute = "00", ampm = "am",
startDateType = "specific", startDate = "2026-09-15T02:00:00", timezone = tz,
}
},
step = 1,
isUpcoming = false,
});
Console.WriteLine(quot;automation created: {data.GetProperty("automationId").GetString()} ({details.Length} 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, isTerminalA 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)}%)")// 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;
}
/** 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);
System.out.println(" " + monitorId + ": status=" + status
+ " (" + jsonInt(json, "percent", 0) + "%)");
if (TERMINAL.contains(status)) return status;
Thread.sleep(3000);
}
throw new RuntimeException(monitorId + " did not finish within " + timeoutSeconds + "s");
}
// Usage - a repeating automation shows a transfer only while it is running.
for (String monitorId : monitorIds(automationId)) waitFor(monitorId, 3600);const SKIP_ROW_TYPES = new Set(['automation', 'history', 'flow']);
const TERMINAL = new Set([2, 4, 5, 9, 99]);
async function activeTransfers(automationId) {
const result = await call('GET', '/api/transfers', undefined, { automationId });
const records = Array.isArray(result) ? result : result?.data || [];
return records.filter((r) => !SKIP_ROW_TYPES.has(r.type));
}
for (const row of await activeTransfers(automationId)) {
const mid = row.monitorId || row.id;
const detail = (await call('GET', `/api/transfers/${mid}`)) || {};
console.log(` ${mid}: status=${detail.status} (${detail.percent ?? 0}%)`);
}static readonly HashSet<int> Terminal = new() { 2, 4, 5, 9, 99 };
static readonly HashSet<string> SkipRowTypes = new() { "automation", "history", "flow" };
static async Task PrintActive(string automationId)
{
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) continue;
var detail = await Call("GET", "/api/transfers/" + mid);
Console.WriteLine(quot; {mid}: status={detail.GetProperty("status").GetInt32()}");
}
}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 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. |