← 블로그 전체
9 min readApp Store Connect · TypeScript · Indie

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 에서 받아올 것

  1. App Store Connect 로그인 → Users and Access Keys
  2. App Store Connect API 탭 → 새 키 생성 (Access: Sales 또는 그 이상)
  3. .p8 파일 다운로드 — 1회만 받을 수 있음. 손실 시 키 재발급
  4. Issuer ID, Key ID 메모 — JWT 서명에 필요
  5. Vendor Number 확인 → Payments and Financial Reports 상단
p8 키는 secret 이다. .env 에 base64 또는 PEM 그대로 저장. Git 절대 X. Vercel 의 경우 Environment Variables 에 PEM 그대로 (개행 포함) 붙여넣기.

JWT 서명 — ES256 (RS256 아님)

Apple JWT 의 첫 함정. 대부분 JWT 라이브러리가 RS256 을 기본값으로 가정한다. App Store Connect API 는 ES256 (ECDSA + P-256) 전용이다. 다른 알고리즘 보내면 401.

jose 라이브러리가 가장 깔끔하다 (Node · Edge 둘 다 동작).

typescript
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.

typescript
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.

typescript
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개

  1. ES256, RS256 아님 — 라이브러리 기본값 확인
  2. 토큰 20분 max — 캐시 + 만료 1분 전 갱신
  3. vendorNumber ≠ appId — Payments 화면에서 찾기
  4. TSV, CSV 아님 — 탭 구분, 빈 컬럼 처리 주의
  5. D+1 lag — 오늘 매출은 내일 늦은 오후에 나옴
  6. Sandbox row 섞임 Provider 컬럼이 sandbox 면 제외
  7. 다중 통화 — 리포트는 통화별로 옴. 끝에서 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 그냥 쓰는 게 낫다.

Horog 는 8개 매출·시간 통합으로 사이드 프로젝트별 진짜 시급을 계산해주는 SaaS 입니다. App Store Connect 직접 통합 + 한국어 UX + AI 주간 인사이트. 베타 2026-06~09, 첫 100명 Pro 3개월 무료 — 베타 신청.
RevenueCat 없이 App Store Connect API 직접 호출하기 — Horog · Horog