RevenueCat 없이 App Store Connect API 직접 호출하기
Apple 의 App Store Connect API 로 매출 리포트를 직접 받는 법 — JWT (ES256) 서명, Sales Reports 엔드포인트, gzipped TSV 파싱. ~150줄 TypeScript.
Horog 는 매출 통합 8개 중 하나로 App Store Connect 를 직접 부른다. RevenueCat 경유 X. 이유는 단순하다 — 매출 데이터는 우리 거고, Apple 이 무료로 준다. 중간에 SaaS 하나 더 끼울 필요가 없다.
이 글은 그 통합을 어떻게 했는지 — JWT (ES256) 서명, Sales Reports 엔드포인트, gzipped TSV 파싱 — 150줄 분량을 정리한 노트다.
왜 RevenueCat 안 쓰는가
RevenueCat 은 좋은 도구지만 우리 use case 에 비해 비싸고 무겁다:
- 월 매출 $10K 까지 무료, 이후 1% 종량 — 5개 앱 굴리면 곧 $200~500/mo
- 핵심 가치는 entitlement 관리 (iOS+Android+web 동일 구독 묶기) — Horog 는 이게 필요 없음
- 매출 숫자 *조회만* 하려면 Apple API 가 이미 충분
- 외부 SaaS 의존 = 가용성·privacy 추가 위험
순수 매출 트래킹용이라면 Apple 직접 호출이 답이다. Subscription state 관리·receipt validation·paywall A/B 가 필요하면 RevenueCat 이 여전히 valid.
Apple 에서 받아올 것
- App Store Connect 로그인 →
Users and Access→Keys App Store Connect API탭 → 새 키 생성 (Access: Sales 또는 그 이상).p8파일 다운로드 — 1회만 받을 수 있음. 손실 시 키 재발급Issuer ID,Key ID메모 — JWT 서명에 필요Vendor Number확인 →Payments and Financial Reports상단
JWT 서명 — ES256 (RS256 아님)
Apple JWT 의 첫 함정. 대부분 JWT 라이브러리가 RS256 을 기본값으로 가정한다. App Store Connect API 는 ES256 (ECDSA + P-256) 전용이다. 다른 알고리즘 보내면 401.
jose 라이브러리가 가장 깔끔하다 (Node · Edge 둘 다 동작).
import { SignJWT, importPKCS8 } from "jose";
const ISSUER_ID = process.env.APP_STORE_ISSUER_ID!;
const KEY_ID = process.env.APP_STORE_KEY_ID!;
const P8_KEY = process.env.APP_STORE_P8_KEY!; // -----BEGIN PRIVATE KEY----- ...
export async function getAppStoreToken(): Promise<string> {
const privateKey = await importPKCS8(P8_KEY, "ES256");
return new SignJWT({})
.setProtectedHeader({ alg: "ES256", kid: KEY_ID, typ: "JWT" })
.setIssuer(ISSUER_ID)
.setIssuedAt()
.setExpirationTime("19m") // Apple 최대 20분
.setAudience("appstoreconnect-v1")
.sign(privateKey);
}토큰 lifetime 최대 20분. 매 요청마다 새로 서명하면 CPU 낭비라 토큰 캐시 권장 — 만료 1분 전 갱신.
Sales Reports 호출
엔드포인트: GET /v1/salesReports. 필수 파라미터 5개 — vendor number, reportType, reportSubType, frequency, reportDate.
import { gunzipSync } from "node:zlib";
const VENDOR_NUMBER = process.env.APP_STORE_VENDOR_NUMBER!;
export async function fetchDailySalesReport(reportDate: string) {
const token = await getAppStoreToken();
const url = new URL("https://api.appstoreconnect.apple.com/v1/salesReports");
url.searchParams.set("filter[vendorNumber]", VENDOR_NUMBER);
url.searchParams.set("filter[reportType]", "SALES");
url.searchParams.set("filter[reportSubType]", "SUMMARY");
url.searchParams.set("filter[frequency]", "DAILY");
url.searchParams.set("filter[reportDate]", reportDate); // YYYY-MM-DD
url.searchParams.set("filter[version]", "1_1");
const res = await fetch(url, {
headers: {
Authorization: `Bearer ${token}`,
Accept: "application/a-gzip",
},
});
if (res.status === 404) {
// 해당 날짜 리포트 아직 미생성 — Apple 은 보통 D+1 오후에 publish
return null;
}
if (!res.ok) {
throw new Error(`App Store ${res.status}: ${await res.text()}`);
}
const buf = Buffer.from(await res.arrayBuffer());
const tsv = gunzipSync(buf).toString("utf8");
return parseSalesTsv(tsv);
}주의 사항 4개:
- 응답은
application/a-gzip— gunzip 필수. fetch body 가 자동 압축 해제되지 않음 - 오늘 날짜로 요청하면
404— Apple 은 D+1 오후 publish. 보통 KST 16~18시 - DAILY 외에 WEEKLY · MONTHLY · YEARLY 도 동일 패턴
filter[version]=1_1명시 추천 — 컬럼 안정성
TSV 파싱
Apple 은 CSV 가 아니라 TSV 로 준다. 탭 구분자, 첫 줄은 헤더. 인코딩은 UTF-8.
interface SalesRow {
sku: string;
units: number;
developerProceeds: number;
currency: string;
countryCode: string;
}
function parseSalesTsv(tsv: string): SalesRow[] {
const [header, ...lines] = tsv.split("\n").filter(Boolean);
const cols = header.split("\t");
const idx = (name: string) => cols.indexOf(name);
const SKU = idx("SKU");
const UNITS = idx("Units");
const PROCEEDS = idx("Developer Proceeds");
const CURRENCY = idx("Currency of Proceeds");
const COUNTRY = idx("Country Code");
return lines.map((line) => {
const c = line.split("\t");
return {
sku: c[SKU],
units: Number(c[UNITS]),
developerProceeds: Number(c[PROCEEDS]),
currency: c[CURRENCY],
countryCode: c[COUNTRY],
};
});
}컬럼 인덱스를 이름으로 찾는 게 중요하다. Apple 이 가끔 컬럼 순서를 바꾸기 때문 — hardcoded index 쓰면 어느 날 매출이 SKU 자리에 들어온다.
실제로 부딪힌 gotcha 7개
- ES256, RS256 아님 — 라이브러리 기본값 확인
- 토큰 20분 max — 캐시 + 만료 1분 전 갱신
- vendorNumber ≠ appId — Payments 화면에서 찾기
- TSV, CSV 아님 — 탭 구분, 빈 컬럼 처리 주의
- D+1 lag — 오늘 매출은 내일 늦은 오후에 나옴
- Sandbox row 섞임 —
Provider컬럼이 sandbox 면 제외 - 다중 통화 — 리포트는 통화별로 옴. 끝에서 ECB 환율로 USD/KRW 환산
저장은 어디에
Horog 는 Supabase 의 revenue_events 테이블에 SKU·일자·금액 · 통화 단위로 그대로 적재. 시급 계산 시 통화 환산 + 프로젝트별 aggregate. RLS 정책은 user_id 기반 — 절대 다른 사람 매출 못 봄.
비용 비교
- RevenueCat: 월 매출 $50K 가정 시 약 $400/mo = $4,800/year
- Apple 직접: 무료. 코드 ~150줄 + 토큰 캐시 50줄 + 크론 50줄 = ~250줄
- 구현 시간: 하루. 회수 기간: 첫 달
언제 RevenueCat 이 답인가
- iOS + Android + web 구독 entitlement 통합 관리
- Receipt validation + 복원 UI
- Paywall A/B 테스트
- Subscription state webhook (구독 취소·환불·grace period)
Horog 는 위 4개 다 해당 X — 매출 *조회* 만 필요해서 Apple 직접이 정답이었다. 본인 use case 가 entitlement 관리 쪽이면 RevenueCat 그냥 쓰는 게 낫다.