This article is co-authored with generative AI. While I have cross-checked facts against official documentation where possible, errors may remain. Please verify primary sources before making important decisions.
In the earlier post Building a macOS App That Turns a Folder of Page Images Into a Single TEI/XML, I built TEI Scanner, a macOS app that OCRs page images and emits TEI (Text Encoding Initiative) / XML, and took it as far as Developer ID signing + notarization with .dmg distribution. This post continues from there: it records submitting the same app to the Mac App Store and getting it through review.
The general App Store Connect API submission flow is written up for iOS in Submitting an iOS App for Review Using Only the App Store Connect API. To avoid repetition, this post focuses on what differs on macOS. For screenshot automation, see Fully Automating App Store Screenshot Generation with Python + UI Tests; for post-rejection resubmission, see Fixing and Resubmitting After an App Store Rejection via the App Store Connect API.
On timing: I submitted on May 10, 2026, and four days later received notice that review was complete and the app was eligible for distribution.
The app in question looks like this (see the earlier post for implementation details).
| Empty state | TEI/XML view |
|---|---|
![]() | ![]() |
Developer ID distribution vs. the Mac App Store
Even when shipping the same .app, the Developer ID path (the earlier post) and the Mac App Store path diverge at several steps.
| Item | Developer ID distribution | Mac App Store |
|---|---|---|
| App Sandbox | Optional | Required |
| Distribution artifact | .dmg (or .pkg) | .pkg uploaded to App Store Connect |
| Signing certificate | Developer ID Application | Apple Distribution + an installer-signing certificate |
| Notarization | Done explicitly via notarytool | Handled by Apple during review |
| Distribution channel | GitHub Releases or anywhere | App Store only |
| Review | None | Yes |
I reused the archiving script from the earlier post (xcodegen generate → xcodebuild archive) verbatim and branched only the export step onward for the App Store.
scripts/archive.sh --devid # Developer ID + notarized .dmg (earlier post)
scripts/archive.sh --appstore # Mac App Store .pkg + upload (this post)
Enabling App Sandbox
An app shipped through the Mac App Store must run in the App Sandbox. It is optional for Developer ID distribution, so the earlier build kept entitlements minimal; for the App Store submission I added the following.
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.app-sandbox</key>
<true/>
<key>com.apple.security.files.user-selected.read-write</key>
<true/>
</dict>
</plist>
With App Sandbox on, the app can no longer reach paths the user has not explicitly selected. TEI Scanner only ever opens a folder through the folder-picker dialog (NSOpenPanel) or via drag-and-drop onto the window, so the single com.apple.security.files.user-selected.read-write entitlement was enough. The bundled sample images live inside the app bundle, so they are readable without any extra entitlement. OCR runs on-device through Apple's Vision framework with no network access, so an entitlement like com.apple.security.network.client was not needed either.
The sandbox design stayed simple largely because the earlier iteration had already narrowed the entry points to "open a folder only via picker or drag-and-drop."
Exporting the build: .pkg and altool
The App Store export produces a .pkg, not a .dmg. The fork is in the method of the exportOptions passed to xcodebuild -exportArchive: set it to app-store-connect.
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>method</key>
<string>app-store-connect</string>
<key>teamID</key>
<string>(Team ID)</string>
<key>signingStyle</key>
<string>automatic</string>
<key>uploadSymbols</key>
<true/>
<key>manageAppVersionAndBuildNumber</key>
<false/>
<key>destination</key>
<string>export</string>
</dict>
</plist>
Setting signingStyle to automatic and passing -allowProvisioningUpdates to xcodebuild provisioned the signing certificates needed for App Store submission (one for the app, one for the installer) as cloud-managed certificates — the same behaviour as the auto-created Developer ID Application certificate in the earlier post. I set manageAppVersionAndBuildNumber to false so that version and build numbers are managed on the project.yml side.
The upload uses altool, not the notarytool used for notarization.
xcrun altool --upload-app \
--type macos \
--file "$PKG_FILE" \
--apiKey "$APP_STORE_API_KEY" \
--apiIssuer "$APP_STORE_API_ISSUER"
--type is the diff from iOS (ios); macOS uses macos. Because --apiKey / --apiIssuer accept the App Store Connect API key directly, the credentials placed in the earlier post's .env could be reused. Notarization for the App Store build is handled by Apple during review, so there is no need to invoke notarytool explicitly. After upload, the build typically takes anywhere from a few minutes to about 30 minutes to finish processing on the App Store Connect side and reach the VALID state.
Note that altool is now considered legacy; Apple steers developers toward the Transporter app, the upload capability of xcodebuild -exportArchive, and the App Store Connect API. This setup uses altool for the convenience of passing the earlier post's .env straight through, but those other paths are worth considering for a new setup.
Pushing metadata: filter[platform]=MAC_OS
From here on are Python scripts that call the App Store Connect API. The skeleton matches the iOS post: build a JWT and send requests to https://api.appstoreconnect.apple.com/v1/.
The clearest API diff from the iOS flow is adding a platform filter to the query that fetches the app version.
def get_editable_version_id(app_id):
_, data = request("GET", f"apps/{app_id}/appStoreVersions?filter[platform]=MAC_OS")
versions = data.get("data", [])
editable_states = {"PREPARE_FOR_SUBMISSION", "DEVELOPER_REJECTED",
"REJECTED", "METADATA_REJECTED"}
for v in versions:
if v["attributes"]["appStoreState"] in editable_states:
return v["id"]
raise SystemExit("No editable version found.")
Without filter[platform]=MAC_OS, an app record that also holds an iOS version could return the wrong one. Even with a single platform, being explicit was the safer choice.
The metadata script sets the following items idempotently (running it twice is safe):
appStoreVersionLocalizations(ja / en-US): description, keywords, promotional text, support URLappInfos: primary category (PRODUCTIVITY) and secondary category (REFERENCE)appStoreVersions: copyright stringapps: content-rights declaration (DOES_NOT_USE_THIRD_PARTY_CONTENT)appInfoLocalizations: privacy policy URLageRatingDeclarations: age rating (all categoriesNONE, all booleansfalse, resulting in 4+)
Categories are specified by passing the appCategories resource ID (strings like PRODUCTIVITY) directly into the relationship. The age rating has many fields, so I define a single dictionary and PATCH them all at once.
Screenshots: APP_DESKTOP / 2880×1800
Screenshots are handled differently on macOS and iOS. iOS splits screenshotDisplayType across multiple device sizes (APP_IPHONE_67, APP_IPAD_PRO_129, and so on), whereas macOS has a single type, APP_DESKTOP. The accepted resolutions are 1280×800 / 1440×900 / 2560×1600 / 2880×1800; this app uses the largest, 2880×1800.
For capture itself I reuse the self-screenshot mode built into the app in the earlier post, then pad the resulting PNGs to 2880×1800 with sips.
PAD_W=2880
PAD_H=1800
for f in docs/screenshots/0*.png; do
out="docs/asc-screenshots/$(basename "$f")"
cp "$f" "$out"
sips --padToHeightWidth "$PAD_H" "$PAD_W" --padColor FFFFFF "$out" --out "$out"
done
sips --padToHeightWidth centers the original and adds margin up to the given size. The app window is not close to square, so using the capture resolution as-is can fail to match an aspect ratio App Store Connect accepts; padding to a fixed size was the easier route. After padding, the images gain white margins on the sides and settle at 2880×1800, as shown below.
| After padding (OCR done) | After padding (TEI/XML view) |
|---|---|
![]() | ![]() |
The upload targets the appScreenshotSets whose displayType is APP_DESKTOP. The three-step "reserve → PUT the binary → commit with a checksum" sequence per image is the same as on iOS.
Attaching the build and declaring encryption compliance
Once the uploaded build reaches VALID, attach it to the editable version. The encryption-compliance declaration can also be done via the API.
def declare_encryption(build_id):
request("PATCH", f"builds/{build_id}", {
"data": {
"type": "builds",
"id": build_id,
"attributes": {"usesNonExemptEncryption": False},
}
})
TEI Scanner keeps OCR fully local and makes no HTTPS calls, so it uses no encryption subject to export regulations. Setting usesNonExemptEncryption to false avoids repeating the same declaration in the web UI on every upload.
Review details and submission
The information for the review team (appStoreReviewDetails) can be set from the API as well. TEI Scanner needs no login, so I marked the demo account as not required and wrote reproduction steps into notes.
REVIEW_DETAILS = {
"contactFirstName": "Satoru",
"contactLastName": "Nakamura",
"contactEmail": "(contact email)",
"contactPhone": "(contact phone)",
"demoAccountRequired": False,
"notes": (
"TEI Scanner runs entirely on-device. No login or network access "
"is required. To exercise the app: launch, click 'Try sample' on "
"the empty window to load two bundled English page images, click "
"'Run OCR', then 'Export TEI/XML…' and save the result anywhere."
),
}
The shortest path — "on the empty window, Try sample → Run OCR → Export TEI/XML" — is spelled out so the reviewer can exercise the whole app without a network or an account. The bundled sample prepared in the earlier post turned out to double as a clear route for the reviewer here.
Submitting for review is a three-step sequence: create a reviewSubmissions, attach the version with a reviewSubmissionItems, and PATCH submitted to true. Aside from specifying MAC_OS as the platform of the reviewSubmissions, the shape matches the iOS flow.
request("POST", "reviewSubmissions", {
"data": {
"type": "reviewSubmissions",
"attributes": {"platform": "MAC_OS"},
"relationships": {
"app": {"data": {"type": "apps", "id": app_id}}
},
}
})
App Privacy is web-UI only
App Privacy (the privacy data-collection declaration) has, as far as I could tell, no API and must be set from the App Store Connect web UI. TEI Scanner does everything on-device and carries no analytics, telemetry, or advertising identifiers, so I selected "Data Not Collected." Since this declaration must be in place before a version can be submitted for review, the web UI side had to be settled before running the submission scripts.
This is the same as for iOS submission — one of the few steps that the API does not cover.
Review outcome and release
I submitted on May 10, 2026, and four days later received notice that review was complete and the app had become eligible for distribution. Version 1.0 passed with no back-and-forth.
One thing worth noting: "review complete" and "available on the App Store" are separate stages. If the version's release type is automatic, it goes public right after approval; if manual, it stays unpublished until the developer explicitly releases it. Even after the approval notice arrives, the App Store page may not appear immediately — Apple's own notice states it can take up to about 24 hours after release for items to become publicly available. The release state can be checked via the version's state (whether it is pending developer release, or ready for distribution).
Between "review complete" and "release," there was also a third axis this post does not cover — App Availability. Because I built the submission flow API-first, availability was never set, the app passed review with zero territories, and it still did not appear on the App Store afterward. I've written up that diagnosis and the after-the-fact fix via the App Store Connect API separately in Review Passed but the App Isn't on the App Store — Setting App Availability After the Fact via the App Store Connect API.
Summary of diffs from the iOS submission flow
Finally, here are the points that surfaced as diffs from iOS for this app.
| Step | iOS | macOS |
|---|---|---|
Archive destination | generic/platform=iOS | generic/platform=macOS |
| App Sandbox | Always sandboxed | Add the entitlement explicitly |
| Distribution artifact | .ipa | .pkg |
| Upload | altool --type ios | altool --type macos |
| Version-fetch filter | filter[platform]=IOS | filter[platform]=MAC_OS |
Screenshot displayType | APP_IPHONE_67 and others | APP_DESKTOP only |
| Screenshot resolution | Per device size | One of 4 sizes, e.g. 2880×1800 |
reviewSubmissions platform | IOS | MAC_OS |
The App Store Connect API skeleton is nearly identical for iOS and macOS; the diffs concentrate into platform identifiers and the screenshot display type. Because the earlier post had already set up .env-based CLI tooling and a self-screenshot mode, the App Store additions fit into a handful of scripts.
References
- App Store Connect API
- App Sandbox — Apple Developer
- Distributing your app for beta testing and releases — Apple Developer
- Companion post: Building a macOS App That Turns a Folder of Page Images Into a Single TEI/XML
- Companion post: Submitting an iOS App for Review Using Only the App Store Connect API
- Companion post: Fully Automating App Store Screenshot Generation with Python + UI Tests
- Companion post: Fixing and Resubmitting After an App Store Rejection via the App Store Connect API
- Companion post: Review Passed but the App Isn't on the App Store — Setting App Availability After the Fact via the App Store Connect API





Comments
…