여러 곳으로 파일 보내기는 보내는 디바이스 한 대에서 여러 대의 받는 디바이스로 같은 파일/폴더를 배포합니다. 본사에서 전 지점으로 배포, 마스터 서버에서 엣지 서버 전체로 배포 같은 경우에 쓰입니다.
API 요청은 POST /api/automations 한 번입니다.
details[] 에 받는 디바이스 수만큼 항목을 넣으면, 보내는 쪽은 하나로 두고 여러 곳으로 동시에 나갑니다.
시작하기#
준비물#
- API Key — 제품 좌측 하단 프로필 메뉴 → Developer 에서 발급합니다.
같은 화면에 Workspace ID 도 함께 표시됩니다.
API 로 발급하려면
POST /api/auth/api-keys(Bearer 액세스 토큰, 바디 없음) →data.apiKey. - deviceId — 보내는 디바이스 1개와 받는 디바이스 N개. 제품의 Devices 에서 디바이스를 선택하면 우측 상단에 표시되는 ID 입니다.
- 경로 — 보낼 쪽 경로(
sourceItem[].filePath)와 받을 쪽 경로(targetPath). 둘 다 슬래시(/)로 구분한 절대 경로를 사용합니다.targetPath는 비어 있거나/이면 안 됩니다.
인증은 다음 두 가지 중 하나를 사용합니다.
x-api-key: <API Key> # long-lived key (recommended)
Authorization: Bearer <accessToken> # short-lived token from login워크스페이스를 명시해야 하는 경우에만 헤더를 하나 더 추가합니다. 이 헤더는 인증 수단이 아니라 대상 워크스페이스 지정용입니다.
x-workspace-id: <Workspace ID> # optional기본 주소는 https://app.innorix.com 입니다.
빠른 시작#
빌더에서 Get API Code 로 받은 번들을 그대로 실행하는 순서입니다.
- 전송 빌더에서 옵션을 고르고 Get API Code → 언어 선택 → zip 다운로드
- 압축을 풀고
.env를 열어INNORIX_API_KEY,SOURCE_ID,TARGET_IDS와 경로(SOURCE_PATH·TARGET_PATHS)를 채웁니다 - 아래 명령으로 실행합니다
- 출력된
automationId로 전송 상태를 조회합니다
| 언어 | 요구 사항 | 실행 |
|---|---|---|
| Python | Python 3.8+ | pip install requests → python combo_builder.py |
| Node.js | Node.js 18+ (의존성 없음) | node combo_builder.js |
| Java | JDK 11+ (의존성 없음) | java ComboBuilder.java 또는 javac ComboBuilder.java && java ComboBuilder |
| C# | .NET 8+ | dotnet run |
ℹ️ 위 요구 사항은 번들 예제 기준입니다. 이 문서에 실린 Java 발췌 코드는 가독성을 위해 텍스트 블록(
""")을 사용하므로 JDK 17+ 가 필요합니다. 번들의ComboBuilder.java는 JDK 11+ 에서 동작합니다.
ℹ️ 번들의
combo_builder.*는 같은 폴더의.env를 직접 읽습니다(별도 라이브러리 없이). 반면 이 문서에 실린 발췌 코드는 환경 변수에서 값을 읽으므로, 그대로 복사해 실행할 때는 아래처럼 값을 내보낸 뒤 실행하세요.
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 targetWindows PowerShell (CMD 에서는 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"전송 만들기#
전송 만들기#
받는 디바이스 3대로 배포하는 요청입니다. details 의 항목마다
senderId 와 sourceItem 은 같고 receiverId · targetPath 만 달라집니다.
{
"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은 이름이 겹칠 때의 동작입니다.overwrite(덮어쓰기) ·numbering(이름 뒤에 번호) ·nosend(건너뛰기) 중 하나를 넣습니다.startDate는 예시 값입니다. Now 로 실행할 때는 요청 시점의 현재 UTC 시각을 넣으세요 (아래 예제 코드는 실행할 때마다 현재 시각을 계산합니다).
아래 예제는 받는 경로가 1개면 전부 같은 경로, N개면 디바이스 순서대로 매칭하는 방식으로
목록을 펼칩니다. 내려받은 예제(combo_builder.*)의 TARGET_IDS / TARGET_PATHS 규칙과 같습니다.
# 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)");
}
}진행 상황 확인#
배포 대상이 N대면 전송도 N건이 생깁니다.
GET /api/transfers?automationId=<automationId> 로 monitorId 를 모두 모은 뒤 각각 폴링합니다.
GET /api/transfers?automationId=<automationId> -> rows in data.data[] whose type is not automation|history|flow
GET /api/transfers/<monitorId> → status, percent, isTerminal한 대만 실패해도 나머지는 계속 진행되므로, 종료 상태(2 완료 / 4 오류 / 5 취소 / 9 부분완료 / 99 실패)를
디바이스별로 따로 집계하는 편이 좋습니다.
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);
}
}대상별 경로 지정#
지점마다 저장 위치가 다르면 TARGET_PATHS 를 디바이스 수만큼 넣습니다.
TARGET_IDS=branch-01,branch-02,branch-03
TARGET_PATHS=C:/deploy,D:/deploy,E:/incoming위 예제의 expand() 가 순서대로 매칭해 details[i].targetPath 에 넣습니다.
경로 수가 1개도 N개도 아니면 요청을 만들기 전에 오류로 끊는 편이 안전합니다.
전송 옵션#
시작 시점#
실행 시점은 schedules[0] 하나로 정합니다. details 는 그대로 두고 이 객체만 바꾸면 됩니다.
| 실행 시점 | schedules[0] |
비고 |
|---|---|---|
| 지금 바로 | { type: "none", startDateType: "now", startDate: <현재 ISO> } |
생성 즉시 실행 |
| 지정 시각에 1회 | { type: "none", startDateType: "specific", startDate: "2026-09-20T01:00:00" } |
|
| 매시 반복 | { type: "hour", ... } |
매시 정각 |
| 매일 반복 | { type: "day", hour, minute, ampm } |
|
| 매주 반복 | { type: "week", dayInWeek: ["monday"], hour, minute, ampm } |
|
| 매월 반복 | { type: "month", dayInMonth: ["1"], hour, minute, ampm } |
0 은 말일 |
| 이전 자동화가 끝난 뒤 | { type: "none", startDateType: "now", triggerAutomation: { value: "<이전 automationId>" } } |
바디에 flowId 추가 |
| 외부 요청으로 | { type: "none", startDateType: "now" } + 바디 transferType: "command" |
아래 참고 |
hour는 1–12,ampm은am/pm,timezone은Asia/Seoul같은 IANA 이름입니다.dayInWeek·dayInMonth는 배열이라["monday","wednesday"],["1","15"]처럼 여러 개를 넣을 수 있습니다.dayInMonth의0은 말일입니다.startDateType: "now"면 생성 즉시 한 번 실행하고 이후 주기대로,"specific"이면startDate로 지정한 첫 실행 시각부터 시작합니다.
External request 는 두 번의 사전 호출이 필요합니다.
POST /api/command/generate-code → data.code
GET /api/command/generate-api-key → data.apiKey두 값을 자동화 바디의 code · apiKey 로 넣어 생성하면, 다음 주소를 호출할 때마다 전송이 시작됩니다.
POST https://app.innorix.com/command/<code>
x-api-key: <apiKey>파일 옵션#
파일 처리 옵션은 details[].transferOptions 안에 넣습니다.
| 옵션 | 키 | 값 |
|---|---|---|
| 확장자 필터 | send-fileoption.extension |
{ "extension": ["pdf","mp4"], "allow": true } — 차단 목록이면 allow: false |
| 크기 필터 | send-fileoption.fileSize |
{ "size": <바이트>, "over": true, "equal": true } — 하한은 over: true, 상한은 over: false (한쪽만 지정 가능) |
| 이름 필터 | send-fileoption.fileName |
{ "name": "temp", "allow": false } — 이름에 포함되면 제외 |
| 폴더 구조 유지 | savepath |
true |
| 날짜 하위 폴더 | savepath + optionPath |
true + 1 |
| 디바이스명 하위 폴더 | savepath + optionPath |
true + 2 |
| 사용자 지정 하위 폴더 | savepath + optionPath |
"<폴더명>" + 3 |
| 중복 이름 — 덮어쓰기 | target-action |
"overwrite" |
| 중복 이름 — 이름 뒤에 번호 | target-action |
"numbering" |
| 중복 이름 — 건너뛰기 | target-action |
"nosend" |
| 무결성 검증 | 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(날짜 · 디바이스명 · 사용자 지정 하위 폴더)는 자동화에서만 적용됩니다. 이 문서의 모든 전송은POST /api/automations로 만들어지므로Start → Now여도 그대로 적용됩니다.
ℹ️ 팁 — 배포형 전송에서는
target-action을overwrite로 두는 경우가 많습니다. 지점에서 파일을 수정할 여지가 있다면numbering(Rename) 이나nosend(Skip) 를 검토하세요. 파일 옵션은details[]항목마다 따로 줄 수 있으므로, 특정 지점에만 다른 정책을 적용할 수도 있습니다.
전송 후 동작#
전송이 끝난 뒤의 동작은 두 갈래로 나뉩니다.
① 자동화에 붙는 프로세서 — 바디의 processors[]
{
"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 — 전송마다 호출되는 HTTP 훅입니다.
- Monitoring(Grafana · Datadog · Prometheus 등) — 워크스페이스 전역이 아니라 이 자동화에 붙습니다.
선택 가능한 이벤트는
started·completed·paused·recovered·deviceConnected·deviceDisconnected입니다.
② 워크스페이스 전역 연동 — POST /api/integrations
Message(Slack · Teams · Discord …), Virus scan(ClamAV · Microsoft Defender …), Email(SES · SendGrid) 은 전송이 아니라 워크스페이스에 등록됩니다.
{
"name": "builder-slack",
"type": "slack",
"category": "notification",
"config": { "webhookUrl": "https://hooks.slack.com/services/XXX", "channel": "#transfers" },
"notificationConfig": { "events": { "completed": true, "error": true } }
}category 는 Message → notification, Virus scan → security, Email → email 입니다.
제공자별 필수 설정 항목은 GET /api/integrations/rules/{type} 으로 확인할 수 있습니다.
이벤트 이름은 started · completed · paused · resumed · recovered · canceled · error · skipped 입니다.
참고#
빌더 UI ↔ .env ↔ API 매핑#
| 빌더 UI | .env |
API |
|---|---|---|
| 탭 = 한 대에서 여러 대로 보내기 | TRANSFER_TYPE=send_many |
details N개 |
| From 디바이스 | SOURCE_ID |
모든 details[].senderId (공통) |
| From 경로 | SOURCE_PATH |
모든 details[].sourceItem[].filePath (공통) |
| To 디바이스 목록 | TARGET_IDS (콤마 구분) |
details[].receiverId |
| To 경로 | TARGET_PATHS (1개 또는 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 |
TARGET_IDS 가 비어 있으면 예제는 단일 TARGET_ID 를 1개짜리 목록으로 대신 사용합니다.
자주 겪는 오류#
| 증상 | 원인과 해결 |
|---|---|
| 일부 지점만 전송됨 | 해당 에이전트가 오프라인입니다. 자동화는 정상이며, 디바이스 연결 상태를 확인하세요. |
400 Bad Request |
details 중 하나라도 targetPath 가 비었거나 / 이면 전체 요청이 거부됩니다. |
| monitorId 가 대상 수보다 적게 잡힘 | 전송이 순차적으로 시작됩니다. 목록 조회를 몇 차례 반복해 누적 수집하세요(위 예제의 monitorIds). |
| 경로 매칭이 어긋남 | TARGET_PATHS 개수가 1도 N도 아닌 경우입니다. 순서는 TARGET_IDS 와 정확히 일치해야 합니다. |
| 대상이 많을 때 느림 | 전송 자체는 병렬로 처리됩니다. 폴링 간격(3초)을 늘리면 API 호출 부담을 줄일 수 있습니다. |