App Store の提出作業はどこまでAPIでやれるか——止まるのは1箇所、面倒なのはスクリーンショット
著作権表記も年齢レーティングも価格も、画面で入力する前提だと思っていたものはだいたいAPIから設定できる。ブラウザが要るのはプライバシー申告だけ。ただしスクリーンショットの差し替えだけは、更新1発では終わらない4段構えになる。
App Store Connect API(App Store の管理画面を外から操作するための Apple 公式 API)でリリースを自動化していくと、「ここは画面でやるしかないだろう」と思っていた項目の大半が API から触れる。逆に、簡単そうに見えて手順が多い項目もひとつある。境目を実測した結果を置いておく。
API で埋まる範囲
新規アプリの初回提出を準備していた。審査に出すまでに埋める項目は多い。著作権表記、コンテンツ配信権の申告(その動画や音楽を配信する権利を持っているかの申告)、審査連絡先、年齢レーティング、価格、スクリーンショット。管理画面のフォームを順に埋めていく作業になると思っていた。
実際には、この範囲はほぼ API から設定できる。手で埋める前提を疑うと、提出準備そのものがスクリプトになる。
| 項目 | エンドポイント |
|---|---|
| 著作権表記 | appStoreVersions の copyright |
| コンテンツ配信権の申告 | apps の contentRightsDeclaration |
| 審査連絡先・審査メモ | appStoreReviewDetails |
| 年齢レーティング | ageRatingDeclarations |
| 価格 | appPriceSchedules |
| 公開地域 | appAvailabilities |
| スクリーンショット | appScreenshots(後述) |
ブラウザが要るのはプライバシー申告(App Privacy。どんなデータを集めてどう使うかの申告)だけだった。公開されている API リファレンスに該当する口が見当たらず、それらしいパスを推測で叩くと 404 が返る。自動化しても、ここだけは人間の手を残すことになる。逆に言えば残すのはここだけでいい。
ついでに踏んだ落とし穴を3つ
年齢レーティングの新しい項目は、選択肢ではなく true / false で送る。 2025年に設問が刷新されて項目が増えた。増えたほうの項目(広告・メッセージ/チャット・ペアレンタルコントロール・ソーシャルメディア・ユーザー生成コンテンツ・健康関連・年齢確認)は真偽値で、従来からある暴力表現や性的表現は NONE などの選択肢のまま残っている。同じリクエストの中で2つの型が混ざるので、型エラーが返ったらどちらの系統か見て合わせる。
「無料」の価格は "0.0" という文字列で入っている。 Apple が用意している価格の候補一覧(価格ポイント)から無料のものを探すとき、"0" や "0.00" で照合すると取りこぼす。
// ❌ ヒットしない
const free = points.find(p => p.attributes.customerPrice === '0')
// ✅
const free = points.find(p => Number(p.attributes.customerPrice) === 0)
説明文に ♭ と ♯ は入らない。 これを含めると弾かれる。
INVALID_CHARACTERS
音楽系のアプリだと普通に書きたくなる記号なので、「短7度」「増4度」のような言葉に置き換える。
スクリーンショットだけは、更新1発では終わらない
事象
タイトルや説明文の更新は、対象を指定して更新リクエストを1回投げれば終わる。同じ感覚でスクリーンショットを差し替えると、リクエストは通るのに反映されない。
アップロードは受理されたように見える。それなのに状態を確認するとこうなる。
{ "assetDeliveryState": { "state": "AWAITING_UPLOAD" } }
原因
画像は他のメタデータと扱いが違う。先に枠を予約し、そこへ実体を送り込み、送り終えたことを自分で宣言する。この宣言を送らない限り、Apple 側は「まだ届いていない」と判断し続ける。
解決策
4段階に分けて叩く。
① 予約する。 ファイル名とサイズを渡して枠を作る。
const res = await post('/v1/appScreenshots', {
data: {
type: 'appScreenshots',
attributes: { fileName: '01-record.png', fileSize: buf.length },
relationships: { appScreenshotSet: { data: { type: 'appScreenshotSets', id: setId } } },
},
})
const { id, attributes } = res.data
const ops = attributes.uploadOperations // 分割アップロードの指示が入っている
レスポンスの uploadOperations に、送り先の URL・ファイルのどこからどこまでを送るか・付けるヘッダが入っている。
② 指示どおりに実体を送る。 加工せず、そのままのバイト列を送る。ヘッダも指示に従う。
for (const op of ops) {
await fetch(op.url, {
method: op.method, // PUT
headers: Object.fromEntries(op.requestHeaders.map(h => [h.name, h.value])),
body: buf.subarray(op.offset, op.offset + op.length),
})
}
③ 送り終えたことを宣言する。 ここでファイルの MD5 ハッシュ(内容から計算する短い指紋。改ざんや破損の検出に使う)を一緒に渡す。
const md5 = crypto.createHash('md5').update(buf).digest('hex')
await patch(`/v1/appScreenshots/${id}`, {
data: { type: 'appScreenshots', id, attributes: { uploaded: true, sourceFileChecksum: md5 } },
})
この③を飛ばすのが、いちばんよくある詰まり方だった。メタデータと同じつもりで①②だけやると、無言で失敗する。アップロード自体は受理されているので、ログにも異常が出ない。
④ 並び順を決める。 順番はアップロードの順番では決まらない。並べたい順の配列を別に送る。
await patch(`/v1/appScreenshotSets/${setId}/relationships/appScreenshots`, {
data: ids.map(id => ({ type: 'appScreenshots', id })),
})
確認は、成功したと思い込まずに状態を叩く。4枚とも COMPLETE でエラーが空なら、差し替えは成立している。
// { "assetDeliveryState": { "state": "COMPLETE", "errors": [] } }
触る前にバージョンの状態を見る
もうひとつ、事故になりやすいのがここ。編集できるのは、まだ提出していないバージョンだけだ。審査中や公開中のバージョンのスクリーンショットセットを消しにいくと、消してはいけないものを消す。
スクリプトの先頭でガードを入れておく。
const EDITABLE = ['PREPARE_FOR_SUBMISSION', 'DEVELOPER_REJECTED', 'REJECTED', 'METADATA_REJECTED']
if (!EDITABLE.includes(version.attributes.appStoreState)) {
throw new Error(`編集できない状態: ${version.attributes.appStoreState}`)
}
差し替えは元に戻しにくい操作なので、実行前に何をどう並べ替えるかだけ出力する空実行オプションも付けておくと安心して回せる。
まとめ
- 提出メタは、著作権・コンテンツ配信権・審査連絡先・年齢・価格・公開地域まで API で設定できる
- ブラウザが要るのはプライバシー申告だけ
- スクリーンショットは「予約 → 実体を送る → 送り終えたと宣言 → 並び順」の4段
- 宣言(
uploadedと MD5)を忘れると無言で失敗する - 触る前にバージョンの状態でガードする
画面を開くのは、初回のプライバシー申告と、提出ボタンを押す瞬間だけになった。