Tüm Yazılar
KategoriTools
Okuma Süresi
15 dk okuma
Yayın Tarihi
...
Kelime Sayısı
3.531kelime

Kahveni hazırla - bu içerikli bir makale!

App Store Connect API ile TestFlight build yükleme, metadata update, submission automation, Fastlane alternatifi native Swift approach, reports ve analytics.

App Store Connect API 2026: TestFlight, Submission, Analytics Otomasyonu

# App Store Connect API 2026: TestFlight, Submission, Analytics Otomasyonu

App Store Connect API, Apple'ın App Store operasyonlarını programatik olarak yönetmek için sunduğu REST API. 2018'de ilk versiyonu çıktı, 2026 itibarıyla v3.8'e ulaştı ve artık neredeyse tüm App Store Connect web arayüzü işlemlerini kapsıyor. Fastlane spaceship üzerinden yıllarca yürüttüğün otomasyonları artık native Swift ile yapabilirsin — third-party dependency, session cookie hack, eski Apple API workaround yok.

💡 Pro Tip: Fastlane kullanıyorsan migration'ı aceleye getirme. ASC API bazı işlevlerde hâlâ eksik (örn. App Clips entitlements yönetimi). Ama yeni pipeline kuruyorsan doğrudan ASC API ile başla — 2026'da spaceship tabanlı Fastlane action'larının yarısı deprecated.

İçindekiler


JWT Authentication: ES256 Token Üretimi

ASC API, OAuth veya API key kullanmıyor — JWT (JSON Web Token) ile ES256 imzalama yapıyor. Her request için 20 dakika geçerli token üretmen gerekiyor.

API Key Oluşturma

App Store Connect Dashboard → Users and Access → Keys → Generate API Key. Şu bilgileri al:

  • Issuer ID:: UUID formatında, account'a özel
  • Key ID:: Kısa alfanumerik kod
  • Private Key (.p8):: Sadece bir kez indirilir, kaybedersen yenisi lazım

Swift ile JWT Token Üretimi

swift
1import Foundation
2import CryptoKit
3 
4struct AscJwtGenerator {
5 let issuerId: String
6 let keyId: String
7 let privateKeyPem: String
8 
9 func generateToken(expirationMinutes: Int = 20) throws -> String {
10 // Header
11 let header = ["alg": "ES256", "kid": keyId, "typ": "JWT"]
12 let headerData = try JSONSerialization.data(withJSONObject: header)
13 let headerEncoded = headerData.base64URLEncoded
14 
15 // Payload
16 let now = Date()
17 let exp = now.addingTimeInterval(TimeInterval(expirationMinutes * 60))
18 let payload: [String: Any] = [
19 "iss": issuerId,
20 "iat": Int(now.timeIntervalSince1970),
21 "exp": Int(exp.timeIntervalSince1970),
22 "aud": "appstoreconnect-v1"
23 ]
24 let payloadData = try JSONSerialization.data(withJSONObject: payload)
25 let payloadEncoded = payloadData.base64URLEncoded
26 
27 // Signing
28 let signingInput = "(headerEncoded).(payloadEncoded)"
29 let signingData = Data(signingInput.utf8)
30 
31 let privateKey = try P256.Signing.PrivateKey(pemRepresentation: privateKeyPem)
32 let signature = try privateKey.signature(for: signingData)
33 let signatureEncoded = signature.derRepresentation.base64URLEncoded
34 
35 return "(signingInput).(signatureEncoded)"
36 }
37}
38 
39// Data extension — base64 URL encoding
40extension Data {
41 var base64URLEncoded: String {
42 base64EncodedString()
43 .replacingOccurrences(of: "+", with: "-")
44 .replacingOccurrences(of: "/", with: "_")
45 .replacingOccurrences(of: "=", with: "")
46 }
47}

Token Caching ve Yenileme

swift
1actor TokenCache {
2 private var cachedToken: String?
3 private var expiresAt: Date = .distantPast
4 
5 func validToken(generator: AscJwtGenerator) async throws -> String {
6 if let token = cachedToken, Date() < expiresAt.addingTimeInterval(-60) {
7 return token
8 }
9 let newToken = try generator.generateToken(expirationMinutes: 20)
10 cachedToken = newToken
11 expiresAt = Date().addingTimeInterval(20 * 60)
12 return newToken
13 }
14}

Core Endpoints: Apps, Builds, Beta Groups

App Listesi ve Bundle ID Lookup

swift
1struct AscClient {
2 let baseURL = URL(string: "https://api.appstoreconnect.apple.com/v1")!
3 let tokenCache: TokenCache
4 let generator: AscJwtGenerator
5 
6 func fetchApps() async throws -> [AscApp] {
7 let token = try await tokenCache.validToken(generator: generator)
8 var request = URLRequest(url: baseURL.appendingPathComponent("apps"))
9 request.setValue("Bearer (token)", forHTTPHeaderField: "Authorization")
10 
11 let (data, response) = try await URLSession.shared.data(for: request)
12 guard let http = response as? HTTPURLResponse, http.statusCode == 200 else {
13 throw AscError.httpError((response as? HTTPURLResponse)?.statusCode ?? 0)
14 }
15 let decoded = try JSONDecoder().decode(AscAppsResponse.self, from: data)
16 return decoded.data
17 }
18 
19 func fetchBuilds(appId: String, limit: Int = 25) async throws -> [AscBuild] {
20 let token = try await tokenCache.validToken(generator: generator)
21 var components = URLComponents(
22 url: baseURL.appendingPathComponent("builds"),
23 resolvingAgainstBaseURL: false
24 )!
25 components.queryItems = [
26 URLQueryItem(name: "filter[app]", value: appId),
27 URLQueryItem(name: "limit", value: "(limit)"),
28 URLQueryItem(name: "sort", value: "-uploadedDate"),
29 URLQueryItem(name: "include", value: "app,preReleaseVersion")
30 ]
31 
32 var request = URLRequest(url: components.url!)
33 request.setValue("Bearer (token)", forHTTPHeaderField: "Authorization")
34 
35 let (data, _) = try await URLSession.shared.data(for: request)
36 let decoded = try JSONDecoder().decode(AscBuildsResponse.self, from: data)
37 return decoded.data
38 }
39}

Beta Groups Yönetimi

swift
1extension AscClient {
2 // Beta group'a tester ekle
3 func addTesterToBetaGroup(
4 groupId: String,
5 email: String,
6 firstName: String,
7 lastName: String
8 ) async throws {
9 let token = try await tokenCache.validToken(generator: generator)
10 
11 // Önce beta tester oluştur
12 let testerBody: [String: Any] = [
13 "data": [
14 "type": "betaTesters",
15 "attributes": [
16 "email": email,
17 "firstName": firstName,
18 "lastName": lastName
19 ],
20 "relationships": [
21 "betaGroups": [
22 "data": [["type": "betaGroups", "id": groupId]]
23 ]
24 ]
25 ]
26 ]
27 
28 var request = URLRequest(url: baseURL.appendingPathComponent("betaTesters"))
29 request.httpMethod = "POST"
30 request.setValue("Bearer (token)", forHTTPHeaderField: "Authorization")
31 request.setValue("application/json", forHTTPHeaderField: "Content-Type")
32 request.httpBody = try JSONSerialization.data(withJSONObject: testerBody)
33 
34 let (_, response) = try await URLSession.shared.data(for: request)
35 guard let http = response as? HTTPURLResponse, http.statusCode == 201 else {
36 throw AscError.testerCreationFailed
37 }
38 }
39 
40 // Build'i beta group'a assign et
41 func addBuildToBetaGroup(buildId: String, groupId: String) async throws {
42 let token = try await tokenCache.validToken(generator: generator)
43 let body: [String: Any] = [
44 "data": [["type": "builds", "id": buildId]]
45 ]
46 let url = baseURL
47 .appendingPathComponent("betaGroups")
48 .appendingPathComponent(groupId)
49 .appendingPathComponent("relationships")
50 .appendingPathComponent("builds")
51 
52 var request = URLRequest(url: url)
53 request.httpMethod = "POST"
54 request.setValue("Bearer (token)", forHTTPHeaderField: "Authorization")
55 request.setValue("application/json", forHTTPHeaderField: "Content-Type")
56 request.httpBody = try JSONSerialization.data(withJSONObject: body)
57 
58 let (_, response) = try await URLSession.shared.data(for: request)
59 guard let http = response as? HTTPURLResponse, http.statusCode == 204 else {
60 throw AscError.buildAssignmentFailed
61 }
62 }
63}

Upload API: TUS Protocol ile Build Yükleme

Build yükleme — .ipa dosyasını App Store Connect'e göndermek — TUS (resumable upload) protokolü kullanıyor. Apple'ın eski altool ve iTMSTransporter araçları hâlâ çalışıyor ama Upload API'yi direkt kullanmak daha temiz.

iTMSTransporter vs altool vs Upload API

Araç
Durum 2026
Avantaj
Dezavantaj
`altool`
Deprecated
Tanıdık
2025'te kaldırıldı
`iTMSTransporter`
Aktif
Güvenilir
Binary, platform-specific
Upload API (TUS)
Aktif
Programatik, resumable
Karmaşık implementation
`xcodebuild -exportArchive` + notary
Aktif
Native
CI/CD entegrasyonu gerekir

TUS Upload Flow

swift
1struct TusUploader {
2 let ascClient: AscClient
3 
4 // 1. Upload URL al
5 func reserveUploadUrl(appId: String, fileName: String, fileSize: Int) async throws -> String {
6 let token = try await ascClient.tokenCache.validToken(generator: ascClient.generator)
7 let body: [String: Any] = [
8 "data": [
9 "type": "appStoreVersionSubmissions",
10 "attributes": [
11 "fileName": fileName,
12 "fileSize": fileSize
13 ]
14 ]
15 ]
16 
17 var request = URLRequest(
18 url: ascClient.baseURL.appendingPathComponent("bundleIds")
19 )
20 request.httpMethod = "POST"
21 request.setValue("Bearer (token)", forHTTPHeaderField: "Authorization")
22 request.setValue("application/json", forHTTPHeaderField: "Content-Type")
23 request.httpBody = try JSONSerialization.data(withJSONObject: body)
24 
25 let (data, _) = try await URLSession.shared.data(for: request)
26 let response = try JSONDecoder().decode(UploadReservationResponse.self, from: data)
27 return response.data.attributes.uploadUrl
28 }
29 
30 // 2. TUS chunk upload
31 func uploadChunk(
32 uploadUrl: URL,
33 data: Data,
34 offset: Int,
35 totalSize: Int
36 ) async throws {
37 var request = URLRequest(url: uploadUrl)
38 request.httpMethod = "PATCH"
39 request.setValue("application/offset+octet-stream", forHTTPHeaderField: "Content-Type")
40 request.setValue("1.0.0", forHTTPHeaderField: "Tus-Resumable")
41 request.setValue("(offset)", forHTTPHeaderField: "Upload-Offset")
42 request.httpBody = data
43 
44 let (_, response) = try await URLSession.shared.data(for: request)
45 guard let http = response as? HTTPURLResponse, http.statusCode == 204 else {
46 throw AscError.uploadFailed
47 }
48 }
49}

Pratikte: xcodebuild + xcrun altcool Kombinasyonu

Gerçek production pipeline'ında çoğu ekip hâlâ şu kombinasyonu kullanıyor:

bash
1# Archive
2xcodebuild archive -scheme MyApp -destination "generic/platform=iOS" -archivePath /tmp/MyApp.xcarchive CODE_SIGN_IDENTITY="Apple Distribution" PROVISIONING_PROFILE_SPECIFIER="MyApp Distribution"
3 
4# Export IPA
5xcodebuild -exportArchive -archivePath /tmp/MyApp.xcarchive -exportPath /tmp/MyApp-export -exportOptionsPlist ExportOptions.plist
6 
7# Upload via xcrun notarytool (macOS 12+ replacement for altool)
8xcrun notarytool submit /tmp/MyApp-export/MyApp.ipa --key /path/to/AuthKey.p8 --key-id YOUR_KEY_ID --issuer YOUR_ISSUER_ID --wait

TestFlight Otomasyonu

Build'i TestFlight'a Hazırlama

swift
1extension AscClient {
2 // Build'in TestFlight durumunu güncelle
3 func updateBuildTestFlightStatus(
4 buildId: String,
5 testFlightDetails: TestFlightDetails
6 ) async throws {
7 let token = try await tokenCache.validToken(generator: generator)
8 let body: [String: Any] = [
9 "data": [
10 "type": "betaBuildLocalizations",
11 "attributes": [
12 "whatsNew": testFlightDetails.whatsNew,
13 "locale": testFlightDetails.locale
14 ],
15 "relationships": [
16 "build": [
17 "data": ["type": "builds", "id": buildId]
18 ]
19 ]
20 ]
21 ]
22 
23 var request = URLRequest(
24 url: baseURL.appendingPathComponent("betaBuildLocalizations")
25 )
26 request.httpMethod = "POST"
27 request.setValue("Bearer (token)", forHTTPHeaderField: "Authorization")
28 request.setValue("application/json", forHTTPHeaderField: "Content-Type")
29 request.httpBody = try JSONSerialization.data(withJSONObject: body)
30 
31 let (_, response) = try await URLSession.shared.data(for: request)
32 guard let http = response as? HTTPURLResponse,
33 http.statusCode == 201 else {
34 throw AscError.testFlightUpdateFailed
35 }
36 }
37 
38 // External test grubuna build gönder (Apple review gerektirmez artık bazı durumlarda)
39 func submitBuildForExternalTesting(buildId: String) async throws {
40 let token = try await tokenCache.validToken(generator: generator)
41 let body: [String: Any] = [
42 "data": [
43 "type": "betaAppReviewSubmissions",
44 "relationships": [
45 "build": [
46 "data": ["type": "builds", "id": buildId]
47 ]
48 ]
49 ]
50 ]
51 
52 var request = URLRequest(
53 url: baseURL.appendingPathComponent("betaAppReviewSubmissions")
54 )
55 request.httpMethod = "POST"
56 request.setValue("Bearer (token)", forHTTPHeaderField: "Authorization")
57 request.setValue("application/json", forHTTPHeaderField: "Content-Type")
58 request.httpBody = try JSONSerialization.data(withJSONObject: body)
59 
60 let (_, response) = try await URLSession.shared.data(for: request)
61 guard let http = response as? HTTPURLResponse,
62 http.statusCode == 201 else {
63 throw AscError.reviewSubmissionFailed
64 }
65 }
66}
67 
68struct TestFlightDetails {
69 let whatsNew: String
70 let locale: String // örn. "tr"
71}

Otomatik Build Dağıtım Scripti

swift
1// CI/CD pipeline'ında çalışacak script
2@main
3struct TestFlightDeployScript {
4 static func main() async {
5 let generator = AscJwtGenerator(
6 issuerId: ProcessInfo.processInfo.environment["ASC_ISSUER_ID"]!,
7 keyId: ProcessInfo.processInfo.environment["ASC_KEY_ID"]!,
8 privateKeyPem: ProcessInfo.processInfo.environment["ASC_PRIVATE_KEY"]!
9 )
10 let tokenCache = TokenCache()
11 let client = AscClient(tokenCache: tokenCache, generator: generator)
12 
13 do {
14 // 1. App bul
15 let apps = try await client.fetchApps()
16 guard let app = apps.first(where: { $0.attributes.bundleId == "com.example.myapp" }) else {
17 print("App bulunamadi")
18 exit(1)
19 }
20 
21 // 2. Son build'i al
22 let builds = try await client.fetchBuilds(appId: app.id, limit: 1)
23 guard let latestBuild = builds.first else {
24 print("Build bulunamadi")
25 exit(1)
26 }
27 
28 // 3. TestFlight notunu ekle
29 let buildNumber = ProcessInfo.processInfo.environment["BUILD_NUMBER"] ?? "bilinmiyor"
30 try await client.updateBuildTestFlightStatus(
31 buildId: latestBuild.id,
32 testFlightDetails: TestFlightDetails(
33 whatsNew: "Build #(buildNumber) — Otomatik CI/CD deploy. Changelog: (ProcessInfo.processInfo.environment["CHANGELOG"] ?? "Yok")",
34 locale: "tr"
35 )
36 )
37 
38 // 4. Internal test grubuna assign et
39 let internalGroupId = ProcessInfo.processInfo.environment["TF_INTERNAL_GROUP_ID"]!
40 try await client.addBuildToBetaGroup(buildId: latestBuild.id, groupId: internalGroupId)
41 
42 print("TestFlight deploy tamamlandi: Build (latestBuild.id)")
43 } catch {
44 print("Hata: (error)")
45 exit(1)
46 }
47 }
48}

Metadata ve Submission Automation

App Store Version Metadata Update

swift
1extension AscClient {
2 func updateAppStoreVersionLocalization(
3 localizationId: String,
4 description: String,
5 keywords: String,
6 promotionalText: String,
7 whatsNew: String
8 ) async throws {
9 let token = try await tokenCache.validToken(generator: generator)
10 let body: [String: Any] = [
11 "data": [
12 "type": "appStoreVersionLocalizations",
13 "id": localizationId,
14 "attributes": [
15 "description": description,
16 "keywords": keywords,
17 "promotionalText": promotionalText,
18 "whatsNew": whatsNew
19 ]
20 ]
21 ]
22 
23 let url = baseURL
24 .appendingPathComponent("appStoreVersionLocalizations")
25 .appendingPathComponent(localizationId)
26 var request = URLRequest(url: url)
27 request.httpMethod = "PATCH"
28 request.setValue("Bearer (token)", forHTTPHeaderField: "Authorization")
29 request.setValue("application/json", forHTTPHeaderField: "Content-Type")
30 request.httpBody = try JSONSerialization.data(withJSONObject: body)
31 
32 let (_, response) = try await URLSession.shared.data(for: request)
33 guard let http = response as? HTTPURLResponse, http.statusCode == 200 else {
34 throw AscError.metadataUpdateFailed
35 }
36 }
37 
38 // App Review'a submit et
39 func submitForReview(appStoreVersionId: String) async throws {
40 let token = try await tokenCache.validToken(generator: generator)
41 let body: [String: Any] = [
42 "data": [
43 "type": "appStoreVersionSubmissions",
44 "relationships": [
45 "appStoreVersion": [
46 "data": ["type": "appStoreVersions", "id": appStoreVersionId]
47 ]
48 ]
49 ]
50 ]
51 
52 var request = URLRequest(
53 url: baseURL.appendingPathComponent("appStoreVersionSubmissions")
54 )
55 request.httpMethod = "POST"
56 request.setValue("Bearer (token)", forHTTPHeaderField: "Authorization")
57 request.setValue("application/json", forHTTPHeaderField: "Content-Type")
58 request.httpBody = try JSONSerialization.data(withJSONObject: body)
59 
60 let (_, response) = try await URLSession.shared.data(for: request)
61 guard let http = response as? HTTPURLResponse, http.statusCode == 201 else {
62 throw AscError.reviewSubmissionFailed
63 }
64 }
65}

App Store Connect GraphQL v2 (2025 Preview)

Apple 2025'te ASC GraphQL v2'yi preview olarak açtı. REST'e göre avantajı: tek request'te ihtiyaç duyduğun tüm alanları çekebiliyorsun.

graphql
1# Tek query ile app + versions + builds
2query AppOverview($bundleId: String!) {
3 app(bundleIdentifier: $bundleId) {
4 id
5 name
6 currentVersionReleaseDate
7 latestAppStoreVersion {
8 versionString
9 appStoreState
10 localizations(languages: ["tr", "en-US"]) {
11 locale
12 description
13 keywords
14 whatsNew
15 }
16 }
17 builds(first: 5) {
18 edges {
19 node {
20 version
21 processingState
22 uploadedDate
23 betaGroups {
24 name
25 publicLink
26 }
27 }
28 }
29 }
30 }
31}

GraphQL endpoint henüz production'a çıkmadı (Q2 2026 bekleniyor), ama REST API'nin kısıtlamalarından kaçınmak için takip etmeye değer.


In-App Purchase ve Subscription Yönetimi

IAP Oluşturma

swift
1extension AscClient {
2 func createInAppPurchase(
3 appId: String,
4 productId: String,
5 name: String,
6 purchaseType: IapType,
7 price: IapPrice
8 ) async throws -> String {
9 let token = try await tokenCache.validToken(generator: generator)
10 let body: [String: Any] = [
11 "data": [
12 "type": "inAppPurchases",
13 "attributes": [
14 "referenceName": name,
15 "productId": productId,
16 "inAppPurchaseType": purchaseType.rawValue
17 ],
18 "relationships": [
19 "app": [
20 "data": ["type": "apps", "id": appId]
21 ]
22 ]
23 ]
24 ]
25 
26 var request = URLRequest(
27 url: baseURL.appendingPathComponent("inAppPurchasesV2")
28 )
29 request.httpMethod = "POST"
30 request.setValue("Bearer (token)", forHTTPHeaderField: "Authorization")
31 request.setValue("application/json", forHTTPHeaderField: "Content-Type")
32 request.httpBody = try JSONSerialization.data(withJSONObject: body)
33 
34 let (data, response) = try await URLSession.shared.data(for: request)
35 guard let http = response as? HTTPURLResponse, http.statusCode == 201 else {
36 throw AscError.iapCreationFailed
37 }
38 let decoded = try JSONDecoder().decode(IapCreateResponse.self, from: data)
39 return decoded.data.id
40 }
41 
42 // Subscription offer code oluştur
43 func createOfferCode(
44 subscriptionId: String,
45 name: String,
46 customerEligibilityPaidSubscriptions: Bool,
47 duration: String // örn. "ONE_MONTH"
48 ) async throws -> String {
49 let token = try await tokenCache.validToken(generator: generator)
50 let body: [String: Any] = [
51 "data": [
52 "type": "subscriptionOfferCodes",
53 "attributes": [
54 "name": name,
55 "customerEligibilityPaidSubscriptions": customerEligibilityPaidSubscriptions,
56 "customerEligibilityIntroductoryOfferIneligible": false,
57 "customerEligibilitySubscriptionCodeRedemptionIneligible": false,
58 "offerEligibility": "NEW_SUBSCRIBERS",
59 "priority": "NORMAL",
60 "totalNumberOfCodes": 100
61 ],
62 "relationships": [
63 "subscription": [
64 "data": ["type": "subscriptions", "id": subscriptionId]
65 ],
66 "oneTimeUseCodes": [
67 "data": [
68 [
69 "type": "subscriptionOfferCodeOneTimeUseCodes",
70 "id": "$new"
71 ]
72 ]
73 ]
74 ]
75 ]
76 ]
77 
78 var request = URLRequest(
79 url: baseURL.appendingPathComponent("subscriptionOfferCodes")
80 )
81 request.httpMethod = "POST"
82 request.setValue("Bearer (token)", forHTTPHeaderField: "Authorization")
83 request.setValue("application/json", forHTTPHeaderField: "Content-Type")
84 request.httpBody = try JSONSerialization.data(withJSONObject: body)
85 
86 let (data, _) = try await URLSession.shared.data(for: request)
87 let decoded = try JSONDecoder().decode(OfferCodeResponse.self, from: data)
88 return decoded.data.id
89 }
90}
91 
92enum IapType: String {
93 case consumable = "CONSUMABLE"
94 case nonConsumable = "NON_CONSUMABLE"
95 case nonRenewing = "NON_RENEWING_SUBSCRIPTION"
96 case autoRenewing = "AUTO_RENEWABLE_SUBSCRIPTION"
97}
98 
99struct IapPrice {
100 let territory: String // örn. "TUR"
101 let customerPrice: String // örn. "299.99"
102}

Analytics ve Sales Reports

Sales/Trends Report İndirme

ASC API üzerinden sales raporlarını programatik olarak çekebilirsin. Raporlar gzip sıkıştırılmış TSV formatında gelir.

swift
1struct AscReportsClient {
2 let ascClient: AscClient
3 
4 enum ReportType: String {
5 case sales = "SALES"
6 case newsstand = "NEWSSTAND"
7 case subscriptions = "SUBSCRIPTIONS"
8 case subscriptionEvents = "SUBSCRIPTION_EVENTS"
9 case subscribers = "SUBSCRIBERS"
10 case preOrders = "PRE_ORDERS"
11 }
12 
13 func downloadSalesReport(
14 vendorId: String,
15 reportType: ReportType,
16 date: String, // "YYYY-MM-DD"
17 frequency: String = "DAILY" // DAILY, WEEKLY, MONTHLY, YEARLY
18 ) async throws -> [[String: String]] {
19 let token = try await ascClient.tokenCache.validToken(generator: ascClient.generator)
20 
21 var components = URLComponents(
22 url: ascClient.baseURL.appendingPathComponent("salesReports"),
23 resolvingAgainstBaseURL: false
24 )!
25 components.queryItems = [
26 URLQueryItem(name: "filter[reportType]", value: reportType.rawValue),
27 URLQueryItem(name: "filter[reportSubType]", value: "SUMMARY"),
28 URLQueryItem(name: "filter[frequency]", value: frequency),
29 URLQueryItem(name: "filter[reportDate]", value: date),
30 URLQueryItem(name: "filter[vendorNumber]", value: vendorId)
31 ]
32 
33 var request = URLRequest(url: components.url!)
34 request.setValue("Bearer (token)", forHTTPHeaderField: "Authorization")
35 request.setValue("application/a-gzip", forHTTPHeaderField: "Accept")
36 
37 let (data, response) = try await URLSession.shared.data(for: request)
38 guard let http = response as? HTTPURLResponse, http.statusCode == 200 else {
39 throw AscError.reportDownloadFailed
40 }
41 
42 // Decompress gzip
43 let decompressed = try (data as NSData).decompressed(using: .zlib) as Data
44 let tsv = String(data: decompressed, encoding: .utf8) ?? ""
45 
46 // Parse TSV
47 let lines = tsv.components(separatedBy: "
48").filter { !$0.isEmpty }
49 guard let headers = lines.first?.components(separatedBy: " ") else { return [] }
50 
51 return lines.dropFirst().map { line in
52 let values = line.components(separatedBy: " ")
53 return Dictionary(uniqueKeysWithValues: zip(headers, values))
54 }
55 }
56 
57 // App-specific metrics (installs, sessions, crashes, retention)
58 func downloadAnalyticsReport(
59 appId: String,
60 metricType: String = "INSTALLS", // INSTALLS, SESSIONS, ACTIVE_DEVICES, CRASHES, PAYING_USERS
61 startDate: String,
62 endDate: String,
63 granularity: String = "DAY"
64 ) async throws -> Data {
65 let token = try await ascClient.tokenCache.validToken(generator: ascClient.generator)
66 
67 var components = URLComponents(
68 url: ascClient.baseURL.appendingPathComponent("analyticsReportRequests"),
69 resolvingAgainstBaseURL: false
70 )!
71 
72 // Önce report request oluştur
73 let requestBody: [String: Any] = [
74 "data": [
75 "type": "analyticsReportRequests",
76 "attributes": [
77 "accessType": "ONGOING"
78 ],
79 "relationships": [
80 "app": [
81 "data": ["type": "apps", "id": appId]
82 ]
83 ]
84 ]
85 ]
86 
87 var request = URLRequest(url: components.url!)
88 request.httpMethod = "POST"
89 request.setValue("Bearer (token)", forHTTPHeaderField: "Authorization")
90 request.setValue("application/json", forHTTPHeaderField: "Content-Type")
91 request.httpBody = try JSONSerialization.data(withJSONObject: requestBody)
92 
93 let (data, _) = try await URLSession.shared.data(for: request)
94 return data
95 }
96}

Fastlane Spaceship Karşılaştırması

Hangi Yaklaşım Ne Zaman?

Senaryo
Öneri
Neden
Yeni CI/CD pipeline
ASC API direkt
Dependency yok, Apple destekli
Mevcut Fastlane pipeline
Fastlane devam
Migration riski
Screenshot automation
Fastlane snapshot
ASC API screenshot yüklemeyi desteklemiyor
Metadata update
ASC API
Fastlane deliver artık ASC API kullanıyor
Signing & provisioning
Fastlane match
ASC API signing yönetimi sınırlı
Build upload
xcrun notarytool
altool deprecated
TestFlight tester yönetimi
ASC API
Fastlane pilot artık ASC API üzerinde

Fastlane Lane Migration

ruby
1# Eski Fastlane lane
2lane :beta do
3 build_app(scheme: "MyApp")
4 upload_to_testflight(
5 api_key_path: "fastlane/api_key.json",
6 skip_waiting_for_build_processing: false,
7 distribute_external: true,
8 groups: ["Beta Testers"],
9 changelog: ENV["CHANGELOG"]
10 )
11end
12 
13# Yeni ASC API direkt yaklaşım (Swift script)
14lane :beta_native do
15 build_app(scheme: "MyApp")
16 # xcrun ile upload
17 sh("xcrun notarytool submit MyApp.ipa --key fastlane/AuthKey.p8 --key-id #{ENV['ASC_KEY_ID']} --issuer #{ENV['ASC_ISSUER_ID']} --wait")
18 # ASC API ile TestFlight config
19 sh("swift run TestFlightDeploy --build-number #{ENV['BUILD_NUMBER']} --changelog '#{ENV['CHANGELOG']}'")
20end

CI/CD Integration Patterns

GitHub Actions ile Tam Pipeline

yaml
1# .github/workflows/testflight.yml
2name: TestFlight Deploy
3 
4on:
5 push:
6 branches: [main]
7 workflow_dispatch:
8 inputs:
9 changelog:
10 description: "Release notes"
11 required: true
12 
13jobs:
14 deploy:
15 runs-on: macos-14
16 steps:
17 - uses: actions/checkout@v4
18 
19 - name: Select Xcode 16
20 run: sudo xcode-select -s /Applications/Xcode_16.app
21 
22 - name: Import signing certificate
23 env:
24 CERTIFICATE_BASE64: ${{ secrets.DIST_CERTIFICATE_BASE64 }}
25 CERTIFICATE_PASSWORD: ${{ secrets.DIST_CERTIFICATE_PASSWORD }}
26 KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
27 run: |
28 KEYCHAIN_PATH=$RUNNER_TEMP/build.keychain
29 security create-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
30 security set-keychain-settings -lut 21600 $KEYCHAIN_PATH
31 security unlock-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
32 echo "$CERTIFICATE_BASE64" | base64 --decode > /tmp/cert.p12
33 security import /tmp/cert.p12 -k $KEYCHAIN_PATH -P "$CERTIFICATE_PASSWORD" -T /usr/bin/codesign
34 
35 - name: Download provisioning profile
36 env:
37 PP_BASE64: ${{ secrets.PROVISIONING_PROFILE_BASE64 }}
38 run: |
39 mkdir -p ~/Library/MobileDevice/Provisioning Profiles
40 echo "$PP_BASE64" | base64 --decode > ~/Library/MobileDevice/Provisioning Profiles/MyApp.mobileprovision
41 
42 - name: Archive
43 run: |
44 xcodebuild archive -scheme MyApp -destination "generic/platform=iOS" -archivePath /tmp/MyApp.xcarchive CODE_SIGN_IDENTITY="Apple Distribution" PROVISIONING_PROFILE_SPECIFIER="MyApp Distribution"
45 
46 - name: Export IPA
47 run: |
48 xcodebuild -exportArchive -archivePath /tmp/MyApp.xcarchive -exportPath /tmp/MyApp-export -exportOptionsPlist ExportOptions.plist
49 
50 - name: Upload to TestFlight
51 env:
52 ASC_KEY_ID: ${{ secrets.ASC_KEY_ID }}
53 ASC_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }}
54 ASC_PRIVATE_KEY: ${{ secrets.ASC_PRIVATE_KEY }}
55 run: |
56 echo "$ASC_PRIVATE_KEY" > /tmp/AuthKey.p8
57 xcrun notarytool submit /tmp/MyApp-export/MyApp.ipa --key /tmp/AuthKey.p8 --key-id $ASC_KEY_ID --issuer $ASC_ISSUER_ID --wait
58 
59 - name: Notify Slack
60 if: success()
61 uses: slackapi/slack-github-action@v1
62 with:
63 payload: |
64 {
65 "text": "TestFlight build hazir! Build #${{ github.run_number }}",
66 "attachments": [{"color": "good", "text": "${{ github.event.inputs.changelog }}"}]
67 }
68 env:
69 SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}

Rate Limits ve Error Handling

Rate Limit Politikası

ASC API'nin rate limit'i sıkı değil ama dikkat etmek gerekiyor:

  • Per endpoint:: Dakikada ~60-100 istek (Apple dokümantasyonunda kesin değer yok)
  • Burst limit:: 10 saniyede 20 istek
  • Daily limit:: Pratik olarak sınırsız, ama 429 alabilirsin

Robust Error Handling

swift
1enum AscError: LocalizedError {
2 case httpError(Int)
3 case rateLimited(retryAfter: Int)
4 case unauthorized
5 case invalidResponse
6 case testerCreationFailed
7 case buildAssignmentFailed
8 case uploadFailed
9 case testFlightUpdateFailed
10 case metadataUpdateFailed
11 case reviewSubmissionFailed
12 case iapCreationFailed
13 case reportDownloadFailed
14 
15 var errorDescription: String? {
16 switch self {
17 case .httpError(let code): return "HTTP (code)"
18 case .rateLimited(let retry): return "Rate limit. (retry) saniye bekle."
19 case .unauthorized: return "JWT token gecersiz veya suresi dolmus."
20 default: return String(describing: self)
21 }
22 }
23}
24 
25// Exponential backoff ile retry
26func withRetry<T>(
27 maxAttempts: Int = 3,
28 baseDelay: TimeInterval = 1.0,
29 operation: () async throws -> T
30) async throws -> T {
31 var lastError: Error?
32 for attempt in 0..<maxAttempts {
33 do {
34 return try await operation()
35 } catch AscError.rateLimited(let retryAfter) {
36 try await Task.sleep(nanoseconds: UInt64(retryAfter) * 1_000_000_000)
37 } catch {
38 lastError = error
39 if attempt < maxAttempts - 1 {
40 let delay = baseDelay * pow(2.0, Double(attempt))
41 try await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000))
42 }
43 }
44 }
45 throw lastError ?? AscError.invalidResponse
46}

ALTIN İPUCU

Bu yazının en değerli bilgisi

Bu ipucu, yazının en önemli çıkarımını içeriyor.

swift
1// Sandbox tester oluştur (IAP test için)
2extension AscClient {
3 func createSandboxTester(
4 email: String, // özel format: [email protected]
5 firstName: String,
6 lastName: String,
7 territory: String = "TUR"
8 ) async throws {
9 let token = try await tokenCache.validToken(generator: generator)
10 let body: [String: Any] = [
11 "data": [
12 "type": "sandboxTesters",
13 "attributes": [
14 "firstName": firstName,
15 "lastName": lastName,
16 "email": email,
17 "password": UUID().uuidString + "Aa1!",
18 "confirmPassword": UUID().uuidString + "Aa1!",
19 "secretQuestion": "Favorite app?",
20 "secretAnswer": "Mine",
21 "birthDate": "1990-01-01",
22 "territory": territory,
23 "interruptPurchases": false
24 ]
25 ]
26 ]
27 
28 var request = URLRequest(
29 url: baseURL.appendingPathComponent("sandboxTesters")
30 )
31 request.httpMethod = "POST"
32 request.setValue("Bearer (token)", forHTTPHeaderField: "Authorization")
33 request.setValue("application/json", forHTTPHeaderField: "Content-Type")
34 request.httpBody = try JSONSerialization.data(withJSONObject: body)
35 
36 let (_, response) = try await URLSession.shared.data(for: request)
37 guard let http = response as? HTTPURLResponse, http.statusCode == 201 else {
38 throw AscError.httpError((response as? HTTPURLResponse)?.statusCode ?? 0)
39 }
40 }
41}

Easter Egg

Gizli bir bilgi buldun!

Bu bölümde gizli bir bilgi var. Keşfetmek ister misin?

swift
1// AvdLee SDK kullanımı
2import AppStoreConnect
3 
4let config = try APIConfiguration(
5 issuerID: "your-issuer-id",
6 privateKeyID: "your-key-id",
7 privateKey: "your-private-key"
8)
9let provider = APIProvider(configuration: config)
10 
11let request = APIEndpoint.v1.apps.get(parameters: .init())
12let apps = try await provider.request(request)

Okuyucu Ödülü

Sıfırdan yazmak yerine community-maintained Swift package kullanabilirsin: - **[AvdLee/appstoreconnect-swift-sdk](https://github.com/AvdLee/appstoreconnect-swift-sdk):** En popüler, aktif maintained, type-safe models - **Apple'ın openapi.json:** `api.appstoreconnect.apple.com/v1` OpenAPI spec mevcut — kendi client'ını generate edebilirsin

Sonuç

App Store Connect API, iOS CI/CD pipeline'ının merkezine oturmuş durumda. JWT token yönetimi biraz zahmetli ama bir kez doğru kurulunca: build upload, TestFlight dağıtım, metadata güncelleme, IAP yönetimi ve sales analytics tamamen otomatik. Fastlane spaceship'in sihirli görünen "App Store Connect ile konuş" kısmı artık direkt API üzerinden yapılabilir — aradaki abstraction kaldırılınca neler döndüğü daha net görünüyor ve debug edilmesi çok daha kolay.

Daha fazla okuma için: iOS CI/CD Pipeline Rehberi, StoreKit 2 Production Guide, iOS 19 Beta Yenilikler, Senior iOS Developer 2026.


Kaynaklar:

Etiketler

#App Store Connect#API#Automation#TestFlight#Fastlane#CI/CD#2026
Muhittin Çamdalı

Muhittin Çamdalı

Senior iOS Developer

12+ yıllık deneyime sahip iOS Developer. Swift, SwiftUI ve modern iOS mimarileri konusunda uzman. Apple platformlarında performanslı ve kullanıcı dostu uygulamalar geliştiriyorum.

iOS Geliştirme Haberleri

Haftalık Swift tips, SwiftUI tricks ve iOS best practices. Spam yok, sadece değerli içerik.

Gizliliğinize saygı duyuyoruz. İstediğiniz zaman abonelikten çıkabilirsiniz.

Paylaş

Bunu da begenebilirsiniz