CodeFluence

가입부터 확인된 결제까지 연결하기

소개 클릭을 가입 시점에 잠그고 제품 서버 또는 인증된 결제 웹훅으로 매출을 기록합니다. 앱에서는 검증된 링크 토큰을 자동 적용합니다. 소개 코드 입력이나 확인 버튼은 필요하지 않습니다.

예제의 YOUR_CODEFLUENCE_HOST와 CODEFLUENCE_BASE_URL을 실제 실행 중인 Codefluence 주소로 바꾸세요. 네이티브 SDK의 apiHost는 경로·query·fragment·사용자정보가 없는 HTTP(S) origin이어야 합니다. 로컬 서버는 http://localhost:3001이며 운영 주소는 HTTPS를 사용합니다. SDK는 현재 저장소의 로컬 소스로 설치하며 공개 패키지로 배포되었다는 뜻은 아닙니다.

키 발급과 연동 상태 확인은 제품 설정에서 합니다.

1. 제품과 키 준비

제품 화면에서 소유 검증, 캠페인과 소개 링크, API 키를 준비합니다. 실제 캠페인 활성화에는 연동 상태와 청구 정보 확인도 필요합니다.

앱은 마케팅 사이트 없이 등록할 수 있습니다. 사이트를 등록했다면 검증 방식에서 DNS TXT·meta 태그·well-known 파일을 선택해 추가로 확인할 수 있습니다. 어떤 방식을 선택해도 앱의 스토어 URL과 서명이 확인된 스토어 결제 알림은 필요하며, 사이트 소유 증명으로 대신할 수 없습니다.

키사용 위치허용 작업
pk_…웹 스크립트·앱클릭·귀속 설정 조회·서명된 앱 소개 확인
sk_test_…제품 서버테스트 lead·sale·refund. 항상 sandbox
sk_live_…제품 서버운영 lead·sale·refund. sandbox: true면 테스트

비밀 키는 브라우저, 모바일 바이너리, 공개 환경 변수에 넣지 않습니다. pk 자체는 테스트 키가 아닙니다. 앱 확인의 환경은 서명된 clickToken이 가리키는 클릭의 sandbox를 계승합니다. 운영·테스트 invoice와 고객 귀속은 분리되며 sandbox 거래는 청구·지급에서 제외됩니다.

중복 방지 키. /track/sale·/track/lead·/track/refund·/track/confirm과 청구 발행·입금·이체 완료 API는 선택적 Idempotency-Key 헤더(1~255자 출력 가능 ASCII)를 받습니다. 같은 범위(제품·환경 또는 요청 사용자)에서 같은 키·같은 본문으로 재시도하면 업무 함수를 다시 실행하지 않고 첫 2xx 응답을 그대로 재생하며 응답에 Idempotent-Replayed: true가 붙습니다. 같은 키에 다른 본문을 보내면 409 idempotency_key_reused, 형식이 잘못된 키는 400 invalid_idempotency_key입니다. 2xx 응답만 저장하므로 4xx·5xx·환율 보류 같은 재시도 가능한 오류는 다시 실행됩니다. 저장 기록은 기본 7일 뒤 정리됩니다. 네트워크 타임아웃 재시도에 같은 키를 사용하면 중복 전환·보상·청구를 막을 수 있습니다.

2. 웹에 스크립트 설치

제품의 동의 절차가 요구하는 동의를 받은 뒤 스크립트를 삽입합니다. /s.js는 Codefluence 서버가 제공하는 기본(코어) 설치 파일로, gzip 1KB 이하로 유지됩니다. outbound 링크 꾸미기·공개 설정 API 조회·data-cookie-options 같은 선택 기능이 필요하면 아래의 변형 스크립트 /s.outbound.js를 대신 사용합니다.

<script defer
  src="https://YOUR_CODEFLUENCE_HOST/s.js"
  data-public-key="pk_..."
  data-api-host="https://YOUR_CODEFLUENCE_HOST">
</script>

data-api-host를 생략하면 script의 출처 주소를 사용합니다. 코어는 기본 last-click이며 명시적으로 선택하려면 data-attribution-model="first_click" 또는 "last_click"을 추가합니다. 코어는 공개 설정 API를 조회하지 않으므로 제품 기본값을 따르려면 변형 스크립트를 사용합니다.

소개 링크로 전달된 cf_id를 우선 사용하고, 없으면 ?via=소개링크코드로 클릭을 기록합니다. 성공한 raw clickId를 cf_id 쿠키(기본 Path=/, SameSite=Lax, HTTPS면 Secure)에 90일 저장합니다. 첫 lead가 확정되면 뒤의 클릭이나 다른 이벤트 이름으로 크리에이터를 바꾸거나 가입 기준 유효기간을 늘릴 수 없습니다.

허용 출처는 제품에 검증된 웹사이트의 정확한 origin입니다. 다른 서브도메인·포트·프로토콜은 자동 허용하지 않습니다.

outbound 링크 꾸미기·설정 조회 변형 (/s.outbound.js)
<script defer
  src="https://YOUR_CODEFLUENCE_HOST/s.outbound.js"
  data-public-key="pk_..."
  data-api-host="https://YOUR_CODEFLUENCE_HOST"
  data-cookie-options='{"sameSite":"Lax"}'
  data-domains="checkout.owned.example">
</script>

변형은 코어의 모든 동작에 더해 공개 설정 API에서 제품의 first-click·last-click 기본값을 읽고, data-cookie-options(domain/path/sameSite/secure)와 data-domains를 지원합니다. data-domains는 cf_id를 query로 넘길 제품 소유 호스트 목록이며 기본값은 전달 없음입니다. 예제의 체크아웃 호스트를 실제 소유한 호스트로 바꾸거나 속성을 지우세요. 코어 /s.js에는 이 forwarding 코드가 포함되지 않습니다.

React 프로젝트에서 사용하기
import { Codefluence } from '@/packages/script/react';

<Codefluence apiHost="https://YOUR_CODEFLUENCE_HOST"
  publicKey="pk_..." consent={consentGranted} />

React 래퍼는 코어와 변형 기능을 모두 포함한 모듈을 번들에 직접 담으므로 코어 /s.js의 크기 예산과는 무관합니다. 위 경로는 이 저장소의 로컬 모듈입니다. 소스를 가져온 호스트 프로젝트에서는 실제 경로에 맞춥니다.

data-consent="false" 또는 React consent=false이면 쿠키·네트워크 요청·링크 변경을 하나도 만들지 않습니다. HTML 속성만 나중에 바꾸면 재초기화되지 않으므로 동의 뒤 script를 삽입하거나 React prop을 사용하세요. codefluence:attribution 이벤트의 detail.status에서 stored·preserved·ignored·failed 결과를 확인할 수 있습니다.

3. 가입이 완료되면 lead 보내기

인증된 제품 서버에서 가입이 완료된 회원을 기록합니다. 매 방문마다 새 고객 ID를 만들지 않고 가입과 결제에 같은 안정적인 ID를 사용합니다. 쿠키가 없거나 소개 연결이 거절되면 귀속을 추측하지 않습니다.

// 제품 서버: 인증한 회원의 실제 가입 완료 이후 실행합니다.
// cfClickId는 cf_id 쿠키에서 읽어 가입 요청에 전달한 값입니다.
const response = await fetch(process.env.CODEFLUENCE_BASE_URL + "/track/lead", {
  method: "POST",
  headers: {
    Authorization: "Bearer " + process.env.CODEFLUENCE_SECRET_KEY,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    customerExternalId: customer.id,
    eventName: "signup",
    clickId: cfClickId || undefined,
  }),
});
if (!response.ok) throw new Error("lead delivery failed: " + response.status);
Next.js App Router 가입 처리 예제
import { cookies } from "next/headers";
import { createCustomerWithReferralJob } from "@/server/signup";

// Next.js App Router의 인증된 가입 처리 안에 넣는 부분입니다.
// verifiedIdentity는 제품의 인증 절차가 확인한 사용자입니다.
const clickId = (await cookies()).get("cf_id")?.value;
const customer = await createCustomerWithReferralJob(verifiedIdentity, {
  eventName: "signup", clickId: clickId || undefined,
});
// 제품 DB의 같은 transaction에서 회원과 전달 작업을 저장합니다.
// 작업의 customerExternalId에는 서버가 확정한 결제 고객 ID를 넣습니다.
Express 가입 처리 예제
import cookieParser from "cookie-parser";
app.use(cookieParser());

// 인증 미들웨어와 회원/outbox 저장 함수는 제품의 기존 구현입니다.
app.post("/signup", verifySignupIdentity, async (req, res, next) => {
  try {
    const customer = await createCustomerWithReferralJob(req.verifiedIdentity, {
      eventName: "signup", clickId: req.cookies.cf_id || undefined,
    });
    res.status(201).json({ customerId: customer.id });
  } catch (error) { next(error); }
});
// outbox worker가 위 /track/lead 예제대로 전송합니다.

두 예제의 인증 미들웨어와 createCustomerWithReferralJob은 제품이 구현할 연결 지점입니다. 회원 생성과 Codefluence 전달 작업을 같은 DB transaction에 저장하고, 작업자가 위 HTTP 예제를 실행하도록 구성하세요. 브라우저가 보낸 고객 ID로 기존 고객을 바꾸지 않습니다. 쿠키 값은 귀속 후보이며, Codefluence 서버가 실제 클릭과 유효기간을 검증합니다. 전송 실패로 이미 완료된 가입을 다시 만들지 않고 같은 작업을 재시도합니다.

CODEFLUENCE_SECRET_KEY에는 테스트 중 sk_test_…를 설정합니다. Stripe 자동 웹훅을 사용한다면 customer.id를 Stripe customer ID와 연결하세요.

클릭→lead 허용 창은 기본 30일이고 lead→sale 귀속은 기본 12개월입니다. 먼저 기록된 고객 이력으로 기존 고객임을 확인할 수 있으면 새 소개 보상을 만들지 않습니다. 이벤트 이름은 고객 귀속을 다시 시작하는 수단이 아닙니다.

Dub SDK 호환 범위
import { Dub } from "dub"; // 검증 버전: 0.73.5

// 서버 전용. 공식 TypeScript SDK의 주소 옵션은 serverURL입니다.
const codefluence = new Dub({
  token: process.env.CODEFLUENCE_SECRET_KEY,
  serverURL: process.env.CODEFLUENCE_BASE_URL,
});
await codefluence.track.lead({
  customerExternalId: customer.id, eventName: "Signup",
  clickId: savedReferralClickId || undefined,
});
// 제품 서버에서 실제 결제를 확인한 뒤 전송합니다.
await codefluence.track.sale({
  customerExternalId: order.customerId,
  invoiceId: order.providerInvoiceId, // Codefluence에서는 필수
  amount: order.amountMinor, currency: order.currency,
  paymentProcessor: "stripe",
});

공식 TypeScript dub@0.73.5, Python dub==0.38.6, Go dub-go v0.23.19의 lead·sale 요청 직렬화와 성공·오류 응답을 로컬 계약 검사에 사용합니다. Python의 서버 주소 옵션은 server_url, Go는 WithServerURL입니다. 실제 제품 서버나 Dub 서비스로 호출한 결과는 아닙니다. SDK 버전을 바꿀 때 계약을 다시 확인하세요.

Codefluence의 sale은 중복 청구를 막기 위해 비어 있지 않은 invoiceId가 필수입니다. mode는 생략·async·wait를 허용하고 모두 처리가 끝난 뒤 응답합니다. 별도 비동기 대기열과 deferred 모드는 지원하지 않습니다. eventQuantity는 1~100 사이 정수이며 생략·null은 1입니다. 범위를 벗어나거나 정수가 아니면 저장 전에 400 오류로 거절합니다.

같은 제품·고객·이벤트 이름·테스트 여부의 재전송은 최초 수량을 유지합니다. 예를 들어 eventQuantity: 5인 가입 이벤트를 재전송해도 가입자 1명·이벤트 5건입니다. 다른 이벤트 이름을 기록하면 이벤트 수만 추가되며 고객 귀속·유효기간·결제·보상 금액은 늘어나지 않습니다.

고객 이름·이메일·아바타·임의 metadata는 저장하거나 응답에 복사하지 않습니다. lead의 개인정보·link와 sale의 customer·metadata는 null이며, 연결된 원클릭이 없으면 click.id는 빈 문자열입니다. 이를 귀속 성공으로 판단하지 마세요. sale의 금액·통화·invoiceId는 저장된 거래 값이고, 응답의 eventName·paymentProcessor는 요청의 표시값이므로 결제 증빙이 아닙니다. 같은 invoice 재전송으로 원거래 금액이나 보상이 바뀌지 않습니다.

보관 기간이 끝난 sandbox 거래의 재전송은 sale: null로 응답하며 새 거래를 만들지 않습니다. 오류는 error.code·error.message로 읽습니다. 직접 HTTP 호출에는 Codefluence의 leadId·conversionId·deduplicated 확장값도 제공되지만 공식 SDK의 반환 타입에는 포함되지 않습니다. 토스 전용 값은 위 HTTP 예제를 사용하세요.

4. 확인된 결제만 sale로 기록

Stripe Checkout + 인증된 웹훅

제품 서버에서 Checkout을 만들고 Codefluence 웹훅을 등록하면 결제 완료 이벤트가 sale을 만듭니다. 자동 웹훅과 별도 sale API를 함께 쓴다면 동일한 provider invoice ID로 중복을 피하세요.

// 제품 서버: Stripe SDK와 저장된 회원·상품을 사용합니다.
// signup lead의 customerExternalId도 user.stripeCustomerId로 연결합니다.
const checkout = await stripe.checkout.sessions.create({
  mode: "subscription",
  customer: user.stripeCustomerId,
  client_reference_id: savedReferralClickId || undefined,
  line_items: [{ price: process.env.STRIPE_PRICE_ID, quantity: 1 }],
  success_url: appOrigin + "/billing/success?session_id={CHECKOUT_SESSION_ID}",
  cancel_url: appOrigin + "/billing",
});
// checkout.url로 이동합니다. success_url 도착만으로 sale을 보내지 않습니다.

checkout.session.completed의 paid 상태와 invoice.paid의 양수 결제를 처리합니다. 구독의 첫 Checkout에 invoice가 있다면 해당 ID로 두 이벤트를 묶습니다. 소개 보상 후보는 서명된 customer.created 또는 서명된 expanded customer의 생성 시각으로 신규 고객 여부를 확인합니다. 고객 생성 증거가 먼저 도착하도록 아래 웹훅 이벤트를 함께 등록하세요.

토스: 제품 서버에서 승인 후 sale 전송

토스 자동 웹훅 어댑터 대신 제품 서버가 토스 결과를 확인한 뒤 /track/sale을 호출합니다. 결제 성공 화면이나 클라이언트 금액만으로 전환을 만들지 않습니다.

// 제품 서버: 인증한 주문을 DB에서 읽고 토스 서버에 확인합니다.
// 주문 ID·금액·고객 ID는 저장된 주문 값이어야 합니다.
const verified = await fetch("https://api.tosspayments.com/v1/payments/confirm", {
  method: "POST",
  headers: {
    Authorization: "Basic " + Buffer.from(process.env.TOSS_SECRET_KEY + ":").toString("base64"),
    "Content-Type": "application/json",
  },
  body: JSON.stringify({ paymentKey, orderId: order.id, amount: order.amountKrw }),
});
if (!verified.ok) throw new Error("payment confirmation failed");
const payment = await verified.json();
if (payment.status !== "DONE" || payment.orderId !== order.id ||
    payment.totalAmount !== order.amountKrw) throw new Error("payment mismatch");

// 확인된 결과를 저장하고 sale 전달을 내구성 있는 서버 작업으로 재시도합니다.
const result = await fetch(process.env.CODEFLUENCE_BASE_URL + "/track/sale", {
  method: "POST",
  headers: {
    Authorization: "Bearer " + process.env.CODEFLUENCE_SECRET_KEY,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    customerExternalId: order.customerId,
    invoiceId: "toss:" + order.id,
    amount: order.amountKrw, currency: "KRW", paymentProcessor: "toss",
  }),
});
if (!result.ok) throw new Error("sale delivery failed: " + result.status);

테스트에는 토스 테스트 키와 sk_test를 함께 사용합니다. sale 전달 실패 시 이미 승인된 결제를 다시 승인하지 말고 저장된 결제 결과로 Codefluence 전달만 재시도하세요.

amount는 통화의 최소 단위 정수입니다. KRW·JPY는 1원·1엔, USD·EUR는 센트입니다. 서버의 당일 환율을 사용하고 클라이언트 환율은 신뢰하지 않습니다. 환율을 확보하지 못하면 거래를 거절하므로 응답 코드 확인과 재전송 준비가 필요합니다.

5. iOS: 로컬 Swift Package

Xcode의 Add Local Package에서 packages/sdk-ios를 선택하고 앱 타깃에 Codefluence를 연결합니다. iOS 16 이상, Swift 5.9 Package 계약입니다. 다른 Swift Package에서는 .package(path: "../codefluence/packages/sdk-ios")와 product Codefluence를 사용합니다.

import Codefluence

Codefluence.configure(
    publicKey: "pk_...",
    apiHost: URL(string: "https://YOUR_CODEFLUENCE_HOST")!
)
// 최초 확인 사용자 ID를 저장하고 재실행 뒤 재시도에도 그대로 사용합니다.
// 앱이 쓰는 안정적인 사용자 ID를 넣습니다(임의 이메일·기기 ID 금지).
let referralCustomerId = stableAppUserId
// 앱 시작 또는 딥링크 수신 후 호출합니다.
Codefluence.shared.autoConfirmIfAvailable(
    appUserId: referralCustomerId
) { result in
    // success(nil): 토큰 없음. 실패 시 pending 요청만 재시도합니다.
}

// 별도 재시도 동작입니다. 최초 referralCustomerId를 복원해 사용합니다.
Codefluence.shared.retryPendingConfirmation { _ in }

앱 시작 또는 딥링크 수신 뒤 자동 귀속을 호출합니다. 유효한 토큰이 있으면 확인창 없이 공개 키로 /track/confirm을 보냅니다. 토큰이 없으면 무귀속으로 시작하며 같은 설치에서 새 귀속을 만들지 않습니다. 기존 명시 동의 UX가 필요하면 showConfirmationIfNeeded를 사용할 수 있습니다.

현재 SDK는 저장된 토큰과 iOS 클립보드를 복원합니다. URL scheme은 앱의 URL Types에 등록하며 myapp://referral?cf_click_token=…을 받는 코드는 호스트 앱이 작성해야 합니다. SDK가 scheme을 자동 등록하거나 URL을 자동 처리하지 않습니다. 받은 토큰을 autoConfirmIfAvailable(appUserId:clickToken:completion:)에 전달하면 바로 귀속합니다.

호출 순서에 주의하세요. SDK는 토큰 없이 처음 호출되면 그 설치를 "소개 없음"으로 영구 저장합니다. 소개 랜딩에서 앱이 처음 실행될 때는 딥링크(onOpenURL)가 첫 화면의 작업보다 늦게 도착할 수 있으므로, 딥링크로 받은 토큰을 먼저 전달하고 토큰 없는 호출은 딥링크가 없을 때만(예: 실행 후 잠시 기다린 뒤) 하세요. appUserId에는 앱이 쓰는 안정적인 사용자 ID를 넣고, 로그인·계정 전환 뒤에도 같은 기준을 유지하세요.

6. Android: 로컬 Gradle 모듈 또는 AAR

SDK 소스를 호스트 프로젝트에 복사하거나 로컬 AAR를 넣습니다. Maven 좌표로 게시된 패키지는 없습니다. minSdk 23, compileSdk 35, Kotlin 2.0.21·Android Gradle Plugin 8.7.3 기준입니다.

// settings.gradle.kts: SDK 소스를 호스트 프로젝트 vendor에 둔 경우
include(":codefluence-sdk")
project(":codefluence-sdk").projectDir =
    file("vendor/codefluence/packages/sdk-android")

// app/build.gradle.kts
dependencies { implementation(project(":codefluence-sdk")) }

// 또는 standalone assembleRelease 산출물을 app/libs에 복사합니다.
// implementation(files("libs/codefluence-sdk-android-release.aar"))
import com.codefluence.sdk.Codefluence

val codefluence = Codefluence.configure(
    applicationContext, "pk_...", "https://YOUR_CODEFLUENCE_HOST"
)
// 최초 확인 사용자 ID를 저장하고 재실행 뒤 재시도에도 그대로 사용합니다.
// 앱이 쓰는 안정적인 사용자 ID를 넣습니다(임의 이메일·기기 ID 금지).
val referralCustomerId = stableAppUserId
// 앱 시작 또는 딥링크 수신 후 호출합니다. Play Install Referrer는 자동 조회합니다.
codefluence.autoConfirmIfAvailable(
    appUserId = referralCustomerId,
    onResult = { }
)
// 같은 사용자에서 실패한 확인을 재시도하는 별도 동작:
codefluence.retryPendingConfirmation { }

SDK manifest의 INTERNET 권한이 앱에 병합됩니다. 저장된 토큰이 없으면 공식 Play Install Referrer 클라이언트로 조회하고, 2초 timeout·조회 실패·토큰 없음이면 클립보드로 복원합니다. 호스트에서 이미 조회한 문자열은 선택적인 installReferrer 인자로 전달할 수 있습니다. cf_click_token 또는 codefluence_click_token query를 읽습니다. 로컬 AAR 설치에는 com.android.installreferrer:installreferrer:2.2 의존성도 호스트에 추가합니다.

URL scheme을 쓰면 호스트 AndroidManifest에 VIEW·DEFAULT·BROWSABLE intent-filter를 등록하고 Intent의 cf_click_token을 autoConfirmIfAvailable에 전달합니다. 저장된 토큰이 먼저 사용되며 소개 코드 입력 화면이나 확인 시트는 없습니다. 기존 명시 동의 UX가 필요하면 현재 Activity와 함께 showConfirmationIfNeeded를 사용할 수 있습니다.

7. 결제 웹훅 인증과 고객 ID

  • Stripe: https://YOUR_CODEFLUENCE_HOST/webhooks/stripe/PRODUCT_ID를 등록합니다. 제품 연동 설정에 해당 endpoint의 whsec_… signing secret을 저장합니다. 서명과 시간 검증을 통과해야 처리됩니다.
  • Stripe 이벤트: customer.created, checkout.session.completed, invoice.paid, charge.refunded, charge.dispute.created. 고객 생성 증거에는 서명된 customer object의 id·created·livemode를 검증하며 제품·환경별로 분리합니다.

Stripe 소개 보상 후보에 고객 생성 증거가 없으면 409 stripe_customer_evidence_required, 기존 lead의 클릭 시각 증거가 없으면 409 stripe_referral_time_required를 반환합니다. event claim은 해제되므로 증거 수신 뒤 같은 결제 이벤트를 다시 전달할 수 있습니다. 고객이 클릭 전에 생성되었다면 기존 고객으로 제외합니다. 고객 생성은 초 단위여서 클릭과 같은 초일 경우 순서를 추측하지 않고 200으로 기록하되 unattributed·stripe_customer_time_ambiguous 사유로 보상을 제외합니다.

앱의 소개 확인 ID(appUserId)는 앱이 쓰는 안정적인 사용자 ID로 유지하세요. 로그인·계정 전환 뒤에도 같은 기준을 쓰며 임의 이메일·기기 ID를 대신 쓰지 않습니다. 앱의 구매 성공 콜백만으로 매출을 확정하지 않습니다.

8. 스토어 직접 연결

앱의 App Store·Google Play 결제를 직접 확인해 같은 장부에 기록합니다. 앱이 구매를 보고하면 Codefluence 서버가 스토어에 다시 확인한 뒤에만 결제로 기록하고, 매일 스토어의 거래·환불 기록과 대조합니다.

App Store

  1. App Store Connect → 사용자 및 액세스 → 통합 → 인앱 구매에서 키를 새로 만들고 .p8 파일을 내려받습니다. 키 ID와 같은 화면의 발급자 ID(Issuer ID)를 적어 둡니다. 다른 용도의 키와 따로 만들면 이 키만 언제든 폐기할 수 있습니다.
  2. 제품 화면 → 결제 조회 키에서 출처를 「App Store 직접」으로 고르고 키 파일, 키 ID, 발급자 ID, 번들 ID, Apple 앱 ID(App Store 주소의 id 뒤 숫자)를 넣습니다.
  3. 운영 키는 운영 거래만, 샌드박스 키는 TestFlight·Xcode 테스트 거래만 받습니다. 다른 앱이나 다른 환경의 거래는 거부합니다.
import StoreKit
import Codefluence

// 구매할 때 SDK의 구매 계정 토큰을 appAccountToken으로 넘깁니다.
var options: Set<Product.PurchaseOption> = []
if let token = UUID(uuidString: Codefluence.shared.purchaseAccountToken()) {
  options.insert(.appAccountToken(token))
}
let result = try await product.purchase(options: options)
if case .success(.verified(let transaction)) = result {
  Codefluence.shared.reportPurchase(transaction: transaction)
  await transaction.finish()
}

Google Play

  1. Google Cloud에서 서비스 계정을 만들고 키(JSON)를 내려받은 뒤, 그 프로젝트에서 Google Play Android Developer API를 켭니다.
  2. Play Console → 사용자 및 권한에서 서비스 계정 이메일을 초대하고 재무 데이터 보기 권한을 줍니다. 권한이 반영되기까지 최대 하루가 걸릴 수 있습니다.
  3. 제품 화면 → 결제 조회 키에서 「Google Play 직접」을 고르고 패키지 이름과 JSON 파일을 올립니다.
// 결제 화면을 열 때 SDK의 구매 계정 토큰을 obfuscatedAccountId로 넘깁니다.
val params = BillingFlowParams.newBuilder()
  .setProductDetailsParamsList(listOf(productParams))
  .apply { setObfuscatedAccountId(codefluence.purchaseAccountToken()) }
  .build()
billingClient.launchBillingFlow(activity, params)

// PurchasesUpdatedListener에서 구매가 PURCHASED이면 보고합니다.
codefluence.reportPurchase(purchase, isSubscription = true)

구독이면 isSubscription = true를 넘겨야 구독 기록으로 확인합니다. 테스트 결제(라이선스 테스터)는 테스트 거래로만 기록합니다.

소개와 결제가 이어지는 방식

SDK가 소개를 확인할 때 이 설치의 구매 계정 토큰을 함께 등록합니다. 구매 때 같은 토큰을 iOS appAccountToken, Android obfuscatedAccountId로 넘겨야 결제가 그 소개에 연결됩니다. 토큰 없이 들어온 결제는 기록하지만 보상하지 않습니다.

알림 연결(선택)

앱이 꺼져 보고하지 못한 결제와 환불은 매일 대조로 채웁니다. 더 빨리 반영하려면 App Store 서버 알림 주소에 https://YOUR_CODEFLUENCE_HOST/webhooks/app-store/PRODUCT_ID를, Google Play 실시간 개발자 알림의 Pub/Sub 푸시 구독에 https://YOUR_CODEFLUENCE_HOST/webhooks/google-play/PRODUCT_ID를 넣습니다. App Store는 앱마다 알림 주소를 하나만 받으므로, 이미 자체 서버로 받고 있다면 그 서버가 받은 signedPayload를 그대로 이 주소로 전달하세요. Apple 서명을 다시 검증하므로 내용을 바꾸면 거부됩니다. Google Pub/Sub 푸시 구독은 인증을 켜고 대상(audience)에 Codefluence가 안내한 값을 넣어야 합니다. 인증 토큰이 없는 푸시는 거부합니다.

9. 재시도와 테스트 결과 확인

curl -X POST "$CODEFLUENCE_BASE_URL/track/sale" \
  -H "Authorization: Bearer $CODEFLUENCE_TEST_KEY" \
  -H "Content-Type: application/json" \
  -d '{"customerExternalId":"test-customer","invoiceId":"test-order-001","amount":10000,"currency":"KRW"}'

환경 변수에 실제 실행 주소와 sk_test를 넣습니다. 같은 제품·환경·invoiceId로 sale을 재전송하면 기존 전환을 반환합니다. lead는 제품·환경·고객·이벤트 이름으로 중복을 처리하며 서로 다른 이벤트도 처음 고객 귀속을 유지합니다. 웹훅은 provider event ID로 중복을 처리합니다.

서버 전달은 타임아웃·429·5xx에 backoff와 jitter를 적용하고 같은 ID로 재시도하세요. 401·403은 키·서명·origin 설정을 먼저 바로잡습니다. 결제 연결 409는 원거래 수신 후 재시도합니다. 환율 422는 통화·당일 환율을 확인한 뒤 같은 거래를 전달합니다. /track/refund에서 amount를 생략하면 전체 환불이고, 전달하면 환불 차액이므로 부분 환불 재시도는 제품 서버의 별도 중복 방지가 필요합니다.

네이티브 SDK는 예·아니오 결정을 구분해 저장하고, 예 이후 미완료 요청의 토큰·고객 ID를 보존합니다. 두 SDK의 retryPendingConfirmation은 동의한 미완료 요청만 동일한 값으로 재시도하며 시트를 다시 띄우지 않습니다. 거절·이미 완료·대기 요청 없음이면 서버 요청 없이 실패 결과를 반환합니다. 자동 backoff는 제공하지 않으며 거절한 사용자를 우회하지 않습니다.

제품의 최근 이벤트와 공동 장부에서 sandbox·전환·환불을 확인합니다. sandbox 원본은 30일 뒤 정리되며 동일한 삭제 거래의 재생성은 receipt로 막습니다. 모두 삭제된 테스트 고객의 새 invoice는 새 실험 이력이 되고 삭제된 원거래의 새 환불 이벤트는 연결을 찾지 못할 수 있습니다.

이 문서는 저장소 구현 계약입니다. 로컬 빌드·API 테스트는 SDK 공개 배포, 실제 설치 복원, 기기 확인 시트, 실제 Stripe·토스 구매 검증을 대신하지 않습니다.

10. 임베디드 레퍼럴 실적(선택)

제품 안에서 크리에이터가 자신의 소개 실적과 링크를 바로 볼 수 있게 하려면, 제품 서버가 서버 키로 단기 임베드 토큰을 발급하고 그 토큰으로 /embed를 iframe에 띄웁니다. 토큰은 10분 만료이고 도메인 분리된 서명을 사용하며, 운영에서 서명 키가 없으면 503입니다. 토큰에는 크리에이터·본문 같은 개인정보가 들어가지 않습니다.

// 제품 서버 전용: 서버 키로 크리에이터 본인용 단기 임베드 토큰(10분)을 발급합니다.
// 공개 키(pk_)는 거부되고, 이 제품 캠페인에 승인 참여가 없는 크리에이터는 404입니다.
const res = await fetch(process.env.CODEFLUENCE_BASE_URL + "/api/v1/embed-tokens", {
  method: "POST",
  headers: {
    Authorization: "Bearer " + process.env.CODEFLUENCE_SECRET_KEY, // sk_live_ 또는 sk_test_
    "Content-Type": "application/json",
  },
  body: JSON.stringify({ creatorId: participation.creatorId }),
});
const { token } = await res.json();
// 토큰은 서버에서만 만들고 로그에 남기지 않습니다. 브라우저로는 완성된 iframe만 내려보냅니다.
<!-- 소유 검증된 제품 웹 origin에서만 임베드할 수 있습니다(frame-ancestors). -->
<iframe
  src="https://YOUR_CODEFLUENCE_HOST/embed?token=SERVER_ISSUED_TOKEN"
  title="내 소개 실적"
  style="width:100%;max-width:640px;height:420px;border:0"
  referrerpolicy="no-referrer"
></iframe>

임베드 페이지는 토큰의 서명·만료·제품·승인 참여를 검증한 뒤 그 크리에이터의 그 제품 캠페인 실적(클릭·가입·결제·보류/확정 보상)과 본인 링크만, sandbox와 live를 분리해 보여 줍니다. 응답은 Cache-Control: private, no-store, Referrer-Policy: no-referrer이며, frame-ancestors는 소유 검증된 제품 웹 origin만 허용합니다(마케팅 사이트가 없는 앱 제품은 'none'이라 임베드할 수 없습니다). 만료·범위 밖·위조 토큰은 정보 없는 오류 화면을 반환합니다.

11. 구독 상태 조회(읽기 전용)

결제 웹훅·스토어 직접 연결·대조로 기록된 구독 생명주기 이벤트를 정규화해 현재 상태만 읽어 갑니다. 이 API는 entitlement(권한)을 판정하지 않습니다. 결제·장부는 전혀 바뀌지 않고 이벤트만 기록됩니다. 상태는 none·active·grace·cancelled·expired·refunded이며, 응답에 금액·개인정보는 담기지 않습니다. 모든 응답은 Cache-Control: private, no-store입니다.

이벤트 정규화: Stripe는 checkout·첫 invoice→started, subscription_cycle→renewed, customer.subscription.deleted→expired, charge.refunded→refunded, invoice.payment_failed→grace.

제품 서버(서버 키). GET /api/v1/subscriptions/{customerExternalId}에 Authorization: Bearer sk_live_… 또는 sk_test_…를 보냅니다. 환경은 키 종류로 정해집니다(sk_test는 sandbox). 200 응답: { customerExternalId, state, sandbox, updatedAt, events: [{ type, occurredAt, source }] }. 이벤트가 없는 고객도 state: 'none'으로 200입니다.

앱(공개 키). SDK는 POST /track/subscription에 Authorization: Bearer pk_…와 본문 { leadId, purchaseAccountToken }를 보냅니다. lead의 purchaseAccountToken과 timing-safe 비교로 일치할 때만 200 { state, sandbox, updatedAt, events }를 돌려줍니다. 토큰이 다르거나 lead가 없으면 다른 lead를 탐색할 수 없도록 404 { error: 'subscription_not_found' }입니다. lead는 있으나 이벤트가 없으면 state: 'none'으로 200입니다.