[애플] 인앱 결제 - 구독 상품 API

2026. 1. 21. 14:49·기능/API
반응형

지난 글에서는 애플 인앱 결제 상품 중 개별 상품 API에 대해서 알아봤습니다. 개별 상품의 생성, 조회, 수정, 삭제를 구현하고 약 1일만에 심사까지 통과 했습니다. 이번 글에서는 두 번째 상품 종류인 구독 상품(Auto-Renewable Subscriptions)에 대해 알아보도록 하겠습니다. API Key, JWT 등 기본 설정 방법은 인앱 결제 API를 참고해주세요.

 

기본 흐름

구독 상품의 경우도 개별 상품과 마찬가지로 생성하는데 있어 많은 단계를 진행해야 합니다. 흐름은 다음과 같습니다.

1. 구독 그룹 생성: 구독 그룹을 생성하고 이름을 설정합니다. 생성 이후 동일한 구독 그룹 내에 상품을 만들 경우 구독 그룹 ID만 재사용하고, 추가적인 호출은 필요 없습니다. 

2. 구독 그룹 현지화 정보 생성: 구독 그룹의 현지화 정보를 생성합니다. 1번과 마찬가지로 동일한 구독 그룹을 사용하는 경우 1회만 호출하고 이후 추가적인 호출은 필요 없습니다. 

3. 구독 그룹 내 구독 상품 생성: 구독 그룹 내 구독 상품을 생성합니다. 상품명(name), 상품 ID(productID), 상품 설명(reviewNote), 결제 주기(subscriptionPeriod)를 설정합니다. 

4. 구독 상품 지역 설정: 상품을 구매할 수 있는 지역을 설정합니다.

5. 구독 상품 가격 설정: 상품의 가격을 설정합니다. 여기서는 1) 사용 가능한 가격 및 가격 ID, 2) 상품 가격 설정, 3) 가격에 해당하는 타 국가 가격 ID 조회, 4) 타 국가 가격 설정으로 구성됩니다.

6. 구독 상품 현지화 정보 설정: 각각의 지역에서 사용자에게 노출을 다르게 설정할 수 있습니다. 

7. 구독 상품 스크린샷 등록: 상품 심사를 위한 앱 스크린샷을 제출합니다. 여기서는 1) 스크린샷 영역 설정, 2) 스크린샷 업로드, 3) 스크린샷 커밋, 4) 스크린샷 업로드 상태 확인으로 구성됩니다.

8. 구독 그룹 심사 등록: 구독 그룹 심사를 제출합니다. 1, 2번과 마찬가지로 한 번 심사 승인 이후 재호출은 불필요합니다.

9. 구독 상품 심사 등록: 구독 그룹 내 구독 상품 심사를 제출합니다.


구독 그룹 생성

구독 그룹을 생성하는 단계입니다. 구독 그룹 이름만 설정하면 되며, 응답값으로 구독 그룹 ID를 받을 수 있습니다.

API Endpoint

POST /v1/subscriptionGroups

요청 예시:

async def create_apple_subs_group(client: httpx.AsyncClient) -> int:
    """
    Apple 구독 그룹 생성 
    1회만 호출하여 subscription_group_id 저장 후, 동일 ID로 호출
    """
    url = f"{APPLE_STORE_BASE_URL}/v1/subscriptionGroups"
    payload = {
        "data": {
            "type": "subscriptionGroups",
            "attributes": {
                "referenceName": name
            },
            "relationships": {
                "app": {
                    "data": {
                        "type": "apps",
                        "id": APPLE_STORE_APP_ID
                    }
                }
            }
        }
    }
    response = await apple_service.call_apple_api(client, "POST", url, json=payload)
    data = response.json()
    return data["data"]["id"]

응답 예시:

{
  "data": {
    "type": "subscriptionGroups",
    "id": "21892785",
    "attributes": {
      "referenceName": name
    },
    "relationships": {
      "subscriptions": {
        "links": {
          "self": "https://api.appstoreconnect.apple.com/v1/subscriptionGroups/21892785/relationships/subscriptions",
          "related": "https://api.appstoreconnect.apple.com/v1/subscriptionGroups/21892785/subscriptions"
        }
      },
      "subscriptionGroupLocalizations": {
        "links": {
          "self": "https://api.appstoreconnect.apple.com/v1/subscriptionGroups/21892785/relationships/subscriptionGroupLocalizations",
          "related": "https://api.appstoreconnect.apple.com/v1/subscriptionGroups/21892785/subscriptionGroupLocalizations"
        }
      }
    }
  },
  "links": {
    "self": "https://api.appstoreconnect.apple.com/v1/subscriptionGroups"
  }
}

여기서 응답받는 id가 구독 그룹의 ID입니다. 글에서는 subscription_group_id로 사용하고 있습니다.

 

적용 영역:


구독 그룹 현지화 정보 생성

구독 그룹이 지역에 따라 사용자에게 노출 되는 정보를 다르게 설정할 수 있습니다.

 

API Endpoint

POST /v1/subscriptionGroupLocalizations

요청 예시:

async def create_apple_subs_group_localization(
        client: httpx.AsyncClient, 
        subscription_group_id: int
    ) -> None:
    """
    Apple 구독 그룹 현지화 정보 설정 
    1회만 호출하여 등록 해두면 추후 호출할 필요 없음
    """
    url = f"{APPLE_STORE_BASE_URL}/v1/subscriptionGroupLocalizations"
    payload = {
        "data": {
            "type": "subscriptionGroupLocalizations",
            "attributes": {
                "name": "그낙이네 구독 상품 그룹",
                "locale": "ko",
            },
            "relationships": {
                "subscriptionGroup": {
                    "data": {
                        "type": "subscriptionGroups",
                        "id": subscription_group_id
                    }
                }
            }
        }
    }
    response = await apple_service.call_apple_api(client, "POST", url, json=payload)
    response.raise_for_status()
    return

응답 예시:

{
  "data": {
    "type": "subscriptionGroupLocalizations",
    "id": "512e14fb-3934-4eed-a11f-78fd75bd62ff",
    "attributes": {
      "name": localized_name,
      "customAppName": null,
      "locale": "ko",
      "state": "PREPARE_FOR_SUBMISSION"
    },
    "links": {
      "self": "https://api.appstoreconnect.apple.com/v1/subscriptionGroupLocalizations/512e14fb-3934-4eed-a11f-78fd75bd62ff"
    }
  },
  "links": {
    "self": "https://api.appstoreconnect.apple.com/v1/subscriptionGroupLocalizations"
  }
}

적용 예시:


구독 상품 생성

구독 그룹 내 구독 상품을 생성합니다. 기본적으로 상품명, 상품 ID, 상품 설명, 결제 주기를 설정합니다. 상품명과 상품 ID는 앱 전체에서 중복될 수 없는 유니크 값입니다. 개별 상품 생성과 마찬가지로 큰 틀을 만드는 단계입니다.

 

API Endpoint

POST /v1/subscriptions

요청 예시:

async def create_apple_subs_metadata(
        client: httpx.AsyncClient, 
        subscription_group_id: int, 
        product_id: str, 
        product_name: str, 
        description: str, 
        monthly: int
    ) -> int:
    """
    Apple Subscriptions 메타데이터 생성
    """
    valid_months = {1, 3, 6, 12}
    if monthly not in valid_months: 
        raise ValueError(f"결제 주기는 {valid_months}개월 중 하나여야 합니다.")
    
    period_map = {
        1: "ONE_MONTH",
        3: "THREE_MONTHS",
        6: "SIX_MONTHS",
        12: "ONE_YEAR"
    }

    period = period_map[monthly]
    url = f"{APPLE_STORE_BASE_URL}/v1/subscriptions"
    payload = {
        "data": {
            "type": "subscriptions",
            "attributes": {
                "name": product_name,
                "productId": product_id,
                "subscriptionPeriod": period,  # ONE_MONTH, TWO_MONTHS, THREE_MONTHS, SIX_MONTHS, ONE_YEAR
                "reviewNote": description,
                "groupLevel": 1,
            },
            "relationships": {
                "group": {
                    "data": {
                        "type": "subscriptionGroups",
                        "id": subscription_group_id
                    }
                }
            }
        }
    }
    response = await apple_service.call_apple_api(client, "POST", url, json=payload)
    data = response.json()
    return data["data"]["id"]

응답 예시:

{
  "data": {
    "type": "subscriptions",
    "id": "6757855490",
    "attributes": {
      "name": "GNAAK FOR 1 MONTH",
      "productId": "SUBS.GNAAK.1000P",
      "familySharable": false,
      "state": "MISSING_METADATA",
      "subscriptionPeriod": "ONE_MONTH",
      "reviewNote": "THIS IS ALL ACCESS FOR GNAAK TIER 1",
      "groupLevel": 1
    },
    /* ... */
  },
  "links": {
    "self": "https://api.appstoreconnect.apple.com/v1/subscriptions"
  }
}

여기서 응답 받은 id 값을 subscription_id로 사용했습니다.

 

적용 영역:

 


구독 상품 지역 설정

상품이 사용 가능한 지역을 설정해줍니다.

 

API Endpoint

POST /v1/subscriptionAvailabilities

요청 예시:

async def create_apple_subs_availability(
        client: httpx.AsyncClient, 
        subscription_id: int
    ) -> None:
    """
    Apple Subscriptions 사용 가능 지역 설정
    """
    url = f"{APPLE_STORE_BASE_URL}/v1/subscriptionAvailabilities"
    payload = {
        "data": {
            "type": "subscriptionAvailabilities",
            "attributes": {
                "availableInNewTerritories": False,
            },
            "relationships": {
                "availableTerritories": {
                    "data": [{
                        "type": "territories",
                        "id": "KOR",
                    }]
                },
                "subscription": {
                    "data": {
                        "type": "subscriptions",
                        "id": subscription_id,
                    }
                }
            }
        }
    }
    response = await apple_service.call_apple_api(client, "POST", url, json=payload)
    response.raise_for_status()
    return

응답 예시:

{
  "data": {
    "type": "subscriptionAvailabilities",
    "id": "6757855490",
    "attributes": {
      "availableInNewTerritories": false
    },
    "relationships": {
      "availableTerritories": {
        "links": {
          "self": "https://api.appstoreconnect.apple.com/v1/subscriptionAvailabilities/6757855490/relationships/availableTerritories",
          "related": "https://api.appstoreconnect.apple.com/v1/subscriptionAvailabilities/6757855490/availableTerritories"
        }
      }
    },
    "links": {
      "self": "https://api.appstoreconnect.apple.com/v1/subscriptionAvailabilities/6757855490"
    }
  },
  "links": {
    "self": "https://api.appstoreconnect.apple.com/v1/subscriptionAvailabilities"
  }
}

적용 영역:


구독 상품 가격 설정

사용 가능 가격 조회

개별 상품 생성과 마찬가지로 애플에서는 가격에 해당하는 가격 ID를 등록해야합니다. 

 

API Endpoint

필터와 제한 개수를 통해 한국 가격 ID를 찾을 수 있습니다. 가격대마다 limit 내에 존재 여부가 달라지므로 확인하고 사용하시길 바랍니다. 구독 상품은 개별 상품과 다르게 v1을 사용합니다

GET /v1/subscriptions/{subscription_id}/pricePoints?filter[territory]=KOR&include=territory&limit=100

요청 예시:

async def get_apple_subs_price_point_id(
        client: httpx.AsyncClient, 
        subscription_id: int, 
        price: int) -> Optional[str]:
    """
    Apple Subscriptions 사용 가능 가격 조회
    """

    url = f"{APPLE_STORE_BASE_URL}/v1/subscriptions/{subscription_id}/pricePoints?filter[territory]=KOR&include=territory&limit=100"

    response = await apple_service.call_apple_api(client, "GET", url)
    data=response.json()
    price_points = data["data"]
    target_price = price
    price_point_id = None

    for price_point in price_points:
        if price_point["attributes"]["customerPrice"] == str(target_price):
            price_point_id = price_point["id"]
            break
    if not price_point_id:
        raise ValueError("선택하신 가격은 애플에서 지원하지 않습니다.")
    return price_point_id

응답 예시:

{
  "data": [
    {
      "type": "subscriptionPricePoints",
      "id": "eyJzIjoiNjc1Nzg1NTQ5MCIsInQiOiJLT1IiLCJwIjoiMTAwMDEifQ",
      "attributes": {
        "customerPrice": "400",
        "proceeds": "291",
        "proceedsYear2": "345"
      },
      "relationships": {
        "territory": {
          "data": {
            "type": "territories",
            "id": "KOR"
          }
        },
        "equalizations": {
          "links": {
            "self": "https://api.appstoreconnect.apple.com/v1/subscriptionPricePoints/eyJzIjoiNjc1Nzg1NTQ5MCIsInQiOiJLT1IiLCJwIjoiMTAwMDEifQ/relationships/equalizations",
            "related": "https://api.appstoreconnect.apple.com/v1/subscriptionPricePoints/eyJzIjoiNjc1Nzg1NTQ5MCIsInQiOiJLT1IiLCJwIjoiMTAwMDEifQ/equalizations"
          }
        }
      },
      "links": {
        "self": "https://api.appstoreconnect.apple.com/v1/subscriptionPricePoints/eyJzIjoiNjc1Nzg1NTQ5MCIsInQiOiJLT1IiLCJwIjoiMTAwMDEifQ"
      }
    },

    /* … 중간 price point 동일 구조 … */

  ],
  "included": [
    {
      "type": "territories",
      "id": "KOR",
      "attributes": {
        "currency": "KRW"
      },
      "links": {
        "self": "https://api.appstoreconnect.apple.com/v1/territories/KOR"
      }
    }
  ],
  "links": {
    "self": "https://api.appstoreconnect.apple.com/v1/subscriptions/6757855490/pricePoints?filter%5Bterritory%5D=KOR&include=territory&limit=10",
    "next": "https://api.appstoreconnect.apple.com/v1/subscriptions/6757855490/pricePoints?filter%5Bterritory%5D=KOR&include=territory&cursor=Cg&limit=10"
  },
  "meta": {
    "paging": {
      "total": 800,
      "nextCursor": "Cg",
      "limit": 10
    }
  }
}

구독 상품 가격 설정

이제 받아온 price_point_id와 구독 상품의 id인 subscription_id를 사용해서 상품 가격을 설정해줍시다.

 

API Endpoint

POST /v1/subscriptionPrices

 

요청 예시:

async def set_apple_subs_price(
        client: httpx.AsyncClient, 
        subscription_id: int, 
        price_point_id: str
    ) -> None:
    """
    Apple Subscriptions 상품 가격 설정
    """
    url = f"{APPLE_STORE_BASE_URL}/v1/subscriptionPrices"
    payload = {
        "data": {
            "type": "subscriptionPrices",
            "relationships": {
                "subscription": {
                    "data": {
                        "type": "subscriptions", 
                        "id": subscription_id,            
                    }
                },
                "subscriptionPricePoint": {
                    "data": {
                        "type": "subscriptionPricePoints",
                        "id": price_point_id            
                    }
                }
            },
            "attributes": {
                "preserveCurrentPrice": True
            }
        },
    }
    response = await apple_service.call_apple_api(client, "POST", url, json=payload)
    response.raise_for_status()
    return

응답 예시:

{
  "data": {
    "type": "subscriptionPrices",
    "id": "eyJhIjoiNjc1Nzg1NTQ5MCIsImMiOiJLUiIsImQiOjAsInAiOiIwIn0",
    "attributes": {
      "startDate": null,
      "preserved": false
    },
    "links": {
      "self": "https://api.appstoreconnect.apple.com/v1/subscriptionPrices/eyJhIjoiNjc1Nzg1NTQ5MCIsImMiOiJLUiIsImQiOjAsInAiOiIwIn0"
    }
  },
  "links": {
    "self": "https://api.appstoreconnect.apple.com/v1/subscriptionPrices"
  }
}

적용 영역:


가격에 해당하는 타 국가 가격 조회

구독 상품 가격 설정에서는 한국(사용 국가) 가격만 등록하는게 아니라, 모든 국가 가격을 다 등록해줘야합니다. (공식 문서에는 "If the subscription is available in all territories, an individual POST /v1/subscriptionPrices call is necessary for each territory. You might consider automating this step."라고 나와있는데, 가격 등록을 다 안해주면 심사 요청이 막히더라구요.)

 

API Endpoint

총 175개국을 등록해줘야 하기 때문에 limit은 200으로 고정해서 사용했습니다.

GET /v1/subscriptionPricePoints/{price_point_id}/equalizations?include=territory&limit=200

요청 예시:

async def get_apple_subs_price_point_id_all(
        client: httpx.AsyncClient, 
        price_point_id: str
    ) -> List[Dict[str,str]]:
    """
    Apple Subscriptions 다른 국가 상품 ID 조회 
    """
    url = f"{APPLE_STORE_BASE_URL}/v1/subscriptionPricePoints/{price_point_id}/equalizations?include=territory&limit=200"
    response = await apple_service.call_apple_api(client, "GET", url)
    data = response.json()
    result = []
    country_list = data["data"]
    for country in country_list:
        result.append({
            "price_point_id": country["id"],
            "country_code": country["relationships"]["territory"]["data"]["id"]
        })
    return result

응답 예시:

{
  "data": [
    {
      "type": "subscriptionPricePoints",
      "id": "eyJzIjoiNjc1NzkxMDYxMSIsInQiOiJBRkciLCJwIjoiMTAwNDkifQ",
      "attributes": {
        "customerPrice": "3.99",
        "proceeds": "2.8",
        "proceedsYear2": "3.39"
      },
      "relationships": {
        "territory": {
          "data": {
            "type": "territories",
            "id": "AFG"
          }
        },
        "equalizations": {
          "links": {
            "self": "https://api.appstoreconnect.apple.com/v1/subscriptionPricePoints/eyJzIjoiNjc1NzkxMDYxMSIsInQiOiJBRkciLCJwIjoiMTAwNDkifQ/relationships/equalizations",
            "related": "https://api.appstoreconnect.apple.com/v1/subscriptionPricePoints/eyJzIjoiNjc1NzkxMDYxMSIsInQiOiJBRkciLCJwIjoiMTAwNDkifQ/equalizations"
          }
        }
      },
      "links": {
        "self": "https://api.appstoreconnect.apple.com/v1/subscriptionPricePoints/eyJzIjoiNjc1NzkxMDYxMSIsInQiOiJBRkciLCJwIjoiMTAwNDkifQ"
      }
    }

    /* 동일한 구조의 subscriptionPricePoints가 계속 반복 (총 174개) */
  ],

  "included": [
    {
      "type": "territories",
      "id": "AFG",
      "attributes": {
        "currency": "USD"
      },
      "links": {
        "self": "https://api.appstoreconnect.apple.com/v1/territories/AFG"
      }
    },
    {
      "type": "territories",
      "id": "AUS",
      "attributes": {
        "currency": "AUD"
      },
      "links": {
        "self": "https://api.appstoreconnect.apple.com/v1/territories/AUS"
      }
    }

    /* territory 메타데이터 반복 */
  ],

  "links": {
    "self": "https://api.appstoreconnect.apple.com/v1/subscriptionPricePoints/{KOR_PRICE_POINT_ID}/equalizations?include=territory",
    "next": "https://api.appstoreconnect.apple.com/v1/subscriptionPricePoints/{KOR_PRICE_POINT_ID}/equalizations?include=territory&cursor=Mg"
  },

  "meta": {
    "paging": {
      "total": 174,
      "limit": 50,
      "nextCursor": "Mg"
    }
  }
}

타 국가 가격 설정

이제 받아온 응답 데이터로 타 국가에도 동일하게 가격을 설정해줍시다. 위에서 return한 result를 바로 others로 사용했습니다. 다른 국가 가격 설정도 위와 동일한 함수를 호출하면 됩니다. 또한 개별 국가 등록은 병렬로 처리해도 문제가 없기 때문에 10개씩 묶어서 API를 호출했습니다.

 

API Endpoint

POST /v1/subscriptionPrices

요청 예시:

async def set_apple_subs_price_all(
        client: httpx.AsyncClient, 
        subscription_id: int, 
        others: list
    ) -> None:
    """
    Apple Subscriptions 다른 국가 가격 설정
    """
    url = f"{APPLE_STORE_BASE_URL}/v1/subscriptionPrices"
    semaphore = asyncio.Semaphore(10)
    async def post_one(other: dict):
        async with semaphore:
            payload = {
                "data": {
                    "type": "subscriptionPrices",
                    "relationships": {
                        "subscription": {
                            "data": {
                                "type": "subscriptions", 
                                "id": subscription_id,            
                            }
                        },
                        "subscriptionPricePoint": {
                            "data": {
                                "type": "subscriptionPricePoints",
                                "id": other["price_point_id"]           
                            }
                        }
                    },
                },
            }

            response = await apple_service.call_apple_api(client, "POST", url, json=payload) 
            response.raise_for_status()
            print(f"{other['country_code']} 성공!")

    tasks = [post_one(other) for other in others]
    await asyncio.gather(*tasks, return_exceptions=True)

구독 상품 현지화 정보 설정

상품이 지역에 따라 사용자에게 노출 되는 정보를 다르게 설정할 수 있습니다. 위에서 사용 가능 지역을 한국만 선택했으니, 상품 현지화 정보도 한국어로만 설정해줍시다. 

 

API Endpoint

POST /v1/subscriptionLocalizations

요청 예시:

async def create_apple_subs_localization(
        client: httpx.AsyncClient, 
        subscription_id: int, 
        product_name: str, 
        description: str
    ) -> None:
    """
    Apple Subscriptions 현지화 정보 설정
    """
    url = f"{APPLE_STORE_BASE_URL}/v1/subscriptionLocalizations"
    payload = {
        "data": {
            "type": "subscriptionLocalizations",
            "attributes": {
                "locale": "ko",
                "name": product_name,
                "description": description
            },
            "relationships": {
                "subscription": {
                    "data": {
                        "type": "subscriptions",
                        "id": subscription_id
                    }
                }
            }
        }
    }
    response = await apple_service.call_apple_api(client, "POST", url, json=payload)
    response.raise_for_status()
    return

응답 예시:

{
  "data": {
    "type": "subscriptionLocalizations",
    "id": "aab2977d-43b4-4632-8227-c1cf87e92a98",
    "attributes": {
      "name": "IOS 구독 상품",
      "locale": "ko",
      "description": "이것은 애플스토어 구독 상품 생성 API로 생성된 구독 상품입니다.",
      "state": "PREPARE_FOR_SUBMISSION"
    },
    "links": {
      "self": "https://api.appstoreconnect.apple.com/v1/subscriptionLocalizations/aab2977d-43b4-4632-8227-c1cf87e92a98"
    }
  },
  "links": {
    "self": "https://api.appstoreconnect.apple.com/v1/subscriptionLocalizations"
  }
}

적용 영역:


스크린샷 등록하기

스크린샷 정보 등록 

 

API Endpoint

POST /v1/subscriptionAppStoreReviewScreenshots

요청 예시:

async def create_apple_subs_screenshot_metadata(
        client: httpx.AsyncClient,
        subscription_id: int
    ) -> Dict[str, Any]:
    """
    Apple Subscriptions 스크린샷 메타 데이터 생성
    """
    url = f"{APPLE_STORE_BASE_URL}/v1/subscriptionAppStoreReviewScreenshots"

    # TODO: 실제 업로드 하는 스크린샷으로 나중에 바꿔야 할수도
    file_path = MEDIA_ROOT / "sub_screenshot.png"
    file_name = file_path.name
    file_size = file_path.stat().st_size

    payload = {
        "data": {
            "type": "subscriptionAppStoreReviewScreenshots",
            "attributes": {
                "fileName": file_name,
                "fileSize": file_size
            },
            "relationships": {
                "subscription": {
                    "data": {
                        "type": "subscriptions",
                        "id": subscription_id,
                    }
                },
            }
        }
    }

    response = await apple_service.call_apple_api(client, "POST", url, json=payload)
    data = response.json()
    screenshot_id = data["data"]["id"]
    upload_operation = data["data"]["attributes"]["uploadOperations"]
    return {"id": screenshot_id, "method": upload_operation}

응답 예시:

{
  "data": {
    "type": "subscriptionAppStoreReviewScreenshots",
    "id": "64c9c7f5-4542-462c-9aea-ddfbc10fe6c2",
    "attributes": {
      "fileSize": 423676,
      "fileName": "screenshot.png",
      "sourceFileChecksum": "",
      "imageAsset": {
        "templateUrl": "",
        "width": 0,
        "height": 0
      },
      "assetToken": "PurpleSource221/v4/e8/2f/91/e82f917f-0244-68e2-e336-5cae95f32a90/screenshot.png",
      "assetType": "SCREENSHOT",
      "uploadOperations": [
        {
          "method": "PUT",
          "url": "https://northamerica-1.object-storage.apple.com/itmspod12-assets-massilia-200001/PurpleSource221/v4/e8/2f/91/e82f917f-0244-68e2-e336-5cae95f32a90/SHbdIQBRD00atOapEbQ_LNSGivG4x8cL6lIWbN5Zt_Q_U003d-1768480941878?partNumber=1&uploadId=a2d56290-f20f-11f0-b0b8-70b2b91d9cf1&apple-asset-repo-correlation-key=DADDG2HTEI4KRASLE5X62HSDCE&X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Date=20260115T124222Z&X-Amz-SignedHeaders=host&X-Amz-Credential=MKIAP7F9QNTEY48OTE7F%2F20260115%2Fnorthamerica-1%2Fs3%2Faws4_request&X-Amz-Expires=604800&X-Amz-Signature=7abe854a993db80edb9b4b9644b1ca14e6733cc4a2be0c43f55a453016443f63",
          "length": 423676,
          "offset": 0,
          "requestHeaders": [
            {
              "name": "Content-Type",
              "value": "image/png"
            }
          ]
        }
      ],
      "assetDeliveryState": {
        "errors": null,
        "warnings": null,
        "state": "AWAITING_UPLOAD"
      }
    },
    "links": {
      "self": "https://api.appstoreconnect.apple.com/v1/subscriptionAppStoreReviewScreenshots/64c9c7f5-4542-462c-9aea-ddfbc10fe6c2"
    }
  },
  "links": {
    "self": "https://api.appstoreconnect.apple.com/v1/subscriptionAppStoreReviewScreenshots"
  }
}

여기서 받아오는 id와 uploadOperations를 리턴해줍시다. 각각 screenshot_id와 upload_ops로 사용했습니다.


스크린샷 업로드 

이제 스크린샷을 등록해줍시다. upload_ops가 리스트 형태로 넘어오는데 단일 사진만 제출해도 충분하기 때문에 upload_op = upload_ops[0]를 변수로 넘겨줍시다.

async def upload_apple_subs_screenshot(
        client: httpx.AsyncClient, 
        upload_op: dict
    ) -> None:
    """
    Apple Subscriptions 스크린샷 업로드 
    """
    url = upload_op["url"]
    request_header = upload_op["requestHeaders"][0]
    headers = {
        request_header["name"]: request_header["value"]
    }

    file_path = MEDIA_ROOT / "sub_screenshot.png"

    with file_path.open("rb") as f:
        content=f.read()
    
    if not content:
        raise ValueError("파일을 읽을 수 없습니다.")
    

    response = await client.put(url, data=content, headers=headers)
    response.raise_for_status()
    return

스크린샷 커밋 

개별 상품과 마찬가지로 커밋을 진행해줍시다.

 

API Endpoint

PATCH /v1/subscriptionAppStoreReviewScreenshots/{screenshot_id}

요청 예시:

async def commit_apple_subs_screenshot(
        client: httpx.AsyncClient, 
        screenshot_id: str
    ) -> None:
    """
    Apple Subscription 업로드 스크린샷 커밋 
    """
    url = f"{APPLE_STORE_BASE_URL}/v1/subscriptionAppStoreReviewScreenshots/{screenshot_id}"
    payload = {
        "data": {
            "type": "subscriptionAppStoreReviewScreenshots",
            "id": screenshot_id,
            "attributes": {
                "uploaded": True
            },
        }
    }
    response = await apple_service.call_apple_api(client, "PATCH", url, json=payload)
    response.raise_for_status()
    return

스크린샷 상태 확인

이제 커밋된 스크린샷이 업로드 완료될 때까지 상태 확인을 통해서 심사 신청을 너무 일찍 넣는 실수를 방지해봅시다. 

 

API Endpoint

GET /v1/subscriptionAppStoreReviewScreenshots/{screenshot_id}

요청 예시:

async def check_apple_subs_screenshot(
        client: httpx.AsyncClient, 
        screenshot_id: str
    ) -> None:
    """
    Apple Subscription 업로드 스크린샷 상태 확인 
    """
    url = f"{APPLE_STORE_BASE_URL}/v1/subscriptionAppStoreReviewScreenshots/{screenshot_id}"
    start = time.time()
    interval = 5
    timeout = 60
    while True:
        response = await apple_service.call_apple_api(client, "GET", url)
        response.raise_for_status()
        data = response.json()
        status = data["data"]["attributes"]["assetDeliveryState"]["state"]
        if status == "COMPLETE":
            return
        if time.time() - start > timeout:
            raise TimeoutError("기다리는중...")
        await asyncio.sleep(interval)

심사 등록하기

이제 심사 등록을 하기 위한 모든 메타데이터 설정을 완료했습니다. 이제 구독 그룹과 그룹 내 구독 상품에 대해서 심사 제출을 진행해봅시다. 위에서 언급한 바와 같이, 구독 그룹의 경우 1회만 심사 승인이 되면 추후 재호출은 불필요합니다. 

 

구독 그룹 심사 등록하기

API Endpoint

POST /v1/subscriptionGroupSubmissions

요청 예시:

async def submit_apple_subs_group_review(
        client: httpx.AsyncClient, 
        subscription_group_id: int
    ) -> None:
    """
    Apple In-App Purchase 심사 등록
    """
    url = f"{APPLE_STORE_BASE_URL}/v1/subscriptionGroupSubmissions"
    payload = {
        "data": {
            "type": "subscriptionGroupSubmissions",
            "relationships": {
                "subscriptionGroup": {
                    "data": {
                        "id": subscription_group_id,
                        "type": "subscriptionGroups"
                    }
                }
            }
            
        }
    }

    response = await apple_service.call_apple_api(client, "POST", url, json=payload)
    response.raise_for_status()
    return

구독 상품 심사 등록하기

 

API Endpoint

POST /v1/subscriptionSubmissions

요청 예시:

async def submit_apple_subs_review(
        client: httpx.AsyncClient, 
        subscription_id: int
    ) -> None:
    url = f"{APPLE_STORE_BASE_URL}/v1/subscriptionSubmissions"
    payload = {
        "data": {
            "type": "subscriptionSubmissions",
            "relationships": {
                "subscription": {
                    "data": {
                        "id": subscription_id,
                        "type": "subscriptions"
                    }
                }
            }
            
        }
    }
    response = await apple_service.call_apple_api(client, "POST", url, json=payload)
    response.raise_for_status()
    return

이번 글에서는 애플 인앱 상품 중 구독 상품(Auto-Renewable Subscriptions)의 생성 과정에 대해서 알아봤습니다. 개별 상품과 마찬가지로 호출해야 하는 API 개수가 굉장히 많네요. 대략적으로 1~3일 이내에 심사가 끝이 나며 스케쥴러를 12시간 간격으로 돌려 DB와 애플 서버를 동기화 시켜줄 예정입니다. 다음 글에서는 구독 상품 조회와 수정, 그리고 삭제에 대해서 알아보도록 하겠습니다. 

 

반응형

'기능 > API' 카테고리의 다른 글

[애플] 인앱 결제 - 구독 상품 API 2  (0) 2026.01.22
[애플] 인앱 결제 API 2  (0) 2026.01.17
[애플] 인앱 결제 API  (0) 2026.01.17
REST API  (9) 2025.08.06
'기능/API' 카테고리의 다른 글
  • [애플] 인앱 결제 - 구독 상품 API 2
  • [애플] 인앱 결제 API 2
  • [애플] 인앱 결제 API
  • REST API
그낙이
그낙이
시작은 삽질이지만, 끝은 지식입니다.
  • 그낙이
    개발 삽질 일지
    그낙이
  • 전체
    오늘
    어제
    • 분류 전체보기 (71)
      • 서버 (12)
        • 터미널 기본기 (4)
        • AWS (3)
        • Linux (5)
      • 아키텍처 (3)
      • 기능 (19)
        • 로그인 (4)
        • API (5)
        • 앱 (5)
        • 기타 (4)
      • 자유로운 개발일지 (37)
        • APP (4)
        • AI (7)
        • 직링 (19)
        • 자동매매 (6)
  • 블로그 메뉴

    • 홈
    • 태그
    • 방명록
  • 링크

  • 공지사항

  • 인기 글

  • 태그

    예매
    챗봇
    개발자 도구 우회
    비트코인
    IAP
    nginx
    웹소켓
    개발자 도구
    EC2
    직링
    apple connect store
    Capacitor
    apple developer
    자동화 도구
    puppeteer
    kotlin
    자동매매
    퍼피티어
    linux
    GPT
    인앱 결제
    티켓
    콘서트
    fiddler
    FastAPI
    코인
    앱
    챗봇 만들기
    소셜 로그인
    업비트
  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.5
그낙이
[애플] 인앱 결제 - 구독 상품 API
상단으로

티스토리툴바