REST API
모든 엔드포인트는 릴레이의 http(s)://<relay-host>/api/v1/에서 제공됩니다.
인증 방식
- 대시보드 사용자: 세션 쿠키 (
tapflow_token, 로그인 시 자동 설정) - CI/CD 스크립트:
Authorization: Bearer tflw_pat_<token>헤더
아래 엔드포인트만 개인 액세스 토큰(PAT)을 받으며 세션 쿠키로도 호출할 수 있습니다.
| PAT scope | 엔드포인트 |
|---|---|
builds:write | POST /builds, GET /builds, GET /builds/:id, POST /comments, 웹훅 엔드포인트 전체 |
view | GET /apps, GET /sessions/:sessionId/screenshot, GET /sessions/:sessionId/ui-tree, /uploads/ 아래 파일(/api/v1/이 아닌 릴레이 루트 경로), 원격에서 여는 기기 세션 WebSocket |
agent | 원격 에이전트의 WebSocket 연결. 토큰을 발급한 팀원이 Admin인 동안에만 받습니다 |
역할. PAT로 호출해도 토큰 주인의 현재 역할이 적용됩니다. Viewer는 읽기 전용이라 빌드 업로드·수정·삭제 예약, 앱 생성·수정·삭제, 웹훅 엔드포인트 전체에서 403을 받습니다. 빌드 조회와 댓글 작성은 Viewer도 할 수 있습니다. 역할을 확인하는 엔드포인트는 요청마다 역할을 다시 읽으므로 역할을 바꾸면 다시 로그인하거나 토큰을 새로 만들지 않아도 바로 적용됩니다.
에러 응답
모든 에러는 { "error": "..." } 형태의 JSON을 반환합니다.
| 상태 코드 | 의미 | 예시 |
|---|---|---|
400 | 잘못된 요청 (필드 누락, 형식 오류 등) | { "error": "file required" } |
401 | 인증 없음 또는 만료 | { "error": "Unauthorized" } |
403 | 권한 없음 | { "error": "Forbidden" }, { "error": "Insufficient scope" } 또는 { "error": "Viewers have read-only access" } |
404 | 리소스를 찾을 수 없음 | { "error": "Build not found" } |
409 | 현재 상태에서 처리할 수 없음 | { "error": "Device is not booted" } |
410 | 토큰 만료 | { "error": "Invitation expired or not found" } |
429 | 요청이 너무 많음 (Retry-After 헤더 포함) | { "error": "Too many attempts. Try again later." } |
500 | 서버 오류 | { "error": "Internal server error" } |
502 | 에이전트에 연결할 수 없거나 에이전트가 오류를 반환함 | { "error": "Agent offline" } |
504 | 에이전트 응답 시간 초과 | { "error": "Screenshot timed out" } |
댓글·멤버·토큰 삭제는 성공하면 204를 본문 없이 반환합니다. 앱·웹훅 삭제와 빌드 삭제 예약 취소는 200 { "ok": true }를 반환합니다.
인증 (Auth)
GET /api/v1/auth/status
릴레이에 관리자 계정이 있는지 알려 줍니다. 인증이 필요 없습니다.
응답 200
{ "initialized": false, "canInitialize": true }canInitialize는 계정이 하나도 없고 이 요청으로 첫 계정을 만들 수 있을 때 true입니다. 첫 계정은 릴레이 호스트에서 온 요청만 만들 수 있습니다. 설정 페이지는 이 값을 보고 폼 대신 tapflow admin init 안내를 띄웁니다.
POST /api/v1/auth/init
최초 관리자 계정을 생성합니다. 계정이 하나도 없을 때만 사용 가능합니다.
Body (JSON):
email string 필수
password string 필수 (최소 8자)응답 201
{ "ok": true }계정이 이미 존재하면 403 { "error": "Already initialized" }를 반환합니다. 요청이 릴레이 호스트에서 오지 않았으면 tapflow admin init을 안내하는 오류와 함께 403을 반환합니다.
POST /api/v1/auth/login
로그인합니다. 성공 시 tapflow_token 쿠키가 설정됩니다 (7일 유효).
Body (JSON):
email string 필수
password string 필수응답 200
{ "ok": true, "role": "Admin" }이메일은 대소문자와 앞뒤 공백을 구분하지 않고 비교합니다. 같은 주소와 이메일로 로그인에 계속 실패하면 429와 Retry-After 헤더를 반환합니다.
POST /api/v1/auth/logout
로그아웃합니다. 쿠키를 삭제합니다.
응답 200
{ "ok": true }GET /api/v1/auth/me
현재 로그인한 사용자 정보를 반환합니다.
응답 200
{
"id": 1,
"email": "admin@example.com",
"displayName": "Admin",
"avatarUrl": "/uploads/avatars/user-1.png",
"role": "Admin"
}avatarUrl은 프로필 이미지가 없으면 null입니다.
POST /api/v1/auth/change-password
비밀번호를 변경합니다.
Body (JSON):
currentPassword string 필수
newPassword string 필수 (최소 8자)응답 200
{ "ok": true }응답에 새 tapflow_token 쿠키가 설정됩니다. 비밀번호를 바꾸면 다른 모든 브라우저에서 로그아웃되고 열려 있던 연결도 끊깁니다.
초대 (Invitations)
GET /api/v1/invitations/verify
초대 토큰의 유효 여부를 확인합니다.
Query:
token string 필수 (64자 hex)응답 200
{ "role": "QA" }만료되었거나 존재하지 않으면 410을 반환합니다.
POST /api/v1/invitations/accept
초대를 수락하고 계정을 생성합니다. 성공 시 로그인 쿠키가 설정됩니다. 초대받은 이메일로 이미 계정이 있으면 409를 반환합니다. 초대로 기존 계정이 바뀌는 일은 없습니다.
Content-Type: multipart/form-data
Fields:
token string 필수
password string 필수 (최소 8자)
display_name string 선택
File:
avatar 이미지 (PNG/JPEG, 최대 2MB) — 선택응답 200
{ "ok": true }비밀번호 재설정
GET /api/v1/auth/reset-password/verify
비밀번호 재설정 토큰의 유효 여부를 확인합니다.
Query:
token string 필수응답 200
{ "ok": true }재설정 토큰은 발급 후 2시간 동안 유효합니다. 만료되었으면 410을 반환합니다.
POST /api/v1/auth/reset-password
비밀번호를 재설정합니다.
Body (JSON):
token string 필수
password string 필수 (최소 8자)응답 200
{ "ok": true }모든 브라우저에서 로그아웃되며 새 비밀번호로 다시 로그인해야 합니다. 그 사용자의 개인 액세스 토큰은 모두 폐기되고 그 토큰으로 연결된 에이전트도 끊기므로 토큰을 새로 발급해야 합니다.
POST /api/v1/team/members/:id/send-reset
특정 멤버의 비밀번호 재설정 링크를 만들고 SMTP가 설정되어 있으면 이메일로도 보냅니다. Admin 전용.
응답 200
{ "ok": true, "emailSent": true, "token": "abc123...", "resetUrl": "http://192.168.0.10:4000/reset-password?token=abc123..." }링크는 2시간 동안 한 번만 쓸 수 있습니다. 새 링크를 만들면 그 멤버의 이전 링크는 더 이상 동작하지 않습니다. SMTP가 설정되지 않았거나 이메일 없이 초대된 멤버라면 emailSent는 false입니다. resetUrl은 POST /api/v1/team/invite의 inviteUrl과 같은 규칙을 따릅니다. null이면 <relay-url>/reset-password?token=<token>으로 링크를 직접 만드세요.
앱 (Apps)
GET /api/v1/apps
모든 앱 목록을 반환합니다. 각 앱에 최신 빌드 요약이 포함됩니다.
응답 200
{
"items": [
{
"id": 7,
"name": "My App",
"bundle_id_key": "com.example.app",
"platform": "ios",
"created_at": "2025-05-01T00:00:00.000Z",
"latest_build_id": 42,
"version_name": "1.2.3",
"build_number": "89",
"status_label": "In Progress",
"latest_uploaded_at": "2025-05-15T12:00:00.000Z"
}
]
}POST /api/v1/apps
앱을 수동으로 생성합니다. Admin, Developer, QA 권한이 필요합니다. Viewer는 403을 받습니다.
Body (JSON):
name string 필수
bundle_id_key string 필수
platform ios|android|both 필수응답 201
{ "id": 7, "ok": true }PATCH /api/v1/apps/:id
앱 이름을 수정합니다. Admin, Developer, QA 권한이 필요합니다. Viewer는 403을 받습니다.
Body (JSON):
name string 필수응답 200
{ "ok": true }DELETE /api/v1/apps/:id
앱과 하위의 모든 빌드·댓글을 삭제합니다. Admin, Developer, QA 권한이 필요합니다. Viewer는 403을 받습니다.
응답 200
{ "ok": true }빌드 (Builds)
POST /api/v1/builds
빌드를 업로드합니다. Viewer는 쿠키로 호출하든 자기 PAT로 호출하든 403을 받습니다.
Content-Type: multipart/form-data
Authorization: Bearer tflw_pat_<token> (또는 세션 쿠키)file만 필수이고 나머지는 모두 선택입니다.
| 필드 | 필수 | 설명 |
|---|---|---|
file | 필수 | 빌드 산출물. iOS는 .app.zip 또는 .tar.gz/.tgz(시뮬레이터 빌드), Android는 .apk입니다. 최대 크기는 기본 500MB이며 TAPFLOW_MAX_BUILD_BYTES로 바꿀 수 있습니다. .ipa·.aab는 거부됩니다. |
status | 선택 | 초기 리뷰 상태로 Backlog, In Progress, Done, Rejected 중 하나입니다. 생략하면 미설정으로 둡니다. |
label | 선택 | App Center에서 빌드를 식별하는 자유 텍스트 레이블입니다(예: 브랜치명이나 rc-1). |
platform | 선택 | ios 또는 android입니다. 생략하면 파일 형식에서 자동으로 정해집니다. |
app_id | 선택 | 기존 앱에 명시적으로 연결합니다. 보통은 bundle ID로 앱이 자동 결정됩니다. |
iOS 빌드 주의사항
.ipa 파일은 지원하지 않습니다. .app.zip을 올리거나, 클라우드 시뮬레이터 빌드가 만드는 .tar.gz/.tgz를 올리세요. .app.zip은 xcodebuild -sdk iphonesimulator로 빌드한 .app 폴더를 zip으로 압축하면 됩니다.
응답 201
{
"id": 42,
"app_id": 7,
"name": "My App",
"version_name": "1.2.3",
"build_number": "89",
"bundle_id": "com.example.app",
"status_label": "In Progress",
"platform": "ios",
"uploaded_at": "2025-05-15T12:00:00.000Z"
}GET /api/v1/builds
빌드 목록을 페이지네이션으로 반환합니다.
Query:
page number 페이지 번호 (기본값: 0)
limit number 페이지 크기 (기본값: 20, 최대: 100)
q string 버전명으로 검색
platform ios|android 플랫폼 필터
status Backlog|In Progress|Done|Rejected 상태 필터
app_id number 특정 앱의 빌드만 조회
sort uploaded_at|version_name|status_label 정렬 기준 (기본값: uploaded_at)
dir asc|desc 정렬 방향 (기본값: desc)응답 200
{
"items": [ { ... } ],
"total": 128
}GET /api/v1/builds/:id
빌드 단건을 조회합니다.
응답 200
{
"id": 42,
"app_id": 7,
"name": "My App",
"version_name": "1.2.3",
"build_number": "89",
"version_label": "rc-1",
"status_label": "In Progress",
"platform": "ios",
"bundle_id": "com.example.app",
"uploaded_at": "2025-05-15T12:00:00.000Z",
"completed_at": null,
"delete_after": null
}delete_after는 빌드 파일이 삭제되는 시각입니다. 삭제가 예약되지 않았으면 null입니다. status_label과 독립적이라 빌드를 Done으로 표시해도 삭제가 예약되지 않습니다.
PATCH /api/v1/builds/:id
빌드의 상태 또는 레이블을 수정합니다. Viewer는 403을 받습니다.
Body (JSON):
status_label Backlog|In Progress|Done|Rejected|null 선택
version_label string|null 선택응답 200
{ "ok": true }POST /api/v1/builds/:id/schedule-deletion
빌드 삭제를 예약합니다. 서버가 delete_after = now + TAPFLOW_BUILD_TTL_DAYS로 설정하고 그 시각이 지나면 파일과 레코드를 삭제합니다. Viewer는 403을 받습니다.
응답 200
{ "ok": true, "delete_after": "2025-05-22 12:00:00" }DELETE /api/v1/builds/:id/schedule-deletion
예약한 삭제를 취소하고 delete_after를 비웁니다. Viewer는 403을 받습니다.
응답 200
{ "ok": true }웹훅 (Webhooks)
빌드 리뷰 상태가 바뀔 때 알림을 받을 엔드포인트를 관리합니다. 모두 세션 쿠키나 builds:write scope의 PAT로 호출합니다. 웹훅 URL 자체가 비밀인 경우가 많아서 Viewer는 목록 조회를 포함한 모든 웹훅 엔드포인트에서 403을 받습니다. 페이로드와 서명 검증은 웹훅에서 다룹니다.
GET /api/v1/webhooks
등록된 웹훅 목록을 반환합니다. secret 값은 반환하지 않고 설정 여부만 has_secret으로 알려 줍니다.
응답 200
{
"webhooks": [
{ "id": 1, "url": "https://ci.internal/hooks/tapflow", "enabled": true, "has_secret": true, "created_at": "2025-05-01 00:00:00" }
]
}POST /api/v1/webhooks
웹훅을 등록합니다.
Body (JSON):
url string 필수
secret string|null 선택 (HMAC 서명 secret)
enabled boolean 선택 (기본값: true)응답 201: 등록된 웹훅 (GET 목록의 항목과 같은 형태)
PATCH /api/v1/webhooks/:id
웹훅의 url, secret, enabled 중 보낸 필드만 수정합니다.
응답 200: 수정된 웹훅. 바꿀 필드가 없으면 400, 웹훅이 없으면 404입니다.
DELETE /api/v1/webhooks/:id
웹훅을 삭제합니다.
응답 200
{ "ok": true }댓글 (Comments)
GET /api/v1/comments
빌드별 댓글 목록을 반환합니다.
Query:
build_id number 필수응답 200
[
{
"id": 1,
"body": "로그인 버튼이 안 눌려요",
"created_at": "2025-05-15T12:00:00.000Z",
"author": "Kim QA",
"authorAvatarUrl": "/uploads/avatars/user-3.png",
"attachments": [
{ "id": 3, "file_path": "/uploads/comments/...", "mime": "image/png" }
]
}
]author는 작성자의 표시 이름이고 표시 이름이 없으면 이메일의 @ 앞부분입니다. authorAvatarUrl은 프로필 이미지가 없으면 null입니다.
POST /api/v1/comments
댓글을 작성합니다. 이미지를 첨부할 수 있습니다. 세션 쿠키나 builds:write scope의 PAT로 호출합니다. Viewer를 포함한 모든 역할이 작성할 수 있습니다.
Content-Type: multipart/form-data
Fields:
build_id number 필수
body string 필수
File:
attachment 이미지 (PNG/JPEG/WebP, 최대 5MB) — 선택응답 201
{
"id": 1,
"body": "로그인 버튼이 안 눌려요",
"created_at": "2025-05-15T12:00:00.000Z",
"author": "Kim QA"
}이 응답의 author는 표시 이름 그대로이므로 표시 이름이 없으면 null입니다.
DELETE /api/v1/comments/:id
댓글을 삭제합니다. 작성자 본인 또는 Admin만 가능합니다.
응답 204 (본문 없음)
팀 (Team)
GET /api/v1/team/members
전체 멤버 목록을 반환합니다. Admin 전용.
응답 200
[
{
"id": 1,
"email": "admin@example.com",
"display_name": "Admin",
"role": "Admin",
"joined_at": "2025-05-01T00:00:00.000Z"
}
]POST /api/v1/team/invite
팀원을 초대합니다. Admin 전용. 초대 링크는 7일 후 만료됩니다. email이 대소문자와 관계없이 이미 멤버의 이메일이면 409를 반환합니다.
Body (JSON):
email string 선택 (생략하면 초대 메일을 보내지 않음)
role Admin|Developer|QA|Viewer 선택 (기본값: QA)응답 201
{ "token": "abc123...", "emailSent": true, "inviteUrl": "http://192.168.0.10:4000/invite?token=abc123..." }SMTP가 설정되지 않았거나 email을 생략한 경우 emailSent: false가 반환됩니다. inviteUrl이 null이 아니면 초대 이메일의 링크와 같습니다. 터널의 publicUrl을 먼저 쓰고 없으면 relay.url(TAPFLOW_RELAY_URL)을 씁니다. tapflow start나 tapflow relay start가 터널을 띄우면 터널이 받은 주소를 씁니다(Tailscale이 감지한 주소 포함). 단독 실행한 릴레이는 설정값을 씁니다. 릴레이가 HTTPS로 동작하면 http:// 터널 주소는 쓰지 않습니다. 후보가 없거나 localhost처럼 팀원이 열 수 없는 주소뿐이면 null입니다. 이때는 팀원이 접속하는 릴레이 주소로 <relay-url>/invite?token=<token> 링크를 직접 만드세요.
PATCH /api/v1/team/members/:id
멤버의 역할을 변경합니다. Admin 전용.
Body (JSON):
role Admin|Developer|QA|Viewer 필수응답 200
{ "ok": true }DELETE /api/v1/team/members/:id
멤버를 삭제합니다. Admin 전용. 자기 자신은 삭제할 수 없습니다.
응답 204 (본문 없음)
토큰 (Personal Access Tokens)
GET /api/v1/tokens
PAT 목록을 반환합니다.
응답 200
[
{
"id": 1,
"name": "GitHub Actions",
"scope": "builds:write",
"last_used_at": "2025-05-15T12:00:00.000Z",
"expires_at": null,
"created_at": "2025-05-01T00:00:00.000Z"
}
]POST /api/v1/tokens
PAT를 생성합니다. 토큰 값은 생성 직후 한 번만 반환됩니다.
Body (JSON):
name string 필수
expires_in_days number 선택 (없거나 0이면 만료 없음)
scope string 선택 (콤마로 구분. 기본값: view,builds:write)expires_in_days에는 0 이상의 일수를 보냅니다("30" 같은 숫자 문자열도 됩니다). 음수, 빈 문자열을 포함해 숫자가 아닌 값, 날짜로 표현할 수 없을 만큼 큰 값을 보내면 400을 반환합니다. 대시보드에서는 1~365일만 입력할 수 있지만 API에는 상한이 없습니다.
scope에는 view, builds:write, agent를 쓸 수 있습니다. agent scope는 원격 Mac의 에이전트가 릴레이에 연결할 때 쓰며 Admin만 발급할 수 있습니다. 다른 역할이 요청하면 403을 반환합니다.
응답 201
{ "token": "tflw_pat_abc123..." }DELETE /api/v1/tokens/:id
PAT를 즉시 무효화합니다.
응답 204 (본문 없음)
프로필 (Profile)
PATCH /api/v1/profile
현재 로그인한 사용자의 프로필을 수정합니다.
Content-Type: multipart/form-data
Fields:
display_name string 선택
File:
avatar 이미지 (PNG/JPEG, 최대 2MB) — 선택응답 200
{ "ok": true }설정 (Settings)
GET /api/v1/settings
팀 설정을 조회합니다. 로그인이 필요합니다.
응답 200
{ "team_name": "My Team", "logo_url": "/uploads/team/logo.png" }로고가 없으면 logo_url은 null입니다.
PATCH /api/v1/settings
팀 설정을 수정합니다. Admin 전용.
Content-Type: multipart/form-data
Fields:
team_name string 선택
File:
logo 이미지 (PNG/JPEG, 최대 2MB) — 선택응답 200
{ "ok": true }녹화 (Recordings)
POST /api/v1/recordings/upload
녹화 파일을 업로드합니다. 업로드 후 72시간 뒤에 자동 삭제됩니다.
Content-Type: multipart/form-data
Query:
sessionId string 선택
buildId number 선택
File:
(필드 이름 무관) webm 등 영상 파일 필수응답 200
{ "url": "/api/v1/recordings/abc123.webm" }GET /api/v1/recordings
녹화 목록을 반환합니다.
Query:
buildId number 선택응답 200
[
{
"id": 1,
"url": "/api/v1/recordings/abc123.webm",
"sessionId": "sess_xxx",
"fileSize": 1048576,
"mime": "video/webm",
"createdAt": "2025-05-15T12:00:00.000Z",
"expiresAt": "2025-05-18T12:00:00.000Z"
}
]GET /api/v1/recordings/:filename
녹화 파일을 다운로드합니다. 만료된 파일은 404를 반환합니다.
에이전트 (Agents)
GET /api/v1/agents
리소스 기록이 남아 있는 에이전트의 이름 목록을 반환합니다. 기록은 30일간 보관되므로 지금 연결되어 있지 않은 에이전트도 포함될 수 있습니다.
응답 200
["mac-mini-office", "mac-mini-lab"]GET /api/v1/agents/:name/resources
특정 에이전트의 CPU·RAM 시계열 데이터를 반환합니다.
Query:
range 1h|6h|24h|7d 선택 (기본값: 1h)응답 200
[
{ "cpu_percent": 44.2, "mem_percent": 61.0, "recorded_at": "2025-05-15T12:00:00Z" }
]데이터는 1분마다 샘플링되며 30일간 보관됩니다.
세션 (Sessions)
sessionId는 MCP 서버의 list_devices 결과에서 확인합니다. 두 엔드포인트 모두 세션 쿠키나 view scope의 PAT로 호출합니다.
GET /api/v1/sessions/:sessionId/screenshot
세션 기기의 현재 화면을 이미지로 반환합니다.
Query:
format png|jpeg 선택 (기본값: png)응답 200: image/png 또는 image/jpeg 본문
세션이 없으면 404, 기기가 꺼져 있으면 409, 에이전트가 오프라인이거나 캡처에 실패하면 502, 10초 안에 응답이 없으면 504를 반환합니다.
GET /api/v1/sessions/:sessionId/ui-tree
세션 기기의 현재 화면에 있는 UI 요소 목록을 반환합니다.
응답 200
{
"elements": [
{
"role": "button",
"label": "Sign in",
"identifier": "login_button",
"frame": { "x": 0.1, "y": 0.8, "width": 0.8, "height": 0.06 },
"enabled": true
}
]
}frame은 화면 크기에 대한 0–1 비율입니다. 오류 상태 코드는 스크린샷과 같으며 시간 초과는 15초입니다.
릴레이 (Relay)
GET /api/v1/relay/host
대시보드가 팀원에게 줄 주소를 만들 때 쓰는 릴레이 주소 정보를 반환합니다. 로그인이 필요합니다.
응답 200
{
"lanHost": "192.168.0.10",
"port": 4000,
"publicBaseUrl": "https://tap.example.com",
"agentRelayUrl": "wss://tap.example.com"
}값을 정할 수 없는 항목은 null입니다. 컨테이너 안에서 실행하면 lanHost는 항상 null입니다.
로그 (Logs)
GET /api/v1/logs
릴레이 인메모리 로그 버퍼(최근 500줄)를 반환합니다. 릴레이 호스트에서 보낸 요청에만 응답합니다. 다른 기기나 터널, 신뢰 프록시를 거친 원격 클라이언트는 인증 여부와 관계없이 403을 받습니다. 원격에서 로그를 보려면 릴레이 호스트의 출력(터미널, journalctl, docker compose logs)을 확인하세요.
Query:
lines number 선택 (1~500 사이 정수, 기본값: 100). 숫자가 아니면 100, 범위를 벗어나면 가까운 끝값을 씁니다.응답 403
{ "error": "Logs are only available on the relay host. Run `tapflow logs` there." }응답 200
[
"[2025-05-15T12:00:00.000Z] ..."
]관련 문서
- CI에서 빌드 올리기: PAT로 빌드 업로드하기
- 팀·역할·토큰: 역할별로 쓸 수 있는 엔드포인트와 토큰 발급
- CLI 레퍼런스: 릴레이와 에이전트를 실행하는 명령