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
  • .p8 private key downloaded from App Store Connect → Users and Access → Keys

Setup

Step 1 — Generate App Store Connect API key

  1. App Store Connect → Users and Access → Integrations → App Store Connect API → Generate API Key.
  2. Name: pricing-automation. Access: Admin (Developer role can’t manage pricing).
  3. Download the .p8 file (one-time download — back it up).
  4. 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 sale to accept --territory all and iterate the 175 territories from /v1/territories, applying PPP-aware tier maps from a config YAML.
  • Subscription repricing: add a sub-reprice subcommand using POST /v1/subscriptionPrices with preserveCurrentPrice: 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 diff reads 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 via preserveCurrentPrice: true.
  • The .p8 key 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).