# iOS Signing Service API v1 Base URL: `https://ios.chorus.com` Content-Type: `application/json` (all request/response bodies unless noted) Error shape: `{"error": "message"}` with appropriate HTTP status code ## API Authentication All `/api/*` routes require an API key via the `Authorization` header: ``` Authorization: Bearer ``` Responses: - `401` `Missing Authorization header` — no Bearer token provided - `401` `Invalid API key format` — token does not match the expected format - `401` `Invalid API key` — key not recognized - `403` `Forbidden: you do not own this signing service user` — key is valid but you don't own the requested resource - `503` `Service temporarily unavailable` — backend unreachable (retry) Public routes (no auth needed): `/health`, `/register/*`, `/install/*`, `/manifest/*`, `/ipa/*` --- ## Apple Authentication Two auth methods. User must authenticate with Apple before signing. ### Password Flow ``` POST /api/auth/start → {"sessionId", "userId"} GET /api/auth/status/{id} → poll until state changes POST /api/auth/respond → submit 2FA code (if awaiting_2fa) GET /api/auth/status/{id} → poll again POST /api/auth/respond → select team (if awaiting_team) GET /api/auth/status/{id} → state = "authenticated" POST /api/sign → sign app ``` Session states: `authenticating` → `awaiting_2fa` → `authenticating` → `awaiting_team` → `authenticated` Failure at any point: `auth_failed` (check `output` for reason) SRP session data is persisted — subsequent signs refresh the app token automatically without re-login (~1 year validity). ### API Key Flow ``` POST /api/auth/apikey → {"ok": true, "userId"} POST /api/sign → sign app ``` No 2FA, no polling, no team selection. Ready immediately. ### Build + Sign Flow If you have raw Swift source code (a .zip with an .xcodeproj), you can build AND sign in one flow: ``` POST /api/build → upload zip → {"buildJobId", "statusUrl"} GET /api/build-jobs/{jobId} → poll until state = "built" or "failed" POST /api/sign → pass appUrl from build job → {"buildId", "installUrl"} GET /api/builds/{buildId} → poll until state = "signed" or "failed" ``` Build job states: `uploading` → `building` → `built` | `failed` Sign build states: `pending` → `signing` → `signed` | `failed` ### Sign-Only Flow (if you already have a .tar.gz with a .app) ``` POST /api/sign → {"buildId", "installUrl", "statusUrl"} GET /api/builds/{buildId} → poll until state = "signed" or "failed" ``` Open `installUrl` on iPhone to install. Requires HTTPS. --- ## POST /api/auth/start **Request** ```json {"username": "user@example.com", "password": "secret", "userId": "my-id"} ``` | Field | Type | Required | Notes | |-------|------|----------|-------| | `username` | string | yes | Apple ID email | | `password` | string | yes | Apple ID password | | `userId` | string | no | Auto-generated UUID if omitted | **Response 200** ```json {"sessionId": "a1b2c3d4-...", "userId": "my-id"} ``` **Errors** | Status | Error | Cause | |--------|-------|-------| | 400 | `username and password required` | Missing/empty username or password | | 403 | `userId already belongs to a different Apple ID` | userId was used with a different email before | --- ## POST /api/auth/apikey **Request** ```json {"userId": "my-id", "issuerID": "xxxx-xxxx", "keyID": "XXXXXXXXXX", "p8Key": "-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----", "teamId": "XXXXXXXXXX"} ``` | Field | Type | Required | Notes | |-------|------|----------|-------| | `userId` | string | no | Auto-generated UUID if omitted | | `issuerID` | string | yes | From App Store Connect > Keys | | `keyID` | string | yes | Key identifier | | `p8Key` | string | yes | Full .p8 file contents including headers | | `teamId` | string | yes | Apple Developer team ID | **Response 200** ```json {"ok": true, "userId": "my-id"} ``` **Errors** | Status | Error | Cause | |--------|-------|-------| | 400 | `issuerID, keyID, p8Key, and teamId are required` | Missing required fields | | 400 | `Invalid API key material: ...` | P8 key can't produce a valid ES256 JWT | | 400 | `Invalid JSON body: ...` | Request body is not valid JSON | --- ## GET /api/auth/status/{sessionId} **Response 200** ```json { "state": "awaiting_team", "userId": "my-id", "output": "Select a team:\n1: Personal Team\n2: My Company", "teams": [ {"teamId": "ABC123", "name": "Personal Team"}, {"teamId": "DEF456", "name": "My Company"} ] } ``` | Field | Type | Notes | |-------|------|-------| | `state` | string | See state machine below | | `userId` | string | User identifier | | `output` | string | Human-readable status (last 1000 chars) | | `teams` | array | Only populated when state = `awaiting_team` | **State machine** | State | Meaning | Action | |-------|---------|--------| | `authenticating` | SRP handshake in progress | Poll again (1-2s interval) | | `awaiting_2fa` | Apple sent 2FA push to trusted device | `POST /api/auth/respond` with 6-digit code | | `awaiting_team` | Auth succeeded, pick a team | `POST /api/auth/respond` with team index or teamId | | `authenticated` | Ready to sign | Proceed to `POST /api/sign` | | `auth_failed` | Failed | Read `output` for error details | **Errors** | Status | Error | Cause | |--------|-------|-------| | 404 | `Session not found` | Invalid sessionId or session expired (10 min after terminal state) | --- ## POST /api/auth/respond **Request** ```json {"sessionId": "a1b2c3d4-...", "value": "123456"} ``` | Field | Type | Required | Notes | |-------|------|----------|-------| | `sessionId` | string | yes | From `/api/auth/start` response | | `value` | string | yes | 2FA code OR team selection | **Team selection**: `value` can be 1-based index (`"1"`, `"2"`) or team ID (`"ABC123"`). **Response 200**: `{"ok": true}` Poll `/api/auth/status/{sessionId}` after to see updated state. **Errors** | Status | Error | Cause | |--------|-------|-------| | 400 | `value required` | Empty value | | 400 | `No teams available for this session` | Session has no teams data | | 400 | `Invalid team selection` | Index out of range or team ID not found | | 400 | `Session is in state {state}` | Session not in an interactive state | | 404 | `Session not found` | Invalid or expired sessionId | --- ## POST /api/build Upload a source zip to build an unsigned iOS app on a macOS build server. Returns a build job ID to poll. **Required headers**: - `Authorization: Bearer ` - `x-project-id: ` — your project or agent ID **Request**: Send the zip as either: - `multipart/form-data` with a `file` field - Raw body with `Content-Type: application/zip` ```bash # multipart curl -X POST https://ios.chorus.com/api/build \ -H "Authorization: Bearer " \ -H "x-project-id: " \ -F "file=@source.zip" # raw body curl -X POST https://ios.chorus.com/api/build \ -H "Authorization: Bearer " \ -H "x-project-id: " \ -H "Content-Type: application/zip" \ --data-binary @source.zip ``` **Zip format**: Must contain a `.xcodeproj` within 3 levels. A single top-level wrapper directory is fine. Max size: 500 MB. **Response 200** ```json {"buildJobId": "uuid", "statusUrl": "https://host/api/build-jobs/uuid"} ``` **Errors** | Status | Error | Cause | |--------|-------|-------| | 400 | `multipart form must include a 'file' field` | Missing file in multipart | | 400 | `x-project-id header required` | Missing project ID header | | 400 | `Empty zip file` | Zero bytes | | 413 | `Source zip too large` | Over 500 MB | | 500 | `R2 credentials not configured` | Server missing R2 env vars | | 500 | `AZURE_CLIENT_SECRET not configured` | Server missing Azure env vars | --- ## GET /api/build-jobs/{jobId} Poll build job status. **Response 200** ```json { "id": "uuid", "state": "built", "error": null, "appUrl": "https://iosbuilds.composerapi.com/raw-builds/uuid.tar.gz", "createdAt": 1710900000 } ``` | Field | Type | Notes | |-------|------|-------| | `state` | string | `uploading` → `building` → `built` \| `failed` | | `error` | string\|null | Error message when state = `failed` | | `appUrl` | string\|null | URL to unsigned .app tar.gz when state = `built`. Pass this to `POST /api/sign`. | Typical build time: 2-5 minutes. **Errors**: `404` `Build job not found` --- ## GET /api/build-jobs/{jobId}/logs Fetch pipeline logs from Azure DevOps for a build job. Useful for debugging failed builds. **Response 200** ```json { "logs": [ {"id": 1, "lineCount": 42, "text": "Starting: Initialize job\n..."}, {"id": 2, "lineCount": 15, "text": "Starting: Checkout\n..."} ] } ``` | Field | Type | Notes | |-------|------|-------| | `logs` | array | One entry per pipeline task/step | | `logs[].id` | number | Azure log entry ID | | `logs[].lineCount` | number | Number of lines in this log entry | | `logs[].text` | string | Full text content of the log entry | **Errors** | Status | Error | Cause | |--------|-------|-------| | 400 | `Build has not started an Azure pipeline run yet` | Build still uploading or failed before pipeline triggered | | 404 | `Build job not found` | Invalid jobId | | 500 | `Failed to list build logs (...)` | Azure API error | --- ## POST /api/sign Starts async signing. Returns immediately — poll build status. **Request** ```json {"userId": "my-id", "appUrl": "https://example.com/App.tar.gz", "projectId": "optional"} ``` | Field | Type | Required | Notes | |-------|------|----------|-------| | `userId` | string | yes | From authentication | | `appUrl` | string | yes | HTTPS URL to .tar.gz (max 500 MB) | | `projectId` | string | no | For tracking | **Archive format**: `.tar.gz` containing `Payload/MyApp.app/` or root-level `MyApp.app/` (auto-moved to Payload/). **Response 200** ```json {"buildId": "b1c2d3e4-...", "installUrl": "https://host/install/b1c2d3e4-...", "statusUrl": "https://host/api/builds/b1c2d3e4-..."} ``` **Synchronous errors** (returned immediately) | Status | Error | Cause | |--------|-------|-------| | 400 | `userId and appUrl required` | Missing fields | | 400 | `appUrl must use http or https` | Non-HTTP protocol | | 400 | `appUrl must point to an allowed host` | Hostname not in allowlist (`iosbuilds.composerapi.com`, `localhost`) | | 400 | `appUrl is not a valid URL` | Malformed URL | | 400 | `No team selected — authenticate first` | User has no team | | 400 | `No saved Apple authentication session` | Password user with no stored session | | 400 | `Saved Apple authentication session is invalid` | Corrupted stored token | | 400 | `No saved App Store Connect API key configuration` | API key user with missing config | | 404 | `User not found — authenticate first` | Unknown userId | **Async errors** (surface as `state: "failed"` in build status) | Error | Cause | |-------|-------| | `No .app found in archive` | Archive doesn't contain a .app bundle | | `curl failed (code N): ...` | Download failed | | `tar failed (code N): ...` | Extraction failed | | `No registered iOS devices were found in App Store Connect.` | No devices to create ad-hoc profile | | `App Store Connect did not return certificate content.` | Cert creation failed | | `App Store Connect did not return provisioning profile content.` | Profile creation failed | | `Stored signing assets are incomplete.` | Cached cert/key/profile partially missing | | `No saved Apple authentication session available.` | Token refresh failed | | `Saved Apple SRP session is invalid` | Stored SRP data corrupted | | `zsign failed (code N): ...` | Code signing failed | | `zip failed (code N): ...` | IPA packaging failed | --- ## GET /api/builds/{buildId} **Response 200** ```json { "id": "b1c2d3e4-...", "user_id": "my-id", "project_id": null, "app_url": "https://example.com/App.tar.gz", "bundle_id": "com.example.app", "bundle_name": "MyApp", "state": "signed", "error": null, "created_at": 1710900000, "installUrl": "https://host/install/b1c2d3e4-..." } ``` | Field | Type | Notes | |-------|------|-------| | `state` | string | `pending` → `signing` → `signed` \| `failed` | | `error` | string\|null | Error message when state = `failed` | | `bundle_id` | string\|null | Extracted after archive processing | | `bundle_name` | string\|null | Extracted after archive processing | | `installUrl` | string\|null | Only present when state = `signed` | **Errors**: `404` `Build not found` --- ## GET /install/{buildId} OTA install page. Open on iPhone to install. | Status | Response | Condition | |--------|----------|-----------| | 200 | HTML install page | Build signed | | 400 | `Build not ready (signing)` | Build not yet signed | | 404 | `Build not found` | Invalid buildId | --- ## GET /manifest/{buildId}.plist OTA manifest. Called by iOS internally via `itms-services://` — not for direct use. | Status | Response | |--------|----------| | 200 | XML plist (`Content-Type: text/xml`) | | 404 | `Build not found` | --- ## GET /ipa/{buildId}.ipa Signed IPA download. | Status | Response | |--------|----------| | 200 | Binary (`Content-Type: application/octet-stream`, `Content-Disposition: attachment`) | | 404 | `Build not found` or `IPA file not found on disk` | --- ## Device Registration Collect tester UDIDs via Apple's .mobileconfig enrollment profile. ### Flow ``` Share link: GET /register/{userId} → tester taps "Register Device" → downloads .mobileconfig profile → installs in Settings > General > VPN & Device Management → iOS POSTs UDID to POST /register/{userId}/callback → tester sees success page → call POST /api/devices/{userId}/register-apple to register with Apple ``` ### GET /register/{userId} Mobile-friendly landing page with "Register Device" button. | Status | Response | |--------|----------| | 200 | HTML page | | 404 | `Registration link not found` (unknown userId) | ### GET /register/{userId}/enroll.mobileconfig Apple enrollment profile (PayloadType: "Profile Service"). iOS automatically POSTs device attributes when installed. | Status | Response | |--------|----------| | 200 | `Content-Type: application/x-apple-aspen-config` | | 404 | `Registration link not found` | ### POST /register/{userId}/callback Receives UDID from iOS. DER-encoded PKCS7 signed body parsed for device attributes. | Status | Response | Condition | |--------|----------|-----------| | 303 | Redirect to `/register/{userId}/success?udid=XXX` | UDID captured | | 400 | `Profile callback body required` | Empty body | | 400 | `Device profile did not include a UDID` | UDID not found in payload | | 404 | `Registration link not found` | Unknown userId | ### GET /register/{userId}/success Confirmation page showing masked UDID (first 8 + last 4 chars). ### GET /api/devices/{userId} List all registered devices for a user. **Response 200** ```json { "devices": [ { "udid": "00008110-000A1CD6268A801E", "product": "iPhone14,2", "deviceName": "John's iPhone", "registeredAt": "2024-03-20T00:00:00.000Z", "appleRegistered": false } ] } ``` **Errors**: `404` `User not found` ### POST /api/devices/{userId}/register-apple Register all unregistered devices with Apple Developer portal. **Response 200** ```json {"ok": true, "total": 3, "registered": [{"udid": "00008110-...", "product": "iPhone14,2", "deviceName": "John's iPhone"}], "failed": []} ``` **Errors** | Status | Error | Cause | |--------|-------|-------| | 404 | `User not found` | Unknown userId | | 400 | No auth credentials | User has no stored auth | --- ## Publishing (App Store Connect) Publish an archived iOS app to App Store Connect — production review or TestFlight. `./ios-cli publish` archives, distribution-signs, uploads, sets metadata, and submits the build in one workflow. All `/api/publish/*` routes require API key auth (`Authorization: Bearer chorus_`); App Store Connect API key auth must be set up via `/api/auth/apikey` first (password / GSA auth is rejected for publishing). ## POST /api/publish/preflight Dry-run readiness check. Never mutates Apple-side state. **Request** ```json {"userId": "user-123", "buildJobId": "job-uuid", "bundleId": "com.example.app", "version": "1.0.0", "target": "production"} ``` | Field | Type | Required | Notes | |-------|------|----------|-------| | `userId` | string | yes | Signing-service user id | | `buildJobId` | string | yes | From `/api/build` | | `bundleId` | string | yes | App bundle identifier | | `version` | string | yes | CFBundleShortVersionString (e.g. `1.0.0`) | | `target` | string | no | `production` (default) or `testflight` | **Response 200** ```json {"ready": true, "checks": [{"name": "auth_valid", "passed": true, "message": "API key auth configured"}]} ``` **Errors** | Status | Error | Cause | |--------|-------|-------| | 400 | `userId, buildJobId, bundleId, and version are required` | Missing field | | 400 | `Publishing requires App Store Connect API key auth` | User has GSA / password auth instead of `apikey` | --- ## POST /api/publish/attempts Kick off the full publish workflow: archive → distribution-sign → upload to ASC → set metadata → submit for review. **Request** ```json {"userId": "user-123", "buildJobId": "job-uuid", "bundleId": "com.example.app", "appName": "My App", "version": "1.0.0", "scheme": "MyApp", "target": "production", "metadata": {"description": "...", "keywords": "...", "primaryCategory": "PRODUCTIVITY", "supportUrl": "https://...", "privacyPolicyUrl": "https://...", "copyright": "2026 You", "screenshots": [{"url": "https://...", "deviceType": "IPHONE_69"}]}} ``` | Field | Type | Required | Notes | |-------|------|----------|-------| | `userId` | string | yes | | | `buildJobId` | string | yes | | | `bundleId` | string | yes | | | `appName` | string | yes | | | `version` | string | yes | | | `scheme` | string | yes | Xcode scheme used by the release-archive build | | `target` | string | yes | `production` or `testflight` | | `metadata` | object | production: yes | Required fields enforced at the readiness gate | **Response 200** ```json {"attemptId": "uuid", "statusUrl": "https://host/api/publish/attempts/uuid"} ``` **Errors** | Status | Error | Cause | |--------|-------|-------| | 400 | Readiness failure | `failed_check_names` lists what to fix | | 409 | `Publish already in flight for this bundle` | Active attempt exists | --- ## GET /api/publish/attempts/{attemptId} Poll publish state. **Response 200** ```json {"status": "running", "currentStep": "building_and_uploading", "completedSteps": ["preparing_assets"], "buildNumber": 32, "submissionId": null, "error": null, "errorDetails": null, "recovery": null, "recoveryEndpoint": null} ``` `status` ∈ `queued | running | needs_manual_action | completed | failed`. On `failed`, `recovery` + `recoveryEndpoint` describe the targeted retry path. --- ## POST /api/publish/metadata Update store listing on an existing version. Use to retry just the metadata step after a failed `setting_metadata` — no rebuild needed. **Request** ```json {"userId": "user-123", "bundleId": "com.example.app", "version": "1.0.0", "metadata": {"description": "...", "keywords": "..."}} ``` **Response 200** ```json {"ok": true, "updated": ["description", "keywords"]} ``` --- ## POST /api/publish/compliance Patch `usesNonExemptEncryption` on the build attached to a version. **Request** ```json {"userId": "user-123", "bundleId": "com.example.app", "version": "1.0.0", "usesNonExemptEncryption": false, "expectedBuildId": "build-uuid"} ``` | Field | Type | Required | Notes | |-------|------|----------|-------| | `usesNonExemptEncryption` | boolean | yes | | | `encryptionDeclarationId` | string | conditional | Required when `usesNonExemptEncryption=true` | | `expectedBuildId` | string | no | Race protection: refuse PATCH if a different build is attached | **Errors** | Status | Error | Cause | |--------|-------|-------| | 400 | `encryptionDeclarationId required when usesNonExemptEncryption=true` | Missing declaration id | | 409 | `Different build attached than expected` | `expectedBuildId` doesn't match Apple | --- ## POST /api/publish/submit-review Submit an existing version for App Review. Verifies a VALID build is attached before submitting. **Request** ```json {"userId": "user-123", "bundleId": "com.example.app", "version": "1.0.0", "target": "production"} ``` **Response 200** ```json {"ok": true, "submissionId": "submission-uuid"} ``` --- ## POST /api/publish/submit-prepared Submit a version that already has metadata + build set on Apple's side. Skips local-input validation; runs only `runAscValidateCheck` against Apple's stored state. **Request** ```json {"userId": "user-123", "bundleId": "com.example.app", "version": "1.0.0", "target": "production"} ``` **Response 200** ```json {"ok": true, "submissionId": "submission-uuid"} ``` --- ## POST /api/publish/cancel-review Cancel an in-flight review submission so a later version can take Apple's "one in-flight per platform" slot. **Request** ```json {"userId": "user-123", "bundleId": "com.example.app", "submissionId": "optional-explicit-id"} ``` When `submissionId` is omitted, the route resolves the active submission for the bundle. **Response 200** ```json {"ok": true, "cancelledSubmissionId": "submission-uuid"} ``` --- ## POST /api/publish/resolve-attempt Operator path to transition a `needs_manual_action` attempt to `completed` or `failed`. Required after a post-submit failure where Apple may have accepted but local persistence didn't. **Request** ```json {"userId": "user-123", "attemptId": "attempt-uuid", "resolution": "completed", "reason": "(required when resolution=failed)"} ``` --- ## POST /api/publish/upload-screenshot Upload a single PNG and return the public URL. Use the URL in `metadata.screenshots[]` or `metadata.screenshotUrls[]`. **Request** ```json {"userId": "user-123", "fileName": "01_hero.png", "fileSize": 192034, "fileBase64": "iVBORw0KGgo..."} ``` PNG IHDR header is validated; dimensions are checked against Apple's device-type matrix. Per-file cap: 8 MiB. **Response 200** ```json {"ok": true, "url": "https://iosbuilds.composerapi.com/screenshots//...png", "deviceType": "IPHONE_69", "width": 1320, "height": 2868} ``` **Errors** | Status | Error | Cause | |--------|-------|-------| | 400 | `Invalid PNG header` | Body is not a PNG | | 400 | `Screenshot dimensions ... do not match a supported Apple device type` | Wrong size | | 413 | `Screenshot exceeds 8 MB limit` | File too big | --- ## GET /api/publish/screenshots?userId=&bundleId=&version=&locale= Inventory screenshot SETs on a version-localization. Locale defaults to `en-US`. **Response 200** ```json {"ok": true, "appId": "6761678338", "versionId": "uuid", "locale": "en-US", "sets": [{"setId": "uuid", "displayType": "APP_IPHONE_69", "screenshots": [{"id": "uuid", "fileName": "01.png", "width": 1320, "height": 2868, "state": "COMPLETE"}]}]} ``` --- ## POST /api/publish/delete-screenshot Delete a single screenshot by id. **Request** ```json {"userId": "user-123", "screenshotId": "uuid"} ``` `screenshotId` must match `[A-Za-z0-9_-]{8,}` — body-sourced ids are validated to block path-traversal injection at the ASC URL boundary. **Response 200** ```json {"ok": true, "deletedId": "uuid"} ``` --- ## POST /api/publish/delete-screenshot-set Delete an entire `appScreenshotSet` (and all screenshots in it). Use after `delete-screenshot` removes every screenshot in a set — Apple's submission readiness rejects empty sets ("Upload at least one screenshot to this set"). **Request** ```json {"userId": "user-123", "setId": "uuid"} ``` `setId` is pattern-validated (`[A-Za-z0-9_-]{8,}`) and `encodeURIComponent`'d at the ASC URL boundary. **Response 200** ```json {"ok": true, "deletedId": "uuid"} ``` --- ## POST /api/publish/routing-coverage Upload a routing app's coverage file (`.geojson`) describing the geographic regions the app provides routing for — used by transit, ride-share, navigation, and similar apps that declare `MKDirectionsApplicationSupportedModes` in their Info.plist. Apple content-validates the GeoJSON shape. **Request** ```json {"userId": "user-123", "bundleId": "com.example.routing", "version": "1.0.0", "fileName": "coverage.geojson", "fileSize": 422, "fileBase64": "..."} ``` Cap: 10 MiB. **Response 200** ```json {"ok": true, "routingCoverageId": "uuid", "fileSize": 422} ``` **Notes**: Apple's content validator gates on the build's pbxproj declaring `INFOPLIST_KEY_MKDirectionsApplicationSupportedModes`. Apple-side `assetDeliveryState: COMPLETE` additionally requires the bundle id to be registered at https://mapsconnect.apple.com — outside pipeline scope. Common rejection: `assetDeliveryState.errors[].code = TRANSIT_APP_FILE_INVALID_JSON`. --- ## POST /api/publish/delete-routing-coverage Clear a routing-coverage record (typically a stuck FAILED one). Idempotent — returns `{deletedId: ""}` when nothing was attached. **Request** ```json {"userId": "user-123", "bundleId": "com.example.app", "version": "1.0.0"} ``` **Response 200** ```json {"ok": true, "deletedId": "uuid-or-empty-string"} ``` --- ## POST /api/publish/review-attachment Upload a file as the App Store reviewer attachment on the version's `appStoreReviewDetail`. Useful for demo videos, sign-in walkthroughs, third-party SDK auth proofs. **Request** ```json {"userId": "user-123", "bundleId": "com.example.app", "version": "1.0.0", "fileName": "demo.pdf", "fileSize": 305, "fileBase64": "..."} ``` Cap: 100 MiB. PDF / MP4 / PNG / MOV accepted. **Response 200** ```json {"ok": true, "appId": "6761678338", "versionId": "uuid", "reviewDetailId": "uuid", "attachmentId": "uuid", "fileName": "demo.pdf", "fileSize": 305} ``` **Errors** | Status | Error | Cause | |--------|-------|-------| | 400 | `fileBase64 decodes to N bytes, not the declared M` | Declared `fileSize` mismatches decoded payload | | 500 | `There can be max of 1 attachment, please delete the existing attachment before loading a new one` | Apple cap — only one attachment per version | --- ## POST /api/publish/encryption-declaration Create / update an Apple encryption declaration for the team. Used when `usesNonExemptEncryption=true` and the binary uses custom crypto. **Request** ```json {"userId": "user-123", "bundleId": "com.example.app", "usesEncryption": true, "containsProprietaryCryptography": false, "containsThirdPartyCryptography": false, "isExempt": false, "availableOnFrenchStore": true} ``` **Response 200** ```json {"ok": true, "encryptionDeclarationId": "uuid"} ``` --- ## POST /api/publish/seed-cert Import an existing distribution cert + private key for a user (rather than having the pipeline mint a fresh cert). **Request** ```json {"userId": "user-123", "certId": "ASC-cert-uuid", "certBase64": "...", "keyBase64": "..."} ``` Both `certBase64` and `keyBase64` are capped at 64 KiB each post-decode (real Apple distribution certs are 1–2 KB). **Response 200** ```json {"ok": true, "certId": "ASC-cert-uuid"} ``` **Errors** | Status | Error | Cause | |--------|-------|-------| | 400 | `certBase64 must decode to between 1 and 65536 bytes` | Cert too large | | 400 | `keyBase64 must decode to between 1 and 65536 bytes` | Key too large | --- ## POST /api/publish/release Release an approved version pending developer release. **Request** ```json {"userId": "user-123", "bundleId": "com.example.app", "version": "1.0.0"} ``` --- ## POST /api/publish/app-setup Idempotent post-app-shell setup: pricing, availability, age rating, content rights, primary category, review contact. **Request** ```json {"userId": "user-123", "bundleId": "com.example.app", "appName": "My App", "ageRating": {...}, "pricing": {...}, "availability": {...}, "reviewContact": {"firstName": "...", "lastName": "...", "email": "...", "phone": "+1..."}} ``` --- ## GET /api/publish/app-info?userId=&bundleId= Current app state in ASC (iOS only): versions + appStoreStates. **Response 200** ```json {"appId": "6761678338", "versions": [{"id": "uuid", "versionString": "1.0.0", "appStoreState": "READY_FOR_REVIEW"}]} ``` --- ## GET /api/publish/review-status?userId=&bundleId= Versions currently in review for the app (iOS only). **Response 200** ```json {"inReview": [{"versionId": "uuid", "versionString": "1.0.0", "submissionId": "uuid", "submittedAt": "2026-05-08T..."}]} ``` --- ## TestFlight surface All TestFlight endpoints require API key auth and a publishable app already set up in ASC. ### POST /api/publish/testflight-group Create a beta group. **Request** ```json {"userId": "user-123", "bundleId": "com.example.app", "groupName": "Internal QA", "publicLinkEnabled": false} ``` ### GET /api/publish/testflight-groups?userId=&bundleId= List beta groups for the app. ### POST /api/publish/testflight-tester Invite a beta tester to a group. **Request** ```json {"userId": "user-123", "groupId": "group-uuid", "email": "tester@example.com", "firstName": "Test", "lastName": "User"} ``` ### GET /api/publish/testflight-testers?userId=&groupId= List testers in a group. ### DELETE /api/publish/testflight-tester Remove a tester from a group. **Request** ```json {"userId": "user-123", "groupId": "group-uuid", "testerId": "tester-uuid"} ``` ### POST /api/publish/testflight-build-to-group Attach an uploaded build to one or more beta groups. **Request** ```json {"userId": "user-123", "buildId": "build-uuid", "groupIds": ["group-uuid-1", "group-uuid-2"]} ``` ### POST /api/publish/testflight-beta-app-info Patch beta app localization (description, feedback email, marketing URL, privacy URL). ### POST /api/publish/testflight-beta-review-submit Submit a build for external beta review. **Request** ```json {"userId": "user-123", "buildId": "build-uuid"} ``` ### POST /api/publish/testflight-build-expire Expire a TestFlight build (force-stop distribution). ### POST /api/publish/testflight-public-link Toggle a group's public link + optional tester limit. **Request** ```json {"userId": "user-123", "groupId": "group-uuid", "enabled": true, "limit": 1000} ``` ### POST /api/publish/testflight-build-whats-new Patch the build-localization "What's New" notes. ### GET /api/publish/testflight-builds?userId=&bundleId= List TestFlight builds with processing state. ### GET /api/publish/testflight-review-status?userId=&bundleId=&buildId= Current external-beta-review status for a build. --- ## Notes - All `/api/*` routes require `Authorization: Bearer ` header - All `/api/*` requests have a body size cap: **16 MiB default**, with overrides for `/api/build` (500 MB) and `/api/publish/review-attachment` (100 MiB). Larger bodies return HTTP 413 - `GET /api/builds/:buildId`, `GET /api/build-jobs/:jobId`, and `GET /api/build-jobs/:jobId/logs` enforce ownership; cross-tenant reads return `403 Forbidden` - Each signing-service user is linked to its owner — other API key holders cannot access your resources - Sessions expire 10 minutes after reaching terminal state (`authenticated` or `auth_failed`) - Auth sessions are scoped to the API key that created them — other keys get `403` - Signing assets (cert, key, profile) are cached per user — subsequent signs reuse them - Signing assets are preserved on re-auth; cleared when switching Apple teams - SRP session data persists across server restarts — no re-login needed for ~1 year - API key auth tokens never expire (until revoked in App Store Connect) - All OTA install URLs require HTTPS to work on iOS - Archive must contain a valid `.app` bundle with `Info.plist` - Publishing requires App Store Connect API key auth (Admin or App Manager role); password / GSA auth is rejected for `/api/publish/*` routes