How to implement App Store submission in SwiftUI
Submit your SwiftUI app to the App Store programmatically by minting a JWT with your App Store Connect API key,
resolving your latest processed build via the /v1/builds endpoint, creating an App Store version,
and posting to /v1/appStoreVersionSubmissions to trigger review.
// Mint a JWT and submit the latest build for App Store review
let token = try AscJWT(keyId: KEY_ID, issuerId: ISS_ID, p8: P8_CONTENTS).sign()
let client = AscClient(jwt: token)
let builds = try await client.builds(bundleId: "com.example.MyApp", state: "VALID")
guard let buildId = builds.first?.id else { throw AscError.noBuild }
let version = try await client.createVersion(appId: APP_ID, versionString: "1.0.1",
buildId: buildId)
let submission = try await client.submitForReview(versionId: version.id)
print("Submitted:", submission.id)
Full implementation
The App Store Connect API uses short-lived (20 min) ES256-signed JWTs — no OAuth dance required.
The pattern below encapsulates JWT minting, REST calls with URLSession, and the three-step
submission pipeline (resolve build → create/patch version → submit) into a lightweight Swift executable
you can run from a Makefile, a GitHub Actions step, or directly inside Soarias's Build phase.
// AscSubmit.swift — run with: swift AscSubmit.swift
// Requires: swift-crypto via SPM, or use CryptoKit on macOS 13+
import Foundation
import CryptoKit
// ── 1. JWT ────────────────────────────────────────────────────────────────────
struct AscJWT {
let keyId: String
let issuerId: String
let p8: String // contents of AuthKey_XXXXXXXX.p8
func sign() throws -> String {
let header = #"{"alg":"ES256","kid":"\#(keyId)","typ":"JWT"}"#
let now = Int(Date().timeIntervalSince1970)
let payload = """
{"iss":"\(issuerId)","iat":\(now),"exp":\(now + 1200),\
"aud":"appstoreconnect-v1"}
"""
let b64h = Data(header.utf8).base64URLEncoded()
let b64p = Data(payload.utf8).base64URLEncoded()
let msg = "\(b64h).\(b64p)"
let stripped = p8
.replacingOccurrences(of: "-----BEGIN PRIVATE KEY-----", with: "")
.replacingOccurrences(of: "-----END PRIVATE KEY-----", with: "")
.replacingOccurrences(of: "\n", with: "")
guard let keyData = Data(base64Encoded: stripped) else {
throw AscError.invalidKey
}
let privateKey = try P256.Signing.PrivateKey(pkcs8DerRepresentation: keyData)
let sig = try privateKey.signature(for: Data(msg.utf8))
return "\(msg).\(sig.rawRepresentation.base64URLEncoded())"
}
}
extension Data {
func base64URLEncoded() -> String {
base64EncodedString()
.replacingOccurrences(of: "+", with: "-")
.replacingOccurrences(of: "/", with: "_")
.replacingOccurrences(of: "=", with: "")
}
}
// ── 2. REST client ────────────────────────────────────────────────────────────
enum AscError: Error { case invalidKey, noBuild, http(Int, String) }
struct AscClient {
let jwt: String
private let base = URL(string: "https://api.appstoreconnect.apple.com/v1")!
private func request(_ path: String,
method: String = "GET",
body: [String: Any]? = nil) async throws -> [String: Any] {
var req = URLRequest(url: base.appendingPathComponent(path))
req.httpMethod = method
req.setValue("Bearer \(jwt)", forHTTPHeaderField: "Authorization")
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
if let body { req.httpBody = try JSONSerialization.data(withJSONObject: body) }
let (data, resp) = try await URLSession.shared.data(for: req)
let code = (resp as! HTTPURLResponse).statusCode
guard (200...299).contains(code) else {
throw AscError.http(code, String(decoding: data, as: UTF8.self))
}
return try JSONSerialization.jsonObject(with: data) as! [String: Any]
}
// Fetch processed builds for a bundle ID
func builds(bundleId: String, state: String = "VALID") async throws -> [[String: Any]] {
let path = "builds?filter[processingState]=\(state)"
+ "&filter[app.bundleId]=\(bundleId)"
+ "&sort=-uploadedDate&limit=1"
let res = try await request(path)
return (res["data"] as? [[String: Any]]) ?? []
}
// Create (or reuse) an App Store version and attach a build
func createVersion(appId: String,
versionString: String,
buildId: String) async throws -> (id: String, [String: Any]) {
let body: [String: Any] = [
"data": [
"type": "appStoreVersions",
"attributes": [
"platform": "IOS",
"versionString": versionString
],
"relationships": [
"app": ["data": ["type": "apps", "id": appId]],
"build": ["data": ["type": "builds", "id": buildId]]
]
]
]
let res = try await request("appStoreVersions", method: "POST", body: body)
let data = res["data"] as! [String: Any]
return (id: data["id"] as! String, data)
}
// Submit the version for App Store review
func submitForReview(versionId: String) async throws -> [String: Any] {
let body: [String: Any] = [
"data": [
"type": "appStoreVersionSubmissions",
"relationships": [
"appStoreVersion": [
"data": ["type": "appStoreVersions", "id": versionId]
]
]
]
]
let res = try await request("appStoreVersionSubmissions", method: "POST", body: body)
return res["data"] as! [String: Any]
}
}
// ── 3. Entry point ────────────────────────────────────────────────────────────
let KEY_ID = ProcessInfo.processInfo.environment["ASC_KEY_ID"]!
let ISS_ID = ProcessInfo.processInfo.environment["ASC_ISSUER_ID"]!
let APP_ID = ProcessInfo.processInfo.environment["ASC_APP_ID"]!
let BUNDLE = ProcessInfo.processInfo.environment["APP_BUNDLE_ID"]!
let VERSION = ProcessInfo.processInfo.environment["APP_VERSION"] ?? "1.0.1"
let P8_PATH = ProcessInfo.processInfo.environment["ASC_P8_PATH"]!
Task {
do {
let p8 = try String(contentsOfFile: P8_PATH)
let jwt = try AscJWT(keyId: KEY_ID, issuerId: ISS_ID, p8: p8).sign()
let asc = AscClient(jwt: jwt)
print("🔍 Fetching latest valid build for \(BUNDLE)…")
let builds = try await asc.builds(bundleId: BUNDLE)
guard let build = builds.first, let buildId = build["id"] as? String else {
throw AscError.noBuild
}
print("✅ Build: \(buildId)")
print("📦 Creating App Store version \(VERSION)…")
let (versionId, _) = try await asc.createVersion(appId: APP_ID,
versionString: VERSION,
buildId: buildId)
print("✅ Version: \(versionId)")
print("🚀 Submitting for review…")
let sub = try await asc.submitForReview(versionId: versionId)
print("🎉 Submission ID:", sub["id"] ?? "unknown")
} catch {
fputs("❌ \(error)\n", stderr)
exit(1)
}
}
RunLoop.main.run(until: Date(timeIntervalSinceNow: 30))
How it works
-
JWT minting (AscJWT.sign). The App Store Connect API authenticates via a
self-signed ES256 JWT — no session cookies or OAuth flows. The token encodes your
Issuer ID, a 20-minute expiry, and is signed with the P256 private key from your
.p8file usingCryptoKit.P256.Signing.PrivateKey. -
Build resolution (AscClient.builds). The
/v1/buildsendpoint is filtered byprocessingState=VALID(Apple has finished processing the binary) and your bundle ID, sorted by upload date descending withlimit=1so you always grab the freshest build without extra iteration. -
Version creation (createVersion). A POST to
/v1/appStoreVersionscreates the App Store version record and wires the build relationship in a single call. If a version for that string already exists (e.g. a rejected build you're resubmitting), use PATCH instead — see the Variants section below. -
Submission trigger (submitForReview). POSTing to
/v1/appStoreVersionSubmissionstransitions the version into Waiting For Review state. Apple's pipeline takes over from here. -
Environment-driven config. All secrets (
ASC_KEY_ID,ASC_ISSUER_ID,ASC_P8_PATH) are read from environment variables so the script is safe to commit and secrets stay in CI secrets or Soarias's encrypted keychain.
Variants
Resubmit a rejected build (PATCH existing version)
// When a version already exists in a REJECTED or PREPARE_FOR_SUBMISSION state,
// PATCH it to attach the new build, then POST the submission.
func patchVersion(versionId: String, buildId: String) async throws {
let body: [String: Any] = [
"data": [
"type": "appStoreVersions",
"id": versionId,
"relationships": [
"build": ["data": ["type": "builds", "id": buildId]]
]
]
]
_ = try await request("appStoreVersions/\(versionId)", method: "PATCH", body: body)
}
// Usage — find the existing version ID from /v1/appStoreVersions?filter[app]=APP_ID
let existingVersionId = "abc123def456"
try await asc.patchVersion(versionId: existingVersionId, buildId: buildId)
let sub = try await asc.submitForReview(versionId: existingVersionId)
print("Resubmitted:", sub["id"] ?? "–")
Check review status after submission
Poll GET /v1/appStoreVersions/{id}?fields[appStoreVersions]=appStoreState every
few minutes and look for appStoreState == "IN_REVIEW", "APPROVED",
or "REJECTED". Wrap this in a GitHub Actions step with
sleep 300 && swift check_status.swift, or use Soarias's built-in
polling notifications so you get a push when Apple's decision lands.
Common pitfalls
-
JWT expiry is 20 minutes max. Apple enforces this server-side; setting
expfurther in the future returns HTTP 401. Always mint a fresh token per script run rather than caching across CI steps. -
Build must reach
processingState = VALIDbefore submission. Uploading withxcodebuild archive | xcrun altoolis asynchronous — Apple's processing takes 2–10 minutes. Query/v1/buildsin a retry loop; submitting against aPROCESSINGbuild returns HTTP 409. -
Privacy manifest and required reason APIs block review, not submission.
The submission call succeeds even if your privacy manifest is incomplete — Apple only
rejects during review. Run
xcrun privacy-manifest validatelocally (Xcode 16+) before you submit to catch missingNSPrivacyAccessedAPITypesentries early. -
Revoked API keys return 403 with no helpful message. If you rotate your
App Store Connect API key, update
ASC_P8_PATH,ASC_KEY_ID, andASC_ISSUER_IDtogether — a stale issuer ID with a new key silently fails authentication.
Prompt this with Claude Code
When using Soarias or Claude Code directly to implement this:
Implement app store submission in SwiftUI for iOS 17+. Use App Store Connect API. Make it accessible (VoiceOver labels). Add a #Preview with realistic sample data.
In Soarias's Build phase, drop this script into the Ship action
alongside your Fastfile so a single soarias ship command archives, uploads to
TestFlight, waits for processing, and triggers the App Store submission without leaving
your editor.
Related
FAQ
Does this work on iOS 16?
The submission script runs on macOS (not on-device), so iOS version is irrelevant
to the script itself. The app binary you submit can target iOS 16+ — just set
IPHONEOS_DEPLOYMENT_TARGET = 16.0 in your Xcode project. However, if your
SwiftUI views use iOS 17 APIs (@Observable, ScrollPosition, etc.),
wrap them in #available(iOS 17, *) checks or raise your deployment target to
iOS 17 to avoid runtime crashes on older devices.
Can I add or update App Store metadata (screenshots, description) via the API too?
Yes. Before calling submitForReview, POST or PATCH
/v1/appStoreVersionLocalizations/{id} to update descriptions, keywords, and
promotional text, and POST to /v1/appScreenshots to upload screenshots.
Fastlane's deliver action wraps all of this — use it when you have more than
a few locales to manage. For a lean pipeline with just English, the raw API calls above
are simpler.
UIKit equivalent?
App Store submission is entirely server-side via the App Store Connect API — it has nothing
to do with whether your UI is built in UIKit or SwiftUI. The same
AscSubmit.swift script works unchanged for any iOS app. The only
UIKit-specific consideration is ensuring your UIMainStoryboardFile or
NSPrincipalClass keys in Info.plist are correct so Apple's
binary validation passes during processing.
Last reviewed: 2026-05-11 by the Soarias team.