iOS releases & TestFlight
How a Raft iOS build travels from CI to TestFlight. The key fact: Hands uploads to Apple server-side — the App Store Connect credential lives encrypted in Hands, and the .p8 key never leaves it. CI does not need Apple upload credentials, and nobody runs altool.
The flow
iOS Release workflow Hands Apple
──────────────────── ───── ─────
build + sign IPA ──────────▶ build + assets in R2
(publish_hands=true)
testflight-upload ───────────▶ Build Upload API
(streams IPA from R2) PROCESSING → COMPLETE
testflight-publish ───────────▶ VALID build
groups + What to Test internal testing, or
beta review + notify external Beta ReviewBuild + sign + publish — dispatch the
iOS Releaseworkflow (botiverse/mobile) withpublish_hands=true. It builds, signs with the distribution certificate, and uploads the IPA + dSYM to Hands as a build (usually with a draft release).Upload to TestFlight — trigger the server-side upload for that build:
POST /api/apps/{app_id}/builds/{build_id}/testflight-uploadHands streams the IPA from R2 straight to Apple's Build Upload API (create → register file → part PUTs → commit) and returns the initial state. Console: the build row's TestFlight action.
Poll upload processing —
GET /api/apps/{app_id}/testflight-uploads/{build_upload_id}Read the nested
state.statefield:AWAITING_UPLOAD → PROCESSING → COMPLETE | FAILED; the same object retains Apple's errors, warnings, and infos.COMPLETEmeans Apple accepted the upload transaction; wait until the corresponding build resource reportsprocessingState=VALIDbefore distribution.List beta groups — use the processed Hands build to resolve the exact App Store Connect app and list stable group ids:
GET /api/apps/{app_id}/builds/{build_id}/testflight-groupsPublish to TestFlight — assign the exact processed build to groups and write localized What to Test metadata:
POST /api/apps/{app_id}/builds/{build_id}/testflight-publish { "distribution": "internal", "group_ids": ["<asc-beta-group-id>"], "what_to_test": { "en-US": "Verify login and Activity.", "zh-Hans": "验证登录和活动页。" }, "notify_testers": false }Internal mode adds only internal groups. External mode adds only external groups, submits
betaAppReviewSubmissions, and setsbuildBetaDetails.autoNotifyEnabled. When an external build is already approved,notify_testers=truecreates the officialbuildBetaNotificationsresource only when automatic notification was not already enabled.Refresh distribution state —
GET /api/apps/{app_id}/builds/{build_id}/testflight-publish?distribution=externalThe response keeps Apple's raw processing, expiry, internal/external build, Beta App Review, auto-notify, assigned-group, and localization state. It distinguishes
waiting_for_review,in_review,approved_not_notified,testing,rejected,expired, and processing failures.Expire an exact beta build when it must leave testing — after explicit human authorization, an app admin supplies all three independent confirmations from the status response:
POST /api/apps/{app_id}/builds/{build_id}/testflight-expire { "asc_build_id": "<exact-asc-build-id>", "confirm_version": "1.0.0", "confirm_build_number": "1000006" }Hands resolves the immutable build tuple again, refuses any ASC id or version/build mismatch, sets only the ASC Build resource's
expiredflag, and immediately reads the same build back. Before the Apple mutation it stores a redacted operation intent containing the actor and exact build coordinate; PATCH/readback success or failure terminalizes that receipt. Retrying an already-expired exact build is a200no-op withchanged=falseand links to any prior PATCH-confirmed operation, so a lost readback or audit response cannot erase who triggered the mutation. Expiration removes TestFlight availability; Apple keeps the build record for audit/history. Responses includeoperation_idfor supported inspection.
These endpoints and the matching CLI commands are TestFlight-only. They never activate a Hands release and never create, submit, or release an App Store production version.
Publication receipts and common pitfalls
Treat upload, review, tester notification, and App Store production as four different boundaries:
- Build Upload
COMPLETEand ASC buildVALIDprove only that Apple accepted and processed the binary. - Group assignment, What to Test,
BETA_APPROVED, orapproved_not_notifiedprove eligibility, not tester notification. auto_notify_enabledis a scheduling-policy field, not an event log.falsecan appear before or after a manual notification and cannot prove that notification did not happen.- TestFlight tester notification never activates a Hands release and never publishes an App Store production version.
For an external publish that must notify testers, preserve two independent receipts:
- The mutation response from
testflight-publishmust havenotification=sentornotification=already_sent. - A fresh, separate
testflight-statusread must showexternal_build_state=IN_BETA_TESTINGfor the exact ASC build.
Do not add --wait to the receipt-producing CLI call. The current testflight-publish --wait --json implementation prints the final polled GET status instead of the original POST response; GET status does not contain the POST notification or operation_id. Use the publish command without --wait, save its JSON, and poll separately with testflight-status.
The notification enum is deliberate:
| Value | Meaning | Notification complete? |
|---|---|---|
not_requested | No notification requested | No |
scheduled | Auto-notify is pending review/testing | No |
sent | Manual notification operation succeeded | Yes, with fresh IN_BETA_TESTING |
already_sent | Idempotent replay found notification already complete | Yes, with fresh IN_BETA_TESTING |
When the first mutation returns scheduled, wait until the exact build reaches IN_BETA_TESTING, then replay the same publish without --wait. Preserve the expected already_sent receipt and fresh-read the exact status once more. If either receipt is missing after a bounded recheck, report notification as unverified rather than calling the build published or sent.
One-time setup (app admin)
Store the App Store Connect API credential in Hands: console → App → Integrations → TestFlight (PUT /api/apps/{app_id}/asc-credentials). Generate the key in App Store Connect → Users and Access → Integrations (App Manager role); you need the Key ID, Issuer ID, and the .p8 file. The credential is encrypted at rest and can be verified without exposing it (POST .../asc-credentials/verify). The same credential powers the App Store review-state surface (GET /api/apps/{app_id}/appstore-review). Hands is the credential's only home — CI never needs a copy, so rotation stays single-source.
Hands roles are intentionally split: uploading a build to Apple and distributing it both require app publisher (upload is a publishing action on your own app, and a publisher-scoped CI token must be able to drive it), expiring an exact beta build requires app admin, and status/group reads require app viewer. Apple's own role boundary still applies to the stored key: external testing requires Account Holder, Admin, or App Manager; internal testing also permits Developer or Marketing.
Before first external testing, App Store Connect must have the required Beta App Description, feedback email, contact information, and export-compliance answers. Hands fails closed and returns Apple's actionable state/error if those prerequisites are missing. Apple allows only one build of a version in Beta App Review at a time and up to six submitted builds in a 24-hour period.
Versioning rules
- The marketing version (e.g.
1.0.0) may repeat across uploads. - The build number (
versionCode, e.g.1000004) must be unique and ascending for that marketing version — Apple rejects reused build numbers. Hands build history is the quick way to see the last used code. - TestFlight access expires 90 days after upload.
- External distribution is fail-closed: Apple must explicitly return
build_audience_type=APP_STORE_ELIGIBLE.INTERNAL_ONLY, missing, or future unknown audience values are rejected before any localization, group, review, or notification mutation.
CLI and API
CLI:
hands builds testflight-groups <app> <hands-build-id>
hands builds testflight-publish <app> <hands-build-id> \
--distribution external \
--group-id <asc-beta-group-id> \
--what-to-test en-US="Verify the release candidate." \
--notify-testers \
--json > testflight-publish-receipt.json
hands builds testflight-status <app> <hands-build-id> \
--distribution external \
--jsonAgents use the Hands CLI or API with Hands app/build ids and stable beta group ids. Neither returns the stored .p8 credential.
Beta App Description is app-level invitation copy and uses betaAppLocalizations.description. It is not the build-level What to Test field (betaBuildLocalizations.whatsNew). The dedicated update API preserves unsupplied locales and succeeds only after exact Apple readback.
App Store release (version, listing, screenshots, review)
Hands uses the stored App Store Connect key for supported submission steps. Some changes, including App Information and Duo screenshot writes, currently require App Store Connect; see the restrictions below. Every CLI step that changes something at Apple is a dry run unless --commit is passed.
On the API, mode defaults differ on purpose:
- prepare (
POST .../app-store/versions) defaults tocommitfor backward compatibility. Its writes are reversible: the version string, build and release type can all be changed again by another prepare. - listing text and screenshots have no default
mode: they overwrite live store content, so the caller must saydry_runorcommit. - release has no default
modeeither: it is irreversible.
# 1. version + build: create, reuse, or rename (see below)
hands app-store prepare raft-ios --version-name 1.17.0 --build <asc-build-id> # plan
hands app-store prepare raft-ios --version-name 1.17.0 --build <asc-build-id> --commit
# 2. listing text per locale
hands app-store localizations push raft-ios --version-name 1.17.0 --file listing.json # plan
hands app-store localizations push raft-ios --version-name 1.17.0 --file listing.json --commit
# 3. screenshots: shots/<locale>/<display_type>/NN.png
hands app-store screenshots push raft-ios --version-name 1.17.0 --dir shots # plan
hands app-store screenshots push raft-ios --version-name 1.17.0 --dir shots --commit
# 4. review: preflight (read-only), then submit
hands app-store preflight raft-ios --version-name 1.17.0
hands app-store submit raft-ios --version-name 1.17.0 --commit
hands app-store status raft-ios --version-name 1.17.0
hands app-store withdraw raft-ios --version-name 1.17.0 --commit
# 5. after approval (MANUAL release type): release -- irreversible
hands app-store release raft-ios <asc-version-id> --version-name 1.17.0 --build <asc-build-id> # plan
hands app-store release raft-ios <asc-version-id> --version-name 1.17.0 --build <asc-build-id> --commitPreflight: is the version ready for review?
GET /api/apps/{appId}/app-store/versions/{versionId}/preflight (app viewer) is read-only: every call Hands makes to App Store Connect is a GET. CLI: hands app-store preflight <app> --version-name 1.17.0 [--json] exits 1 when any error is found; hands app-store submit without --commit prints the errors too. Submit itself is not blocked by it.
The response is {version_id, version_string, state, ready, findings, checked, not_checkable}. Each finding has severity (error, warning, info), check, optional locale / display_type, resource, message and fix. resource uses the paths Apple uses in a refused submit's associated errors (for example /v1/appStoreVersionLocalizations/<id>), and a missing field reads as Apple words it ("You must provide a value for the attribute 'supportUrl'").
| Check | Severity |
|---|---|
version_state: editable (PREPARE_FOR_SUBMISSION, DEVELOPER_REJECTED, REJECTED, METADATA_REJECTED, INVALID_BINARY) | error otherwise; WAITING_FOR_REVIEW / IN_REVIEW is info |
build: attached, VALID, not expired | error |
build: build's version string differs from the App Store version | warning |
export_compliance: usesNonExemptEncryption unset on the build | warning |
copyright empty | warning |
localization_required_fields: description, keywords, supportUrl per locale | error |
localization_whats_new: empty whatsNew (required unless first version) | info |
localization_lengths: description 4000, keywords 100, promotionalText 170, whatsNew 4000 | warning |
app_info_localizations: each version locale has an app name; privacy policy URL on the primary locale | error (privacy URL on other locales: warning) |
screenshots: primary locale has a 6.9"/6.5" iPhone set (APP_IPHONE_67 / APP_IPHONE_65); every screenshot COMPLETE | error |
screenshots: other locales without screenshots fall back to the primary | info |
screenshots: iPad sets exist but none 13" (APP_IPAD_PRO_3GEN_129) on the primary | warning; when Hands cannot tell whether the app runs on iPad, info |
review_detail: contact first/last name, phone, email; demo account name and password when demoAccountRequired | error |
age_rating: no age rating declaration | warning |
review_submission: another version holds an open submission (WAITING_FOR_REVIEW, IN_REVIEW, UNRESOLVED_ISSUES) | error; this version's own submission or a draft is info |
A resource Apple refuses to return becomes a warning unreadable:<what> with Apple's status, code and detail; the rules that need it are listed in not_checkable. ready is true only with no error, no unreadable resource, and every rule run. App Privacy answers are not in the public App Store Connect API, and pricing/availability, content rights and categories are not read yet: they are always listed in not_checkable.
Prepare: one unreleased version per platform
Apple allows only one not-yet-released App Store version per platform. A withdrawn version (for example 1.12.0, now DEVELOPER_REJECTED) still occupies that slot, so creating 1.17.0 fails with Apple's 409 "You cannot create a new version of the App in the current state." Prepare reads every version for the platform (include=build) and decides:
| Apple has | Prepare does | action |
|---|---|---|
| a version with the requested string, editable | attaches the build | reused |
| a version with the requested string, not editable | 409 VERSION_NOT_EDITABLE | - |
another unreleased version Apple holds (WAITING_FOR_REVIEW, IN_REVIEW, PENDING_DEVELOPER_RELEASE, ...) | 409 VERSION_IN_FLIGHT, naming it; withdraw it first | - |
exactly one editable, never-released version (PREPARE_FOR_SUBMISSION, DEVELOPER_REJECTED, REJECTED, METADATA_REJECTED, INVALID_BINARY) | renames it to the requested string and attaches the build in one PATCH | renamed, with previous_version_string |
| only released versions | creates the version, then attaches the build | created |
A version in a state Hands does not recognise, or two editable versions, refuses (409 VERSION_STATE_UNKNOWN / MULTIPLE_EDITABLE_VERSIONS) rather than guessing. After the write Hands reads the version back (GET /v1/appStoreVersions/{id}?include=build); a version string or build that does not match returns 502 READBACK_MISMATCH. A failure after an earlier write returns 502 PREPARE_PARTIALLY_APPLIED with completed_steps. Renaming keeps the version's existing localizations and screenshots.
release_type (MANUAL or AFTER_APPROVAL) applies to every action. On a reused or renamed version Hands sends releaseType only when it differs from Apple's current value (in the same PATCH as the rename and build), and the read-back must show it (502 READBACK_MISMATCH with release_type otherwise). When release_type is omitted, a created version gets MANUAL and an existing version keeps whatever Apple has; prepare never resets it. SCHEDULED is refused with 400 RELEASE_TYPE_SCHEDULED_UNSUPPORTED, because Apple needs an earliestReleaseDate that Hands does not set. The response (dry run included) carries changes: { version_string, build, release_type }, each {from, to} or null when unchanged; release_type is Apple's value (read back after a commit) and requested_release_type is what was asked (null if omitted).
Release an approved version
With release type MANUAL, an approved version waits at PENDING_DEVELOPER_RELEASE. Releasing it is irreversible:
POST /api/apps/{appId}/app-store/versions/{versionId}/release (app publisher) with { "mode": "dry_run" | "commit", "version_string": "1.17.0", "asc_build_id": "<optional>" }.
modeis required;dry_runonly reads.version_string(andasc_build_idwhen given) must equal what Apple holds for that version, or 409RELEASE_CONFIRMATION_MISMATCH; nothing is sent.- Only
PENDING_DEVELOPER_RELEASEis released. A version already live (READY_FOR_SALE/READY_FOR_DISTRIBUTION, or retired) is 409ALREADY_RELEASED, which is not a success. Any other state is 409NOT_RELEASABLEwith the state. - On commit Hands records an operation receipt (kind
app-store-release) and anapp_store.release_intentaudit row first; if either cannot be written, nothing is sent to Apple (500RELEASE_INTENT_NOT_RECORDED). Then it sendsPOST /v1/appStoreVersionReleaseRequestsand reads the version back for up to about 30 seconds. The result is recorded asapp_store.release(orapp_store.release_failed). 200withoutcome: "released"only when Apple showsREADY_FOR_SALE/READY_FOR_DISTRIBUTION. Otherwise202withoutcome: "release_requested"andobserved_state: Apple releases asynchronously, so checkhands app-store statuslater.- Apple's release-request resource cannot be listed, so duplicates are guarded by the version state plus the Hands receipt: within 6 hours of an accepted request a repeat call returns
202 release_requestedwithprior_operation_idand sends nothing; while another release call is still running it is 409RELEASE_IN_PROGRESS.
State changes after the release reach webhooks through the existing app_store:version_state_changed event.
Listing text
POST /api/apps/{appId}/app-store/versions/{versionId}/localizations with { "mode": "dry_run" | "commit", "localizations": { "<locale>": { ... } } }. Fields are Apple's appStoreVersionLocalizations attributes: description (at most 4000 characters), keywords (100), promotionalText (170), whatsNew (4000), marketingUrl, supportUrl (http or https). Only supplied fields are written; an existing locale is patched with the changed fields only, a missing locale is created. Creating a locale requires description, keywords and supportUrl (Apple accepts the create without them but then refuses review); without them the plan entry is blocked with missing_required_fields and lists missing_fields. The What's New endpoint (.../notes) only updates existing locales and returns 409 LOCALIZATION_MISSING for one that does not exist. While Apple holds the version only promotionalText may change; other fields return 409 LOCALIZATION_PLAN_BLOCKED. Commit reads every requested field back and fails with READBACK_MISMATCH if Apple holds anything else. App name, subtitle and primary category live separately on appInfos / appInfoLocalizations; use the App Information section below.
App Information (name, subtitle, primary category)
App Information is shared by the app's platforms, not owned by the selected version. The version is a context and safety check, not a guarantee that changes will affect only that version. Apple describes pending information as taking effect with the next version release. Confirm its intended effect and existing submissions in App Store Connect before submitting.
Create an app-info.json file containing only the desired fields, for example:
{
"primary_category": "PRODUCTIVITY",
"localizations": {
"en-US": { "name": "Example App", "subtitle": "Stay in touch" },
"ja": { "name": "Example App", "subtitle": "Connect with your team" }
}
}hands app-store app-info push raft-ios --version-name 1.22.0 --file app-info.json
# Add --commit to write (requires @botiverse/hands-cli >= 0.5.44).
# Optional: --version-id <ASC-version-id>, --platform <platform>, --jsonThe publisher-only POST /api/apps/{appId}/app-store/versions/{versionId}/app-info requires explicit mode and exact version_string (the CLI sends dry_run by default); commit also requires commit_contract_version: 1 (older CLIs are refused with 409 APP_INFO_CLIENT_UPGRADE_REQUIRED before any Apple call or lock). Preview makes only Apple GET requests and shows the existing and requested values, category, observed platforms and language differences. It does not check or reserve name availability, make writes, or submit a review. It requires an editable app-owned version and one unambiguous pending AppInfo; other active or unknown versions and unknown platform types block the plan. Category checks cover platforms present in Apple's version records, which may omit newly added platforms or an inconsistent response. A requested new App Information locale must also exist on the selected version; this is a conservative Hands guard, not a claim that Apple requires identical language sets. Existing differences are reported for inspection.
With --commit, Hands acquires an application-scoped apple_app_info operation lock, rechecks that the snapshot has not drifted, records an app_store.app_info.attempt audit row (storing SHA-256 and character length rather than plaintext), patches or creates only requested changed locale fields and primary_category, and reads back the selected AppInfo and rechecks consistency. If any write fails, returns an uncertain outcome, or mismatches on readback, Hands retains the lock with recovery_required: true so a second run cannot blindly replay; follow the store-product recovery runbook and inspect App Store Connect before releasing the lock. Hands does not submit for review automatically.
Screenshots
POST /api/apps/{appId}/app-store/versions/{versionId}/screenshots, multipart: a request JSON part { "mode": "dry_run" | "commit", "sets": [ { "locale": "en-US", "display_type": "APP_IPHONE_67", "images": [ { "part": "img1", "sha256": "<hex>" } ] } ] } plus one file part per image, at most 24 MiB of images per push.
Checked before any Apple call: sha256 of each part, PNG or JPEG, no transparency (a PNG with an alpha channel or tRNS chunk is refused; flatten it), 1-10 images per set, and an exact pixel size for the display type (portrait or landscape):
display_type | Sizes (portrait) |
|---|---|
APP_IPHONE_DUO (read-only) | 1398x2034, 2007x2853 |
HANDS_IPHONE_DUO_OUTER (legacy Hands selector; read-only) | 1398x2034 |
HANDS_IPHONE_DUO_INNER (legacy Hands selector; read-only) | 2007x2853 |
APP_IPHONE_67 (6.9" and 6.7") | 1320x2868, 1290x2796, 1260x2736 |
APP_IPHONE_65 | 1242x2688, 1284x2778 |
APP_IPHONE_61 (6.3" and 6.1") | 1206x2622, 1179x2556, 1170x2532, 1125x2436, 1080x2340 |
APP_IPHONE_55 | 1242x2208 |
APP_IPAD_PRO_3GEN_129 (13") | 2064x2752, 2048x2732 |
APP_IPAD_PRO_3GEN_11 | 1488x2266, 1668x2420, 1668x2388, 1640x2360 |
APP_IPAD_PRO_129 | 2048x2732 |
display_type normally uses an Apple ScreenshotDisplayType; other Apple values without a size table are refused. Apple has returned APP_IPHONE_DUO for one set containing both inner and outer screenshots. Use this one type for a mixed set; portrait and landscape dimensions may be combined. A complete existing set with identical checksums and order can be reported unchanged. Empty, untyped, duplicate or incomplete metadata blocks this check. Returned imageAsset dimensions may be missing, including while images are processing; their availability and interpretation are not guaranteed.
The earlier HANDS_IPHONE_DUO_OUTER and HANDS_IPHONE_DUO_INNER Hands-only selectors remain for compatibility but cannot represent mixed sets. Creating, reordering or replacing Duo sets is blocked even in a dry run: seeing a type in an Apple response does not verify API writes. Continue using App Store Connect for Duo changes until write behavior is verified.
For an ordinary Apple display type, an existing empty set, an image with missing dimensions or a Duo size blocks changes. A set whose Apple display type is missing also blocks planning for that locale. This protects against known hazards without assuming all collisions are detectable; it may prevent an update that previously worked. Inspect the set or use App Store Connect when blocked.
To inspect an existing version without changing screenshots:
hands app-store screenshots inspect raft-ios --version-name 1.17.0 --locale en-USThe inspection reports Apple's returned display type and image dimensions as-is; missing values are shown as unavailable. It uses only read operations and does not establish that Apple will accept a subsequent API upload.
Per set, the version must already have that locale (create it with the listing text step first). Existing screenshots are compared by MD5, which is what Apple stores as sourceFileChecksum:
- same files, same order, all
COMPLETE:unchanged, no write (re-running a push is safe); - same files, other order:
reorderonly; - anything else:
replace(orupload/create_set_and_upload): delete the set's screenshots, reserve each image, perform Apple's upload operations, commit with the MD5, pollassetDeliveryStateuntilCOMPLETE, set the order, then read the set back.
Each set is committed on its own: one set failing does not stop the others. Only an error that would hit every set (Apple rejects the API key with 401/403, 404, rate limit, Apple 5xx, a 409 about the version's state, or any error that is not clearly about that one set) stops the run; later sets are then not_attempted. Each set ends in one of:
ok: uploaded,COMPLETE, ordered, and read back;failed: an image endedFAILED, Apple refused a request for the set, or the read-back did not match;uploaded_pending: every image was uploaded and committed and none was rejected, but Apple was still processing at least one when the poll budget ran out. Not a success: the set's order and read-back were not done yet;not_attempted: skipped after a run-stopping error.
The response lists failed_sets and pending_sets. Any failed or not-attempted set makes it 502 SCREENSHOTS_FAILED (this wins over pending); otherwise any pending set makes it 202 SCREENSHOTS_PENDING with ok: false. The CLI exits non-zero for both and prints the code. For pending sets: Apple is still processing some screenshots. Run the dry run again later: if every set is unchanged, files and order are confirmed; if a set shows reorder or replace, run --commit again. Nothing is retried automatically. Because a replace deletes before it uploads, a failed replace can leave a set partly filled: fix the cause and push again.
Every prepare, listing, and screenshot call (dry runs included) is recorded in the app audit log (app_store.prepare_version, app_store.localizations, app_store.screenshots); listing text is recorded as sha256 and length only.
Apple references
- App Store Connect API: Prerelease Versions and Beta Testers
- Build Beta Notifications
- Add internal testers
- Invite external testers
- Provide test information
- TestFlight overview
- App Store Connect API: App Store version release requests
- App Store Connect API: App Store version localizations
- App Store Connect API: App screenshots
- Uploading assets to App Store Connect
- Screenshot specifications
App Store Connect webhook notifications
Apple can notify Hands when an App Store version's review state, a TestFlight build's external review state, or a build upload's processing state changes. This uses App Store Connect webhooks, not in-app-purchase server notifications.
An app admin creates an ingress configuration using the Hands app UUID:
POST /api/apps/{app_id}/apple-webhook
Authorization: Bearer <Hands session>
Content-Type: application/json
{"apple_app_id":"<numeric App Store Connect app ID>"}The response contains payload_url, secret (shown once), and event_types.
If the app already has App Store Connect credentials stored in Hands, an app admin can register this callback without copying keys or signing secrets:
hands api POST /api/apps/{app_id}/apple-webhook/registerHands calls Apple using the stored credentials, discovers an existing subscription for this exact callback URL before creating one, and reads it back to verify the URL, enabled state, and all four event types. The result reveals no secret. If Apple refuses the operation, the response names the failure stage and upstream status; use an App Store Connect key with permission to manage webhooks. Registration alone does not prove a real review notification reached your team.
The secret is encrypted at rest with the existing ASC credential encryption key; keep it out of chat, logs, and shell arguments. Creating an existing configuration returns 409 without rotating its secret. GET on the same path returns metadata only; DELETE invalidates the URL. Recreating it produces a new URL and secret; previous event receipts survive rotation.
In App Store Connect, open Users and Access → Integrations → Additional → Webhooks, create a webhook, paste the returned URL and secret, and select the same Apple app specified by apple_app_id. Select these four triggers:
| Apple trigger | Hands outbound event |
|---|---|
APP_STORE_VERSION_APP_VERSION_STATE_UPDATED | app_store:version_state_changed |
BUILD_BETA_DETAIL_EXTERNAL_BUILD_STATE_UPDATED | testflight:external_state_changed |
BUILD_UPLOAD_STATE_UPDATED | app_store:build_upload_state_changed |
BETA_FEEDBACK_CRASH_SUBMISSION_CREATED | testflight:crash_submission_created |
Choose Test and check the delivery's successful HTTP response in Apple's recent deliveries. Authenticated test pings and unsupported event types are acknowledged without a review notification. A test ping proves Apple can reach the receiver; it does not prove a real review event reached your chat channel.
Subscribe your existing app-scoped or organization-scoped Hands webhook (or Raft channel notifications) to the outbound events above. The existing queue sends notifications immediately and retries failures through its usual delivery ledger. A chat relay must support these event types and deduplicate the stable event_id. Creating the ingress does not itself configure a chat destination.
Outbound payload contains apple_event_id, apple_app_id, app (id, slug, name), resource (type, id), previous_state, state, occurred_at, and app_store_connect_url. States are Apple's raw values. Apple build-upload events can omit the previous state and timestamp; those fields are then null. The resource ID identifies the Apple version/build, not a Hands build ID or a human-readable version number. No credentials or full Apple payloads are forwarded. Display the event's timestamp and state transition; out-of-order delivery must not overwrite an assumed current status.
For testflight:crash_submission_created, resource identifies the crash submission, state and occurred_at contain Apple's createdDate, and previous_state is null. Hands follows the submission's build relationship to add the same best-effort build identity used by other TestFlight events. It also creates an idempotent crash ticket in the normal feedback triage surface and stores the .ips log as an internal attachment (up to 2 MiB). The outbound payload carries ticket_id, never the crash-log body. A crash submission reports a tester chose to share it, not the total crash count for the build.
To backfill existing submissions, an app admin can call POST /api/apps/{appId}/testflight-crashes/backfill with the same optional build, version, and limit filters as the pull endpoint. It uses the same idempotent ingest and returns ticket_ids and failed_submission_ids; a repeat run does not create duplicate tickets. This is the supported way to recover history and any submission whose earlier ingest failed.
Hands verifies x-apple-signature over the exact request bytes, limits the body to 64 KiB, and commits the event receipt and matching outbound deliveries atomically. Replayed event IDs do not create extra deliveries; reuse of an ID with different bytes returns 409. Invalid signatures return 401 and malformed supported events return 400. Apple provides manual redelivery in its delivery history. Configure the webhook before relying on future notifications; it does not replay review changes that happened before it existed.
Upload processing and internal TestFlight notifications
Apple currently exposes an external TestFlight state webhook, but not an internal TestFlight state webhook. For operations performed through Hands, Hands also emits two events from Apple's verified operation readback:
testflight:upload_processing: the upload response reportsPROCESSING.testflight:internal_distribution_ready: the internal publish readback reportsIN_BETA_TESTINGand access for every requested internal group.
Subscribe to these events on an app or organization webhook. They use the existing event / event_id / payload envelope. The payload includes source: hands_operation_receipt, operation_id, app and Apple resource identities, version, build number, platform, state, and an App Store Connect link. Internal distribution also includes selected groups and auto_notify_enabled. The optional groups_display field is a complete Chinese notification line (for example, 测试组:Botiverse、Team B) containing all selected group names; an unnamed group falls back to its ID. It is absent on upload and Apple callback events, so scalar notification templates can leave the line blank. Internal distribution also carries what_to_test and source_commit (see Test notes and source commit). This confirms internal availability, not that a tester received an email. The operation success receipt and delivery rows commit atomically; repeating the same operation receipt does not duplicate deliveries.
These are distinct from Apple callback events. A terminal upload readback does not invent an earlier PROCESSING transition. Changes made directly in App Store Connect are not covered by these Hands operation events.
build on Apple callback events
Apple's own webhook envelope identifies the affected build only by its buildBetaDetails id — it carries no version or build number. So for testflight:external_state_changed, Hands follows buildBetaDetails/{id}/build once and adds a build field to the payload:
"build": { "version": "1.14.0", "version_code": "11400009" }version is the marketing version (preReleaseVersion.version on Apple's side) and version_code the build number (builds.version) — App Store Connect stores those two in different resources, so they are not interchangeable.
The field is best effort and never blocks a notification. If the app has no App Store Connect credentials, Apple is slow (>5s) or errors, or the response is an unexpected shape, build is null and the event is still delivered with every other field unchanged. The build key itself is always present, so a consumer must treat null as "version unknown, open the link" rather than as an absent field. Events whose resource is not a build (e.g. app_store:version_state_changed) always report build: null.
Test notes and source commit
testflight:external_state_changed and testflight:internal_distribution_ready also carry two keys for rendering a "ready to test" card:
"what_to_test": { "zh-Hans": "测试登录流程", "en-US": "Check the login flow" },
"source_commit": "0123456789abcdef0123456789abcdef01234567"what_to_test maps locale to the build's TestFlight What to Test text, each truncated to 1000 characters; blank locales are dropped. On the external event Hands reads GET /v1/builds/{id}/betaBuildLocalizations for the build named by the build lookup (5 s budget). On the internal event it uses the publish readback, falling back to the what_to_test sent with the publish request; no extra Apple call is made.
source_commit is provenance_json.source_commit of the Hands iOS build with the same build number (the internal event prefers the published Hands build itself). It is set when the build was uploaded with --source-commit.
Both are best effort and never block delivery: the keys are always present on these two events and are null when the build, its localizations, or a matching Hands build cannot be read. Other event types do not carry them.
version_string on version state events
app_store:version_state_changed also carries the marketing version of the appStoreVersions resource, read with one GET /v1/appStoreVersions/{id}?fields[appStoreVersions]=versionString:
"version_string": "1.17.0", "version_lookup": "ok"Same contract as build: best effort, 5 s budget, never blocks delivery. When the app has no App Store Connect credentials or the read fails, the event is delivered with "version_string": null, "version_lookup": "failed". Other event types do not carry these keys.