```html SwiftUI: How to App Store Submission (iOS 17+, 2026)

How to implement App Store submission in SwiftUI

iOS 17+ Xcode 16+ Advanced APIs: App Store Connect API Updated: May 11, 2026
TL;DR

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

  1. 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 .p8 file using CryptoKit.P256.Signing.PrivateKey.
  2. Build resolution (AscClient.builds). The /v1/builds endpoint is filtered by processingState=VALID (Apple has finished processing the binary) and your bundle ID, sorted by upload date descending with limit=1 so you always grab the freshest build without extra iteration.
  3. Version creation (createVersion). A POST to /v1/appStoreVersions creates 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.
  4. Submission trigger (submitForReview). POSTing to /v1/appStoreVersionSubmissions transitions the version into Waiting For Review state. Apple's pipeline takes over from here.
  5. 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

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.

```