Skip to content

Subscription API (PRO)

엔타이틀먼트의 진실은 서버. 앱은 스토어 거래 토큰을 백엔드로 보내 검증받고, 백엔드가 users.premium_until 를 갱신한다. 상세 도메인 룰: spec/domain-subscription.md.


POST /api/v1/subscription/verify

의도

스토어 구매/복원 후, 또는 앱 실행 refresh 시 — 거래를 스토어 서버 API 로 검증해 구독 활성 여부를 확정하고 premium_until 를 갱신한다. (구매·복원·refresh 공용)

인증

Bearer JWT 필수 (엔타이틀먼트가 userID 에 귀속 — 비로그인 불가).

요청

json
{
  "platform": "ios",
  "token": "<App Store: signed transaction(JWS) | Play: purchaseToken>",
  "product_id": "dartbrief.pro.monthly"
}
필드설명
platformios / android
tokeniOS = StoreKit2 signed transaction(JWS), Android = purchaseToken
product_iddartbrief.pro.monthly 또는 dartbrief.max.monthly

동작

  • iOS: App Store Server API 로 거래 검증 + Get Subscription Statuses → 만료시각/상태.
  • Android: Play Developer API purchases.subscriptionsv2.get → 만료시각/상태.
  • 활성(active/grace)이면 스토어 검증 응답의 상품 식별자(서버가 신뢰하는 값)로 premium_tier(pro/max) 결정 + premium_until=스토어 만료시각, premium_platform/premium_tx_ref 저장. 요청의 product_id 는 교차검증용일 뿐 티어 결정 근거로 쓰지 않음 — 클라이언트가 PRO 토큰에 dartbrief.max.monthly 를 실어 보내는 식의 티어 위조 차단.
  • expired/refunded 면 엔타이틀먼트 회수(premium_until 과거, premium_tier=none).

응답

200 OK

json
{
  "active": true,
  "tier": "pro",
  "premium_until": "2026-06-24T00:00:00Z",
  "product_id": "dartbrief.pro.monthly"
}
필드설명
activepremium_until > now() (grace 포함)
tiernone / pro / max — 클라이언트가 한도(분석 60/200, 종목 30/100) 표시에 사용
premium_until구독 만료 시각 RFC3339. 비활성(만료/환불)이면 과거값 또는 null
product_id검증된 상품

비활성(만료/환불 등 active:false)이면 엔타이틀먼트 회수(premium_tier=none, premium_until=null) 후 { "active": false, "tier": "none" }.

400 Bad Request

platform(ios/android 외)·token 누락·형식 오류.

401 Unauthorized

JWT 없음.

402 Payment Required

스토어 검증에서 유효하지 않은 거래(위조/미존재) → 권한 부여 안 함.

409 Conflict

검증은 됐으나 알 수 없는 상품(매핑 외 product) → 권한 부여 안 함.

503 Service Unavailable

스토어 검증 클라이언트 미구성(크레덴셜 부재) 또는 일시적 오류 → fail-closed(권한 부여 안 함). 구현 현황: internal/subscriptionStoreVerifier 가 인터페이스로 추상화돼 있고, App Store Server API(.p8)·Play Developer API(서비스계정) 클라이언트는 크레덴셜 도착 후 주입. 그 전까지 stubVerifier 가 503 반환.


POST /internal/appstore-notifications (Apple ASSN V2)

App Store Server Notifications V2 — Apple 이 갱신/만료/환불 등 이벤트를 이 URL 로 직접 POST.

  • 인증: JWT 없음. 본문은 signed payload(JWS) — Apple 공개키로 서명 검증 후 신뢰.
  • 처리: notificationType(DID_RENEW / EXPIRED / DID_FAIL_TO_RENEW(grace) / REFUND / REVOKE 등) → 해당 user 의 premium_until 갱신/회수.
  • 응답: 200(수신 확인). 실패 시 Apple 이 재시도.

POST /internal/play-rtdn (Google Play RTDN)

Play Real-time Developer Notifications — Play → GCP Pub/Sub 토픽push 구독이 이 URL 로 POST.

  • 인증: Pub/Sub push 메시지 검증(OIDC 토큰 or 공유 시크릿). 본문 data 는 base64 RTDN payload.
  • 처리: subscriptionNotification.notificationType(RENEWED / EXPIRED / IN_GRACE_PERIOD / REVOKED / 환불 등) → Play Developer API 로 상태 재조회 → premium_until 갱신/회수.
  • 응답: 200(ack). 비-200 이면 Pub/Sub 재전송.
  • 셋업(사용자 수동): GCP 토픽 생성 + Play 에 발행 권한 + 토픽의 push 구독을 이 URL 로.

웹훅이 즉시 반영, verify 가 누락/지연 보정. 둘 다 premium_tier+premium_until 단일 진실을 갱신.


엔타이틀먼트 노출

activeTier(none/pro/max) 가 다른 API 응답에 반영된다:

  • GET /api/v1/quotaweekly_limit 이 티어 값(10/60/200)으로 내려옴. 응답에 tier(none/pro/max) 포함. (api/quota)
  • GET /api/v1/watchlist/capacitycapacity 가 티어 값(3/30/100). (api/watchlist)

구현: internal/entitlement 패키지가 티어→한도 매핑(Tier.WeeklyLimit()/Tier.SlotCapacity())과 활성 판정(ActiveTier=premium_until > now() 이면 premium_tier)의 단일 진실. migration 00018 (users premium_* 컬럼 + slot_capacity 제거). verify 엔드포인트/웹훅은 후속 PR.