한 곳으로 파일 보내기는 보내는 디바이스 한 대(Source)에서 받는 디바이스 한 대(Target)로 파일 또는 폴더를 전송하는, 가장 기본이 되는 형태입니다.
API 요청은 POST /api/automations 한 번입니다. details 에 보내는 쪽과 받는 쪽 한 쌍을 넣고,
schedules 로 실행 시점을 정합니다.
전송 빌더에서 Get API Code 를 누르면 같은 내용이 언어별 실행 가능한 예제(combo_builder.*)와
.env 로 묶여 내려받아집니다. 이 문서는 그 예제에서 핵심만 떼어낸 것입니다.
시작하기#
준비물#
- API Key — 제품 좌측 하단 프로필 메뉴 → Developer 에서 발급합니다.
같은 화면에 Workspace ID 도 함께 표시됩니다.
API 로 발급하려면
POST /api/auth/api-keys(Bearer 액세스 토큰, 바디 없음) →data.apiKey. - deviceId 두 개 — 제품의 Devices 에서 디바이스를 선택하면 우측 상단에 표시되는 ID 입니다. Windows · macOS · Ubuntu · RHEL · Rocky · Debian 등 에이전트가 설치된 장비와 Amazon S3 · Azure Blob · Google Cloud Storage 같은 오브젝트 스토리지를 모두 지정할 수 있습니다.
- 경로 — 보낼 쪽 경로(
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_ID, 경로(SOURCE_PATH·TARGET_PATH)를 채웁니다 - 아래 명령으로 실행합니다
- 출력된
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=C:/data/out
export TARGET_ID=device-target-01
export TARGET_PATH=C:/incomingWindows 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="C:/data/out"
$env:TARGET_ID="device-target-01"
$env:TARGET_PATH="C:/incoming"전송 만들기#
전송 만들기#
한 대에서 한 대로 보내기는 POST /api/automations 한 번으로 만들어집니다.
details 배열에 보내는 쪽 / 받는 쪽 한 쌍을 넣고, schedules 에 실행 시점을 넣습니다.
아래는 지금 바로 실행하는 경우입니다.
{
"name": "nightly-export",
"flowName": "nightly-export",
"transferType": "normal",
"timezone": "Asia/Seoul",
"details": [
{
"senderId": "<sourceDeviceId>",
"receiverId": "<targetDeviceId>",
"sourceItem": [{ "filePath": "C:/data/out", "isDir": true }],
"targetPath": "C:/incoming",
"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 시각을 넣으세요 (아래 예제 코드는 실행할 때마다 현재 시각을 계산합니다).
응답의 data.automationId 가 이후 조회에 쓰이는 식별자입니다.
# 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).
SOURCE_ID = os.environ["SOURCE_ID"]
SOURCE_PATH = os.getenv("SOURCE_PATH", "C:/data/out")
TARGET_ID = os.environ["TARGET_ID"]
TARGET_PATH = os.getenv("TARGET_PATH", "C:/incoming")
SOURCE_IS_DIR = os.getenv("SOURCE_IS_DIR", "true").lower() != "false"
TZ = os.getenv("SCHEDULE_TZ", "Asia/Seoul")
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")
body = {
"name": "nightly-export",
"flowName": "nightly-export",
"transferType": "normal", # "sync" for sync, "command" for an external trigger
"timezone": TZ,
"details": [{
"senderId": SOURCE_ID,
"receiverId": TARGET_ID,
"sourceItem": [{"filePath": SOURCE_PATH, "isDir": SOURCE_IS_DIR}], # false for a single file
"targetPath": TARGET_PATH,
"step": 1,
"transferOptions": {"noSchedule": False, "target-action": "overwrite"},
}],
"schedules": [{
"type": "none", "startDateType": "now",
"startDate": now_iso(), "timezone": TZ,
}],
"step": 1,
"isUpcoming": False,
}
automation_id = call("POST", "/api/automations", body)["automationId"]
print("automation created:", automation_id)// 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.regex.Matcher;
import java.util.regex.Pattern;
public class SendToOne {
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();
/** 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;
}
/** 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();
}
public static void main(String[] args) throws Exception {
// Every setting comes from an environment variable (second argument is the default).
String sourceId = env("SOURCE_ID", "");
String sourcePath = env("SOURCE_PATH", "C:/data/out");
String targetId = env("TARGET_ID", "");
String targetPath = env("TARGET_PATH", "C:/incoming");
String tz = env("SCHEDULE_TZ", "Asia/Seoul");
String nowIso = Instant.now().truncatedTo(ChronoUnit.SECONDS).toString().replace("Z", ".000Z");
String body = """
{
"name": "nightly-export",
"flowName": "nightly-export",
"transferType": "normal",
"timezone": "%s",
"details": [{
"senderId": "%s",
"receiverId": "%s",
"sourceItem": [{ "filePath": "%s", "isDir": true }],
"targetPath": "%s",
"step": 1,
"transferOptions": { "noSchedule": false, "target-action": "overwrite" }
}],
"schedules": [{
"type": "none", "startDateType": "now",
"startDate": "%s", "timezone": "%s"
}],
"step": 1,
"isUpcoming": false
}
""".formatted(tz, sourceId, targetId, sourcePath, targetPath, nowIso, tz);
String res = call("POST", "/api/automations", body);
System.out.println("automation created: " + jsonString(res, "automationId"));
}
}// 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 (the value after || is the default).
const TZ = process.env.SCHEDULE_TZ || 'Asia/Seoul';
const SOURCE_ID = process.env.SOURCE_ID;
const SOURCE_PATH = process.env.SOURCE_PATH || 'C:/data/out';
const TARGET_ID = process.env.TARGET_ID;
const TARGET_PATH = process.env.TARGET_PATH || 'C:/incoming';
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;
}
const body = {
name: 'nightly-export',
flowName: 'nightly-export',
transferType: 'normal',
timezone: TZ,
details: [{
senderId: SOURCE_ID,
receiverId: TARGET_ID,
sourceItem: [{ filePath: SOURCE_PATH, isDir: true }],
targetPath: TARGET_PATH,
step: 1,
transferOptions: { noSchedule: false, 'target-action': 'overwrite' },
}],
schedules: [{ type: 'none', startDateType: 'now', startDate: nowIso(), timezone: TZ }],
step: 1,
isUpcoming: false,
};
const { automationId } = await call('POST', '/api/automations', body);
console.log('automation created:', automationId);// .NET 8+ (standard library only)
using System;
using System.Collections.Generic;
using System.Net.Http;
using System.Text;
using System.Text.Json;
using System.Threading.Tasks;
static class SendToOne
{
// 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 readonly string Base = Env("INNORIX_BASE_URL", "https://app.innorix.com");
static readonly HttpClient Http = new();
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 async Task Main()
{
var tz = Env("SCHEDULE_TZ", "Asia/Seoul");
var nowIso = DateTime.UtcNow.ToString("yyyy-MM-ddTHH:mm:ss.000Z");
// Keys that contain a hyphen, like "target-action", need a Dictionary.
var transferOptions = new Dictionary<string, object>
{
["noSchedule"] = false,
["target-action"] = "overwrite",
};
var body = new
{
name = "nightly-export",
flowName = "nightly-export",
transferType = "normal",
timezone = tz,
details = new[]
{
new
{
senderId = Env("SOURCE_ID"),
receiverId = Env("TARGET_ID"),
sourceItem = new[] { new { filePath = Env("SOURCE_PATH", "C:/data/out"), isDir = true } },
targetPath = Env("TARGET_PATH", "C:/incoming"),
step = 1,
transferOptions,
}
},
schedules = new[]
{
new { type = "none", startDateType = "now", startDate = nowIso, timezone = tz }
},
step = 1,
isUpcoming = false,
};
var data = await Call("POST", "/api/automations", body);
Console.WriteLine("automation created: " + data.GetProperty("automationId").GetString());
}
}진행 상황 확인#
자동화가 만들어지면 실제 전송은 별도의 monitorId 로 추적합니다.
GET /api/transfers?automationId=<automationId>— 진행 중인 전송 목록. 응답은 커서 페이지네이션(data.data[])이고, 실제 전송 행은type: "monitor"입니다.type이automation·history·flow인 행은 요약 행이므로 건너뜁니다.GET /api/transfers/<monitorId>— 상태와 진행률.
상태 코드는 다음과 같습니다.
| 코드 | 의미 | 코드 | 의미 |
|---|---|---|---|
| -1 | queued | 6 | transferring |
| 0 | waiting | 7 | skipped |
| 1 | started | 8 | retry |
| 2 | complete | 9 | partial-complete |
| 3 | paused | 11 | virus-scanning |
| 4 | error | 12 | syncing |
| 5 | cancelled | 99 | fail |
종료 상태는 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):
result = call("GET", "/api/transfers", params={"automationId": automation_id})
records = result.get("data") if isinstance(result, dict) else result
return [r.get("monitorId") or r.get("id")
for r in (records or []) if r.get("type") not in SKIP_ROW_TYPES]
def wait_for(monitor_id, timeout=3600):
deadline = time.time() + timeout
while time.time() < deadline:
detail = call("GET", f"/api/transfers/{monitor_id}") or {}
status = detail.get("status")
print(f" {STATUS.get(status, status)} ({detail.get('percent', 0)}%)")
if detail.get("isTerminal", status in TERMINAL):
return detail
time.sleep(3)
raise TimeoutError(f"{monitor_id} did not finish within {timeout}s")
for mid in monitor_ids(automation_id):
wait_for(mid)// Reuses call(), jsonString() and jsonInt() from SendToOne 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
for (String monitorId : monitorIds(automationId)) {
int status = waitFor(monitorId, 3600);
System.out.println(" finished: " + (status == 2 ? "complete" : "status " + status));
}const SKIP_ROW_TYPES = new Set(['automation', 'history', 'flow']);
const TERMINAL = new Set([2, 4, 5, 9, 99]);
const 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',
};
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
async function monitorIds(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))
.map((r) => r.monitorId || r.id)
.filter(Boolean);
}
async function waitFor(monitorId, timeoutMs = 3600_000) {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const detail = (await call('GET', `/api/transfers/${monitorId}`)) || {};
console.log(` ${STATUS[detail.status] ?? detail.status} (${detail.percent ?? 0}%)`);
if (detail.isTerminal ?? TERMINAL.has(detail.status)) return detail;
await sleep(3000);
}
throw new Error(`${monitorId} did not finish within the time limit`);
}
for (const mid of await monitorIds(automationId)) await waitFor(mid);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)
{
var result = await Call("GET", "/api/transfers?automationId=" + automationId);
var records = result.ValueKind == JsonValueKind.Object && result.TryGetProperty("data", out var inner)
? inner : result;
var ids = new List<string>();
foreach (var r in records.EnumerateArray())
{
var type = r.TryGetProperty("type", out var t) ? t.GetString() : null;
if (type != null && SkipRowTypes.Contains(type)) continue;
if (r.TryGetProperty("monitorId", out var m)) ids.Add(m.GetString()!);
else if (r.TryGetProperty("id", out var i)) ids.Add(i.GetString()!);
}
return ids;
}
static async Task WaitFor(string monitorId, int timeoutSeconds = 3600)
{
var deadline = DateTime.UtcNow.AddSeconds(timeoutSeconds);
while (DateTime.UtcNow < deadline)
{
var detail = await Call("GET", "/api/transfers/" + monitorId);
var status = detail.GetProperty("status").GetInt32();
Console.WriteLine(quot; status={status}");
var isTerminal = detail.TryGetProperty("isTerminal", out var it)
? it.GetBoolean() : Terminal.Contains(status);
if (isTerminal) return;
await Task.Delay(3000);
}
throw new Exception(quot;{monitorId} did not finish within the time limit");
}전송 옵션#
시작 시점#
실행 시점은 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" |
아래 참고 |
반복(repeat)에서는 startDateType / startDate 로 첫 실행 시각을 정합니다.
startDateType: "now" 면 생성 즉시 한 번 실행하고 이후 주기대로, "specific" 이면 계산한 다음 주기부터 시작합니다.
hour 는 1–12, ampm 은 am / pm, timezone 은 Asia/Seoul 같은 IANA 이름입니다.
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여도 그대로 적용됩니다.
전송 후 동작#
전송이 끝난 뒤의 동작은 두 갈래로 나뉩니다.
① 자동화에 붙는 프로세서 — 바디의 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 매핑#
Get API Code 로 받은 번들의 .env 키와 API 필드의 대응입니다.
| 빌더 UI | .env |
API |
|---|---|---|
| 탭 = 한 대에서 한 대로 보내기 | TRANSFER_TYPE=send_one |
details 1개 |
| From 디바이스 | SOURCE_ID |
details[].senderId |
| From 경로 | SOURCE_PATH |
details[].sourceItem[].filePath |
| 폴더/파일 여부 | SOURCE_IS_DIR |
details[].sourceItem[].isDir |
| To 디바이스 | TARGET_ID |
details[].receiverId |
| To 경로 | TARGET_PATH |
details[].targetPath |
| Start | START_WHEN |
schedules[0] |
| 전송 이름 | NAME |
name · flowName |
| File options | FILTER_* · SAVE_PATH · DUPLICATE_ACTION · INTEGRITY |
details[].transferOptions |
| After transfer | ON_* |
processors[] · POST /api/integrations |
자주 겪는 오류#
| 증상 | 원인과 해결 |
|---|---|
401 Unauthorized |
x-api-key 가 비었거나 만료됐습니다. Developer 화면에서 재발급하세요. Bearer 토큰은 수명이 짧습니다. |
400 Bad Request |
targetPath 가 비었거나 / 인 경우가 가장 많습니다. senderId · receiverId 의 deviceId 오타도 확인하세요. |
| 자동화는 만들어졌는데 전송이 안 보임 | 에이전트가 오프라인일 수 있습니다. GET /api/transfers?automationId=... 을 몇 초 간격으로 다시 조회하세요. |
| 진행률이 멈춰 있음 | 받는 쪽 에이전트 연결이 끊겼을 때 나타납니다. 디바이스 상태를 먼저 확인하세요. |
| 필터를 걸었는데 다 전송됨 | send-fileoption.extension 은 { "extension": [...], "allow": ... } 중첩 구조입니다. 배열만 넣으면 무시됩니다. |
요청과 응답을 그대로 보고 싶으면 내려받은 예제에서 DEBUG=true 를 켜면 됩니다.