REST API
All endpoints are served by the relay at http(s)://<relay-host>/api/v1/.
Authentication
- Dashboard users: session cookie (
tapflow_token, set automatically on login) - CI/CD scripts:
Authorization: Bearer tflw_pat_<token>header
Error responses
All errors return JSON in the form { "error": "..." }.
| Status | Meaning | Example |
|---|---|---|
400 | Bad request (missing field, invalid format, etc.) | { "error": "file required" } |
401 | Not authenticated or session expired | { "error": "Unauthorized" } |
403 | Forbidden | { "error": "Forbidden" } or { "error": "Insufficient scope" } |
404 | Resource not found | { "error": "Build not found" } |
410 | Token expired | { "error": "Invitation expired or not found" } |
500 | Server error | { "error": "Internal server error" } |
Successful deletes return 204 with no body.
Auth
GET /api/v1/auth/status
Whether the relay has an admin account yet. No authentication.
Response 200
{ "initialized": false, "canInitialize": true }canInitialize is true when no account exists and this request comes from the relay host, the only place the first account can be created from. The setup page uses it to show the tapflow admin init instruction instead of the form.
POST /api/v1/auth/init
Create the first admin account. Only works when no accounts exist yet.
Body (JSON):
email string required
password string required (min 8 chars)Response 201
{ "ok": true }Returns 403 { "error": "Already initialized" } if an account already exists, and 403 with an error naming tapflow admin init if the request does not come from the relay host.
POST /api/v1/auth/login
Sign in. Sets the tapflow_token cookie on success (valid for 7 days).
Body (JSON):
email string required
password string requiredResponse 200
{ "ok": true, "role": "Admin" }POST /api/v1/auth/logout
Sign out. Clears the session cookie.
Response 200
{ "ok": true }GET /api/v1/auth/me
Return the currently signed-in user's info.
Response 200
{
"id": 1,
"email": "admin@example.com",
"displayName": "Admin",
"avatarUrl": "/api/v1/uploads/avatars/...",
"role": "Admin"
}POST /api/v1/auth/change-password
Change the current user's password.
Body (JSON):
currentPassword string required
newPassword string required (min 8 chars)Response 200
{ "ok": true }Invitations
GET /api/v1/invitations/verify
Check whether an invitation token is valid.
Query:
token string required (32-char hex)Response 200
{ "role": "QA" }Returns 410 if expired or not found.
POST /api/v1/invitations/accept
Accept an invitation and create an account. Sets a login cookie on success.
Content-Type: multipart/form-data
Fields:
token string required
password string required (min 8 chars)
display_name string optional
File:
avatar image (PNG/JPEG, max 2 MB) — optionalResponse 200
{ "ok": true }Password reset
GET /api/v1/auth/reset-password/verify
Check whether a password reset token is valid.
Query:
token string requiredResponse 200
{ "ok": true }Returns 410 if expired.
POST /api/v1/auth/reset-password
Reset the password.
Body (JSON):
token string required
password string required (min 8 chars)Response 200
{ "ok": true }POST /api/v1/team/members/:id/send-reset
Send a password reset email to a specific member. Admin only.
Response 200
{ "ok": true, "emailSent": true }If SMTP is not configured, emailSent: false is returned and the Admin must share the reset link manually.
Apps
GET /api/v1/apps
Return all apps. Each app includes a summary of its latest build.
Response 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
Create an app manually. Requires Admin or Developer role.
Body (JSON):
name string required
bundle_id_key string required
platform ios|android|both requiredResponse 201
{ "id": 7, "ok": true }PATCH /api/v1/apps/:id
Rename an app. Requires Admin or Developer role.
Body (JSON):
name string requiredResponse 200
{ "ok": true }DELETE /api/v1/apps/:id
Delete an app and all its builds and comments. Requires Admin or Developer role.
Response 200
{ "ok": true }Builds
POST /api/v1/builds
Upload a build.
Content-Type: multipart/form-data
Authorization: Bearer tflw_pat_<token> (or session cookie)Only file is required; every other field is optional.
| Field | Required | Description |
|---|---|---|
file | Yes | The build artifact. iOS: .app.zip or .tar.gz / .tgz (a simulator build); Android: .apk. Max 500 MB. .ipa and .aab are rejected. |
status | No | Initial review status — one of Backlog, In Progress, Done, Rejected. Omit to leave it unset. |
label | No | Free-text label to identify the build in App Center (e.g. a branch name or rc-1). |
platform | No | ios or android. Derived from the file type when omitted. |
app_id | No | Attach to an existing app explicitly. Normally the app is resolved automatically from the bundle ID. |
iOS builds
.ipa files are not supported. Upload .app.zip, or the .tar.gz / .tgz a cloud simulator build produces. Build .app.zip with xcodebuild -sdk iphonesimulator and zip the .app folder.
Response 201
{
"id": 42,
"app_id": 7,
"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
Return a paginated list of builds.
Query:
page number page number (default: 0)
limit number page size (default: 20)
q string search by version name
platform ios|android platform filter
status Backlog|In Progress|Done|Rejected status filter
app_id number filter by app
sort uploaded_at|version_name|status_label sort field (default: uploaded_at)
dir asc|desc sort direction (default: desc)Response 200
{
"items": [ { ... } ],
"total": 128
}GET /api/v1/builds/:id
Return a single build.
Response 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",
"delete_after": null
}delete_after is the time the build's files are purged, or null when no deletion is scheduled. It is independent of status_label — marking a build Done does not schedule deletion.
PATCH /api/v1/builds/:id
Update the status or label of a build.
Body (JSON):
status_label Backlog|In Progress|Done|Rejected|null optional
version_label string|null optionalResponse 200
{ "ok": true }POST /api/v1/builds/:id/schedule-deletion
Schedule the build for deletion. The server sets delete_after = now + TAPFLOW_BUILD_TTL_DAYS; the files and record are purged after that time.
Response 200
{ "ok": true }DELETE /api/v1/builds/:id/schedule-deletion
Cancel a scheduled deletion, clearing delete_after.
Response 200
{ "ok": true }Comments
GET /api/v1/comments
Return comments for a build.
Query:
build_id number requiredResponse 200
[
{
"id": 1,
"body": "Login button is not tappable",
"created_at": "2025-05-15T12:00:00.000Z",
"author": "qa@example.com",
"authorAvatarUrl": "...",
"attachments": [
{ "id": 3, "file_path": "/uploads/comments/...", "mime": "image/png" }
]
}
]POST /api/v1/comments
Post a comment. Supports image attachments.
Content-Type: multipart/form-data
Fields:
build_id number required
body string required
File:
attachment image (PNG/JPEG/WebP, max 5 MB) — optionalResponse 201
{
"id": 1,
"body": "Login button is not tappable",
"created_at": "2025-05-15T12:00:00.000Z",
"author": "qa@example.com"
}DELETE /api/v1/comments/:id
Delete a comment. Only the author or an Admin can delete.
Response 204 (no body)
Team
GET /api/v1/team/members
Return all members. Admin only.
Response 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
Invite a team member. Admin only. Invitations expire after 7 days.
Body (JSON):
email string required
role Admin|Developer|QA|Viewer optional (default: QA)Response 201
{ "token": "abc123...", "emailSent": true, "inviteUrl": "http://192.168.0.10:4000/invite?token=abc123..." }If SMTP is not configured, emailSent: false is returned. When inviteUrl is not null, it is the link the invitation email carries. It is built from the tunnel's publicUrl, otherwise relay.url (TAPFLOW_RELAY_URL). When tapflow start or tapflow relay start runs the tunnel, that is the address the tunnel got, including one Tailscale detected. A standalone relay uses the configured value. An http:// tunnel address is not used when the relay serves HTTPS. inviteUrl is null when there is no candidate, or when the only one is an address a teammate cannot open, such as localhost. In that case, build the link from the address teammates use to reach the relay: <relay-url>/invite?token=<token>.
PATCH /api/v1/team/members/:id
Change a member's role. Admin only.
Body (JSON):
role Admin|Developer|QA|Viewer requiredResponse 200
{ "ok": true }DELETE /api/v1/team/members/:id
Delete a member. Admin only. You cannot delete yourself.
Response 204 (no body)
Tokens (Personal Access Tokens)
GET /api/v1/tokens
Return the current user's PAT list.
Response 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
Create a PAT. The token value is returned only once at creation time.
Body (JSON):
name string required
expires_in_days number optional (omit for no expiry)Response 201
{ "token": "tflw_pat_abc123..." }DELETE /api/v1/tokens/:id
Immediately revoke a PAT.
Response 204 (no body)
Profile
PATCH /api/v1/profile
Update the current user's profile.
Content-Type: multipart/form-data
Fields:
display_name string optional
File:
avatar image (PNG/JPEG, max 2 MB) — optionalResponse 200
{ "ok": true }Settings
GET /api/v1/settings
Return team settings.
Response 200
{ "team_name": "My Team", "logo_url": "..." }PATCH /api/v1/settings
Update team settings. Admin only.
Content-Type: multipart/form-data
Fields:
team_name string optional
File:
logo image (PNG/JPEG, max 2 MB) — optionalResponse 200
{ "ok": true }Recordings
POST /api/v1/recordings/upload
Upload a recording file. Automatically deleted 72 hours after upload.
Content-Type: multipart/form-data
Query:
sessionId string optional
buildId number optional
File:
video video file (webm, etc.) requiredResponse 200
{ "url": "/api/v1/recordings/abc123.webm" }GET /api/v1/recordings
Return a list of recordings.
Query:
buildId number optionalResponse 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
Download a recording file. Returns 404 for expired files.
Agents
GET /api/v1/agents
Return the list of connected agent names.
Response 200
["mac-mini-office", "mac-mini-lab"]GET /api/v1/agents/:name/resources
Return CPU and RAM time-series data for a specific agent.
Query:
range 1h|6h|24h|7d optional (default: 1h)Response 200
[
{ "cpu_percent": 44.2, "mem_percent": 61.0, "recorded_at": "2025-05-15T12:00:00Z" }
]Data is sampled once per minute and retained for 30 days.
Logs
GET /api/v1/logs
Return the relay's in-memory log buffer.
Query:
lines number optional (default: 100, max: 500)Response 200
[
"[2025-05-15T12:00:00.000Z] Agent mac-mini-office connected",
"..."
]