DEV Community

孫昊
孫昊

Posted on

ASC API: baseTerritory Missing in Price Schedule = Undocumented 409 Error

I spent four hours chasing a phantom 409 Conflict error from the Apple App Store Connect API. The response gave me nothing: no error message, no field validation hint, just an HTTP 409 with an empty body.

The app had 8 SKUs. I was trying to set IAP pricing using the appPricePoints endpoint. Six worked fine. Two threw 409. The request bodies looked identical.

After inspecting the successful submissions, I discovered the problem: the baseTerritory field was required in the price schedule relationship, but Apple's API documentation doesn't mention it. The field isn't documented. There's no validation error. You just get a silent 409.

This is a real footgun for anyone automating ASC via the v1/v2 hybrid API.


The Setup: Creating Price Schedules via ASC API

To set IAP pricing in App Store Connect, you follow this flow:

  1. Create an appPricePoint (a single price tier, like $0.99)
  2. Create an appPriceSchedule (a start date + price point mapping)
  3. Associate the price schedule with your IAP

The v2 API is mostly documented. But price schedules live in a weird hybrid zone where v2 resources are accessed via v1 endpoints, and the documentation is incomplete.

Here's the correct flow:

import requests
import json
from datetime import datetime, timezone

def create_price_schedule(app_id: str, price_tier_id: str, territory: str, token: str) -> dict:
    """
    Create a price schedule for an app's IAP.

    Args:
        app_id: App Store Connect app ID (numeric string)
        price_tier_id: ID of the pricePoint (e.g., "ABC123")
        territory: 2-letter ISO territory code (e.g., "US")
        token: ASC API JWT token

    Returns:
        The created schedule resource or error details
    """
    headers = {
        "Authorization": f"Bearer {token}",
        "Content-Type": "application/json"
    }

    # ASC API endpoint for creating price schedules
    url = f"https://api.appstoreconnect.apple.com/v1/apps/{app_id}/appPriceSchedules"

    payload = {
        "data": {
            "type": "appPriceSchedules",
            "attributes": {
                # Start date: today (required)
                "startDate": datetime.now(timezone.utc).strftime("%Y-%m-%d")
            },
            "relationships": {
                # Link to the price point (required)
                "appPricePoint": {
                    "data": {
                        "type": "appPricePoints",
                        "id": price_tier_id
                    }
                },
                # THE MISSING FIELD: baseTerritory (undocumented but required!)
                "baseTerritory": {
                    "data": {
                        "type": "territories",
                        "id": territory  # e.g., "US"
                    }
                }
            }
        }
    }

    response = requests.post(url, json=payload, headers=headers)

    if response.status_code == 201:
        return response.json()["data"]
    elif response.status_code == 409:
        print(f"409 Conflict! Empty response body.")
        print(f"Common cause: missing baseTerritory relationship")
        return {"error": "409", "suggestion": "Check baseTerritory in relationships"}
    else:
        print(f"Status {response.status_code}: {response.text}")
        return {"error": response.status_code, "body": response.text}

# Usage
app_id = "1234567890"
price_point_id = "ABC123XYZ"  # ID returned from pricePoints endpoint
territory = "US"
token = "your-jwt-token"

result = create_price_schedule(app_id, price_point_id, territory, token)
print(json.dumps(result, indent=2))
Enter fullscreen mode Exit fullscreen mode

Why This Happens: API Documentation Debt

Apple's ASC API docs are split across v1 and v2. Most IAP operations moved to v2, but price schedules stayed in v1. When you fetch the appPriceSchedules endpoint via v1, the schema is minimal:

# Documented response from Apple (incomplete)
{
    "data": {
        "type": "appPriceSchedules",
        "id": "123456",
        "attributes": {
            "startDate": "2026-05-12"
        },
        "relationships": {
            "appPricePoint": {
                "data": {
                    "type": "appPricePoints",
                    "id": "ABC123"
                }
            }
            // baseTerritory is NOT listed in the docs
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

But when you try to create a price schedule without baseTerritory, you get a 409. Apple's API doesn't validate the request shape; it returns 409 if the business logic fails (schedule already exists for that territory with an active date, or territory lookup fails internally).

The field is required by the backend, but the documentation doesn't mention it.


The Diagnostic: How to Spot This

If you're hitting 409 errors when creating price schedules, check:

  1. baseTerritory relationship is present and has a valid 2-letter ISO code (US, JP, AU, etc.)
  2. appPricePoint ID is valid (fetch it first via appPricePoints endpoint)
  3. startDate is in ISO format (YYYY-MM-DD)
  4. No duplicate schedule for that territory with overlapping dates

Here's a quick verification script:

def validate_price_schedule_payload(payload: dict, app_id: str, token: str) -> bool:
    """
    Pre-flight check: verify all required fields exist before posting.
    """
    relationships = payload.get("data", {}).get("relationships", {})

    # Check 1: baseTerritory exists
    if "baseTerritory" not in relationships:
        print("ERROR: baseTerritory missing from relationships")
        return False

    # Check 2: appPricePoint exists
    if "appPricePoint" not in relationships:
        print("ERROR: appPricePoint missing from relationships")
        return False

    # Check 3: Territory code is valid (optional, but recommended)
    territory_id = relationships["baseTerritory"]["data"]["id"]
    if len(territory_id) != 2:
        print(f"WARNING: territory ID '{territory_id}' doesn't look like ISO code (should be 2 chars)")

    # Check 4: Fetch the price point to verify it exists
    headers = {"Authorization": f"Bearer {token}"}
    pp_id = relationships["appPricePoint"]["data"]["id"]
    resp = requests.get(
        f"https://api.appstoreconnect.apple.com/v1/apps/{app_id}/appPricePoints/{pp_id}",
        headers=headers
    )
    if resp.status_code != 200:
        print(f"ERROR: appPricePoint {pp_id} not found (status {resp.status_code})")
        return False

    print("✓ All required fields present")
    return True

# Usage
payload = {
    "data": {
        "type": "appPriceSchedules",
        "attributes": {
            "startDate": "2026-05-12"
        },
        "relationships": {
            "appPricePoint": {
                "data": {"type": "appPricePoints", "id": "ABC123"}
            },
            "baseTerritory": {
                "data": {"type": "territories", "id": "US"}
            }
        }
    }
}

validate_price_schedule_payload(payload, "1234567890", token)
Enter fullscreen mode Exit fullscreen mode

Broader Pattern: Undocumented v1/v2 Quirks

This is part of a larger ASC API issue: the v1/v2 split creates documentation gaps. Other undocumented fields to watch:

  • appAvailabilities: The availableInNewTerritories field is v2 but accessed via v1 path
  • reviewSubmissions: Requires appStoreVersions relationship, but docs show it as optional
  • inAppPurchases: The productId vs bundleId distinction is unclear
  • appStoreVersionSubmissions: Deprecated in favor of reviewSubmissions, but still lurks in older code

For all these, the pattern is the same: read successful submissions from the API and reverse-engineer the required fields. Don't trust the docs alone.


The Fix: Ensure baseTerritory Is Always Present

Here's a production-ready function that guarantees success:

def set_iap_price_with_schedule(
    app_id: str,
    iap_id: str,
    price_tier_id: str,
    territory: str,
    start_date: str,
    token: str
) -> dict:
    """
    Set IAP pricing via appPriceSchedule.
    Ensures baseTerritory is present to avoid silent 409.
    """
    headers = {
        "Authorization": f"Bearer {token}",
        "Content-Type": "application/json"
    }

    # Create the price schedule
    url = f"https://api.appstoreconnect.apple.com/v1/apps/{app_id}/appPriceSchedules"

    payload = {
        "data": {
            "type": "appPriceSchedules",
            "attributes": {
                "startDate": start_date
            },
            "relationships": {
                "appPricePoint": {
                    "data": {
                        "type": "appPricePoints",
                        "id": price_tier_id
                    }
                },
                "baseTerritory": {
                    "data": {
                        "type": "territories",
                        "id": territory
                    }
                }
            }
        }
    }

    resp = requests.post(url, json=payload, headers=headers)

    if resp.status_code in (201, 200):
        schedule_id = resp.json()["data"]["id"]
        print(f"✓ Schedule created: {schedule_id}")
        return {"success": True, "schedule_id": schedule_id}
    elif resp.status_code == 409:
        # Likely cause: duplicate or territory conflict
        print(f"409 Conflict. Possible causes:")
        print(f"  1. Schedule already exists for territory {territory}")
        print(f"  2. Price point {price_tier_id} is invalid")
        print(f"  3. Territory {territory} doesn't exist or is inactive")
        return {"success": False, "error": "409 Conflict"}
    else:
        print(f"Error {resp.status_code}: {resp.text}")
        return {"success": False, "error": resp.status_code}
Enter fullscreen mode Exit fullscreen mode

Key Takeaways

  • baseTerritory is required for appPriceSchedules, but Apple doesn't document it
  • 409 Conflict doesn't always mean a duplicate; it's often a missing relationship
  • Read successful payloads via the API to learn what fields are actually required
  • v1/v2 split creates documentation debt across the ASC ecosystem
  • Test price schedules early in your submission flow; don't discover this in production

If you're building an ASC automation tool, add this check to your pre-flight validation. It'll save you hours of debugging 409 loops.


Sources

Subscribe to my Substack for more ASC API undocumented quirks and iOS shipping automation. Get the TestFlight Bible ($29) for 50+ real ASC workflows. Join the affiliate program and earn 30% recurring on every sale.


If this helped you, here are the tools I actually use

ASC API Toolkit — $499 — 60+ Python scripts that hit the v1/v2 endpoints I wrote about

TF Debug Bible — $29 — the TestFlight cache bug workaround via reviewSubmissions API

AutoApp Dashboard — $39 — Flask UI on top of these scripts — manifest-first, no INDEX.md

See the full Day 60 indie hacker tool stack ->

Top comments (0)