Lab 11.2 — Automated Pricing Script (App Store Connect API)
Goal
Build a Python CLI that authenticates with the App Store Connect REST API, reads your app’s current pricing, schedules a sale at a lower tier, and auto-restores after N days. Wire it into a GitHub Actions cron so Black Friday runs itself.
Time
60–90 minutes
Prereqs
- Python 3.11+
- App Store Connect account with Admin role
- An existing app published or in TestFlight
.p8private key downloaded from App Store Connect → Users and Access → Keys
Setup
Step 1 — Generate App Store Connect API key
- App Store Connect → Users and Access → Integrations → App Store Connect API → Generate API Key.
- Name:
pricing-automation. Access: Admin (Developer role can’t manage pricing). - Download the
.p8file (one-time download — back it up). - Note the Key ID (e.g.,
AAAA1111BB) and Issuer ID (top of the page, UUID format).
Step 2 — Find your app’s vendor number and app ID
# Vendor number: App Store Connect → Payments and Financial Reports → top of page
VENDOR_NUMBER=12345678
# App ID: App Store Connect → My Apps → your app → App Information → Apple ID
APP_ID=1234567890
Step 3 — Python project
mkdir asc-pricing && cd asc-pricing
python3 -m venv .venv && source .venv/bin/activate
pip install pyjwt cryptography requests typer rich
mkdir scripts
mv ~/Downloads/AuthKey_AAAA1111BB.p8 ./AuthKey_AAAA1111BB.p8
echo "AuthKey_*.p8" >> .gitignore
Step 4 — Environment
cat > .env <<'EOF'
ASC_KEY_ID=AAAA1111BB
ASC_ISSUER_ID=69a6de70-XXXX-XXXX-XXXX-XXXXXXXXXXXX
ASC_KEY_PATH=./AuthKey_AAAA1111BB.p8
APP_ID=1234567890
EOF
echo ".env" >> .gitignore
Build
File: scripts/asc.py
"""Shared App Store Connect helpers — JWT, requests, price-point lookup."""
import os
import time
import jwt # pyjwt
import requests
from pathlib import Path
from dotenv import load_dotenv
load_dotenv()
KEY_ID = os.environ["ASC_KEY_ID"]
ISSUER_ID = os.environ["ASC_ISSUER_ID"]
KEY_PATH = Path(os.environ["ASC_KEY_PATH"])
APP_ID = os.environ["APP_ID"]
BASE = "https://api.appstoreconnect.apple.com"
def make_token() -> str:
private_key = KEY_PATH.read_text()
headers = {"alg": "ES256", "kid": KEY_ID, "typ": "JWT"}
payload = {
"iss": ISSUER_ID,
"iat": int(time.time()),
"exp": int(time.time()) + 1200,
"aud": "appstoreconnect-v1",
}
return jwt.encode(payload, private_key, algorithm="ES256", headers=headers)
def session() -> requests.Session:
s = requests.Session()
s.headers.update({
"Authorization": f"Bearer {make_token()}",
"Content-Type": "application/json",
})
return s
def find_price_point(s: requests.Session, app_id: str, tier: str, territory: str = "USA") -> dict:
"""Find a specific price point for an app."""
r = s.get(
f"{BASE}/v1/apps/{app_id}/appPricePoints",
params={
"filter[priceTier]": tier,
"filter[territory]": territory,
"limit": 1,
}
)
r.raise_for_status()
data = r.json().get("data", [])
if not data:
raise RuntimeError(f"No price point found for tier {tier} in {territory}")
return data[0]
File: scripts/pricing_cli.py
"""
pricing-cli — manage App Store pricing from the command line.
Commands:
read Show current price schedule
sale TIER --days N Drop price to TIER for N days, then restore
restore Restore base price immediately
"""
import sys
import typer
from datetime import datetime, timedelta, timezone
from rich import print
from rich.table import Table
from asc import APP_ID, session, find_price_point, BASE
app = typer.Typer(no_args_is_help=True)
@app.command()
def read():
"""Show the current/scheduled price schedule for the app."""
s = session()
r = s.get(f"{BASE}/v1/apps/{APP_ID}/appPriceSchedule")
r.raise_for_status()
schedule = r.json()
# Fetch related appPrices
schedule_id = schedule["data"]["id"]
r2 = s.get(f"{BASE}/v2/appPriceSchedules/{schedule_id}/manualPrices",
params={"include": "appPricePoint,territory", "limit": 50})
r2.raise_for_status()
body = r2.json()
table = Table(title=f"App {APP_ID} — Current Price Schedule")
table.add_column("Start Date")
table.add_column("Territory")
table.add_column("Price Tier")
table.add_column("Customer Price")
included_by_id = {(i["type"], i["id"]): i for i in body.get("included", [])}
for entry in body.get("data", []):
start = entry["attributes"].get("startDate") or "(immediate)"
rel = entry["relationships"]
ptid = rel["appPricePoint"]["data"]["id"]
terr = rel["territory"]["data"]["id"]
pp = included_by_id.get(("appPricePoints", ptid))
if pp:
tier = pp["attributes"].get("priceTier", "?")
price = pp["attributes"].get("customerPrice", "?")
else:
tier, price = "?", "?"
table.add_row(str(start), terr, str(tier), str(price))
print(table)
@app.command()
def sale(
sale_tier: str = typer.Argument(..., help="The discounted price tier, e.g. '5' for $4.99"),
days: int = typer.Option(7, help="How many days the sale should run"),
base_tier: str = typer.Option("10", help="Tier to restore to after sale"),
territory: str = typer.Option("USA", help="Apple territory code"),
dry_run: bool = typer.Option(False, help="Print payload instead of POSTing"),
):
"""Drop the price to SALE_TIER for N days, then restore to BASE_TIER."""
s = session()
sale_pp = find_price_point(s, APP_ID, sale_tier, territory)
restore_pp = find_price_point(s, APP_ID, base_tier, territory)
now = datetime.now(timezone.utc).replace(microsecond=0)
restore_at = (now + timedelta(days=days)).isoformat().replace("+00:00", "Z")
payload = {
"data": {
"type": "appPriceSchedules",
"relationships": {
"app": {"data": {"type": "apps", "id": APP_ID}},
"baseTerritory":{"data": {"type": "territories", "id": territory}},
"manualPrices": {"data": [
{"type": "appPrices", "id": "sale"},
{"type": "appPrices", "id": "restore"},
]},
},
},
"included": [
{
"type": "appPrices",
"id": "sale",
"attributes": {"startDate": None},
"relationships": {
"appPricePoint": {"data": {"type": "appPricePoints", "id": sale_pp["id"]}},
"territory": {"data": {"type": "territories", "id": territory}},
},
},
{
"type": "appPrices",
"id": "restore",
"attributes": {"startDate": restore_at},
"relationships": {
"appPricePoint": {"data": {"type": "appPricePoints", "id": restore_pp["id"]}},
"territory": {"data": {"type": "territories", "id": territory}},
},
},
],
}
if dry_run:
import json
print("[yellow]DRY RUN — payload that would be POSTed:[/yellow]")
print(json.dumps(payload, indent=2))
return
r = s.post(f"{BASE}/v2/appPriceSchedules", json=payload)
if r.status_code >= 400:
print(f"[red]ERROR {r.status_code}[/red]")
print(r.json())
sys.exit(1)
print(f"[green]✓[/green] Sale scheduled: tier {sale_tier} → restore to tier {base_tier} at {restore_at}")
@app.command()
def restore(
base_tier: str = typer.Option("10"),
territory: str = typer.Option("USA"),
dry_run: bool = typer.Option(False),
):
"""Restore the base price immediately (e.g. ending a sale early)."""
s = session()
restore_pp = find_price_point(s, APP_ID, base_tier, territory)
payload = {
"data": {
"type": "appPriceSchedules",
"relationships": {
"app": {"data": {"type": "apps", "id": APP_ID}},
"baseTerritory":{"data": {"type": "territories", "id": territory}},
"manualPrices": {"data": [{"type": "appPrices", "id": "restore"}]},
},
},
"included": [{
"type": "appPrices",
"id": "restore",
"attributes": {"startDate": None},
"relationships": {
"appPricePoint": {"data": {"type": "appPricePoints", "id": restore_pp["id"]}},
"territory": {"data": {"type": "territories", "id": territory}},
},
}],
}
if dry_run:
import json
print(json.dumps(payload, indent=2))
return
r = s.post(f"{BASE}/v2/appPriceSchedules", json=payload)
r.raise_for_status()
print(f"[green]✓[/green] Restored to tier {base_tier} immediately")
if __name__ == "__main__":
app()
Run it
# Read current pricing
python scripts/pricing_cli.py read
# Dry-run a Black Friday sale
python scripts/pricing_cli.py sale 5 --days 4 --dry-run
# Schedule it for real
python scripts/pricing_cli.py sale 5 --days 4
# Verify
python scripts/pricing_cli.py read
# Should show two scheduled prices: tier 5 (now) and tier 10 (in 4 days)
# Restore immediately if you change your mind
python scripts/pricing_cli.py restore
Annotated curl equivalents
If you prefer curl over Python:
# Set up
TOKEN=$(python -c "from scripts.asc import make_token; print(make_token())")
# 1. Find a price point
curl -s -H "Authorization: Bearer $TOKEN" \
"https://api.appstoreconnect.apple.com/v1/apps/$APP_ID/appPricePoints?filter[priceTier]=5&filter[territory]=USA" \
| jq '.data[0]'
# → returns the opaque price-point ID
# 2. Read current schedule
curl -s -H "Authorization: Bearer $TOKEN" \
"https://api.appstoreconnect.apple.com/v1/apps/$APP_ID/appPriceSchedule" \
| jq
# 3. Schedule a sale (POST /v2/appPriceSchedules with manualPrices array)
# See payload in pricing_cli.py — too long for inline curl
GitHub Actions integration
# .github/workflows/black-friday.yml
name: Black Friday Sale
on:
schedule:
- cron: '0 7 27 11 *' # Nov 27 2026, 07:00 UTC = midnight PT
workflow_dispatch:
jobs:
start-sale:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with: { python-version: '3.12' }
- run: pip install pyjwt cryptography requests typer rich python-dotenv
- name: Materialize ASC key from secret
env:
ASC_KEY_BASE64: ${{ secrets.ASC_KEY_BASE64 }}
run: |
echo "$ASC_KEY_BASE64" | base64 -d > AuthKey_AAAA1111BB.p8
chmod 600 AuthKey_AAAA1111BB.p8
- name: Schedule sale
env:
ASC_KEY_ID: ${{ secrets.ASC_KEY_ID }}
ASC_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }}
ASC_KEY_PATH: ./AuthKey_AAAA1111BB.p8
APP_ID: ${{ secrets.APP_ID }}
run: python scripts/pricing_cli.py sale 5 --days 4
- name: Notify Slack
if: success()
uses: slackapi/slack-github-action@v1
with:
payload: |
{"text": "🛍️ Black Friday sale live — tier 5 ($4.99) until Monday"}
env:
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK }}
Stash the .p8 file in a secret as base64:
base64 -i AuthKey_AAAA1111BB.p8 | pbcopy
# Paste into GitHub repo Settings → Secrets → ASC_KEY_BASE64
Stretch
- Multi-territory pricing: extend
saleto accept--territory alland iterate the 175 territories from/v1/territories, applying PPP-aware tier maps from a config YAML. - Subscription repricing: add a
sub-repricesubcommand usingPOST /v1/subscriptionPriceswithpreserveCurrentPrice: true. - Audit log: log every schedule change to a JSON file in the repo with
git commit— git becomes your pricing audit trail. - Slack approval workflow: post the dry-run payload to Slack with Approve/Reject buttons; only POST after approval.
- Diff mode:
pricing_cli.py diffreads current state and compares against a YAML config; prints what would change.
Notes
- Apple’s pricing pipeline takes 5–30 minutes to apply. Don’t panic if “tier 5 effective immediately” shows tier 10 for the first 15 minutes after POST.
- App Store Connect API is rate-limited at roughly 50 requests/minute. Pricing scripts won’t hit this; bulk territory operations might — add
time.sleep(1.5)between requests. - For subscription pricing, the API is
/v1/subscriptionPrices(not/v2/appPriceSchedules). The semantics differ: subscriptions can preserve existing subscribers at their current price viapreserveCurrentPrice: true. - The
.p8key gives Admin access to your entire App Store Connect account. Store as carefully as you would an AWS root key. - Test on a secondary app first. There’s no “undo” — you correct a botched schedule by scheduling another change.
Phase 11 complete. Next: Phase 12 — Architecture & Interview Prep (15 chapters + 4 labs covering MVC/MVVM/Clean/VIPER, The Composable Architecture, dependency injection, modular Swift packages, and senior-level system-design + interview question patterns).