먼저 알아야 할 모델
Pulse의 모든 데이터는 프로젝트 → 앱 → 환경 세 층으로 갈립니다. 이걸 먼저 이해하지 않으면 인증은 통과하는데 데이터가 안 보이는 상황을 만납니다.
그리고 사람을 가리키는 값이 두 개입니다. 이 구분이 연동 실수의 절반을 차지합니다.
| 값 | 누가 만드나 | 설명 |
|---|---|---|
externalCustomerId | 여러분이 보냅니다 | 다날 회원번호처럼 서비스가 이미 가진 식별자입니다. Pulse는 이 값을 앱 네임스페이스로 HMAC 토큰화한 뒤에 저장하며, 원문은 저장하지 않습니다. |
personId | Pulse가 만듭니다 | per_로 시작하는 가명 ID입니다. 응답으로 돌려받습니다. 콘솔에 보이는 것도 이 값입니다. |
로그인 전 사용자는 externalCustomerId 없이 anonymousId만 보내고, 로그인 시점에 externalCustomerId를 붙여 보내면 Pulse가 두 흐름을 같은 personId로 잇습니다.
인증과 공통 헤더
표면이 두 개입니다. 섞어 쓰면 401이 납니다.
수집 표면 — /collect/**
앱과 서버가 데이터를 보내는 경로입니다. 앱별로 발급한 수집 키를 씁니다.
| 헤더 | 필수 | 값 |
|---|---|---|
X-Pulse-Api-Key | 필수 | 발급받은 수집 키 원문 |
X-Pulse-App-Id | 필수 | 그 키를 발급받은 앱의 app_… ID. 키와 앱이 짝이 맞아야 통과합니다. |
X-Pulse-Environment | 필수 | DEVELOPMENT / STAGING / PRODUCTION |
Content-Type | 필수 | application/json; charset=utf-8 |
APK/IPA에 넣은 문자열은 추출됩니다. 키는 여러분의 서버가 보관하고, 앱은 여러분의 서버를 거쳐 보내세요. 키는 발급 시 원문이 한 번만 노출되며 Pulse는 SHA-256 해시만 보관합니다.
관리 표면 — /api/v1/**
콘솔이 쓰는 경로입니다. 운영에서는 다날 SSO 세션으로만 접근하며, 개발 환경에서만 Basic 인증이 허용됩니다.
| 헤더 | 설명 |
|---|---|
X-Pulse-Project-Id | 대상 프로젝트 |
X-Pulse-Environment | 대상 환경 |
Origin | 변경 요청에 필요합니다 |
X-Change-Reason | 변경 요청에 필요합니다. 5~500자 ASCII, 티켓 번호 권장 |
If-Match | 버전이 있는 리소스를 바꿀 때 "<id>:<resourceVersion>" 형식. 없으면 428이 돌아옵니다. |
공통 규약
| 항목 | 규칙 |
|---|---|
| 시각 | 모두 RFC 3339 UTC입니다. 2026-08-03T00:12:34Z. 타임존 없는 문자열은 거절됩니다. |
| 문자 인코딩 | UTF-8 고정입니다. 한글을 셸에서 인라인으로 넘기면 깨져서 400 Invalid UTF-8 start byte가 납니다. 파일로 보내세요. |
| 멱등성 | 이벤트는 eventId, 인앱 반응은 clientEventId, 설치 리퍼러는 clientEventId, 삭제 신호는 clientSignalId가 중복 판정 키입니다. 같은 키로 다시 보내도 두 번 적재되지 않습니다. 재시도는 안전합니다. |
| 배치 크기 | /collect/v1/batch는 1회 1~500건입니다. |
| 부분 성공 | 배치는 건별로 판정됩니다. 일부가 거절돼도 HTTP는 202입니다. HTTP 상태만 보고 성공으로 처리하면 안 됩니다. |
| 앱 상태 | 앱이 ACTIVE가 아니면 수집이 거절됩니다(APP_NOT_COLLECTING). |
SDK 내려받기
플랫폼별 SDK를 ZIP으로 내려받을 수 있습니다. 각 아카이브에는 소스와 빌드 파일, 그리고 버전·요구 사항을 적은 README-DOWNLOAD.txt가 들어 있습니다. 빌드 산출물과 의존성 디렉터리(build/, node_modules/, .gradle/, dist/)는 제외되어 있으니 각 플랫폼의 방식대로 빌드하세요.
| 플랫폼 | 파일 | 버전 | 요구 사항 | 크기 | SHA-256 |
|---|---|---|---|---|---|
| Android (Kotlin) | danal-pulse-android-sdk.zip | ||||
| iOS (Swift) | danal-pulse-ios-sdk.zip | ||||
| Flutter (Dart) | danal-pulse-flutter-sdk.zip | ||||
| TypeScript (Web · Server · React Native) | danal-pulse-typescript-sdk.zip | ||||
| 샘플 앱 (Web · React Native) | danal-pulse-samples.zip | ||||
전체 체크섬 목록은 SHA256SUMS.txt에 있습니다.
내려받은 파일 검증
# macOS / Linux
shasum -a 256 danal-pulse-android-sdk.zip
# Windows PowerShell
Get-FileHash danal-pulse-android-sdk.zip -Algorithm SHA256
# 위 표 또는 SHA256SUMS.txt 의 값과 일치해야 합니다.
ZIP은 커밋되어 있지 않고 pnpm sdk:package가 sdks/와 packages/pulse-sdk에서 그때그때 만듭니다. 커밋된 사본이었다면 SDK가 바뀌어도 아무 신호 없이 낡아갔을 것이고, 여러분은 지금 읽는 API와 다른 소스를 받게 됩니다.
생성은 결정적입니다 — 소스가 같으면 몇 번을 다시 만들어도 바이트가 같습니다. 그래서 SHA-256이 바뀌었다는 것은 SDK가 바뀌었다는 뜻이지 다시 만들었다는 뜻이 아닙니다.
당연한 이야기지만 확인차 적습니다. 키는 ① 수집 키 발급으로 여러분의 앱에 대해 직접 발급받아야 하고, 발급 응답에서 한 번만 볼 수 있습니다.
플랫폼별 SDK 연동
아래 API를 직접 호출해도 되지만, 대부분은 SDK를 쓰는 편이 낫습니다. SDK가 대신 처리하는 일이 적지 않습니다.
track() 한 줄입니다.어느 SDK를 쓰나
| 플랫폼 | SDK | 버전 | 요구 사항 | source |
|---|---|---|---|---|
| Android | Kotlin 네이티브 | 0.1.0 | Kotlin 2.4 · minSdk 26 · jvmTarget 17 | android |
| iOS | DanalPulse | 0.1.0 | Swift 6 · iOS 15+ | ios |
| Flutter | danal_pulse | 0.1.0 | Dart ≥3.4 <4.0 · Flutter ≥3.22 | android 또는 ios |
| Web | @danal/pulse-sdk | 0.1.0 | ES2022 | web |
| Server | @danal/pulse-sdk | 0.1.0 | Node 24 | server |
| React Native | @danal/pulse-sdk + 네이티브 | 0.1.0 | RN ≥0.76 <0.84 | android / ios |
온프레미스 제품이라 npm·Maven Central·CocoaPods Trunk 어디에도 올라가 있지 않습니다. TypeScript 패키지는 private: true이고, iOS podspec은 :path => '.'이며, Android는 저장소 안의 모듈입니다. 사내 저장소 또는 소스 동봉으로 받으세요. 아래 설치 예시는 그 전제로 적었습니다.
모든 SDK가 공통으로 지키는 것
| 동작 | 설명 |
|---|---|
| 이벤트 이름 검증 | ^[a-z][a-z0-9_]{1,119}$를 어기면 전송하지 않고 false를 반환합니다. 예외를 던지지 않으므로 반환값을 확인하세요. |
| 오프라인 큐 | 전송 실패 시 저장소에 쌓아두고 나중에 다시 보냅니다. 기본 상한은 10,000건 / 2MB이며 가장 오래된 것부터 버립니다. |
| 샘플링 예외 | samplingRate를 낮춰도 install·reinstall·uninstall·purchase·refund·subscribe·login은 절대 버리지 않습니다. 과금·어트리뷰션의 근거이기 때문입니다. |
| 재시도 안정성 | 샘플링 판정이 이벤트 ID에서 나오므로 재시도해도 결정이 뒤집히지 않습니다. |
| 저장소 주입 | SDK는 저장 방법을 고르지 않습니다. 호스트가 storage를 넘깁니다 — 암호화 여부를 앱이 결정하게 하기 위해서입니다. |
Android
설치
// settings.gradle.kts — 사내 저장소 또는 소스 포함
dependencyResolutionManagement {
repositories { maven { url = uri("https://maven.danal.internal/releases") } }
}
// app/build.gradle.kts
android {
defaultConfig { minSdk = 26 }
compileOptions { targetCompatibility = JavaVersion.VERSION_17 }
}
dependencies {
implementation("com.danal.pulse:pulse-sdk:0.1.0")
// Play 설치 리퍼러를 쓸 때만. 호스트가 명시적으로 옵트인할 때만 로드됩니다.
implementation("com.android.installreferrer:installreferrer:2.2")
}
초기화
import com.danal.pulse.sdk.*
private val prefs = context.getSharedPreferences("pulse", Context.MODE_PRIVATE)
val pulse = PulseClient(
PulseAndroidConfig(
endpoint = "https://collect.danal.internal",
projectId = "prj_danal",
appId = BuildConfig.PULSE_APP_ID,
apiKey = BuildConfig.PULSE_API_KEY, // 키를 앱에 심는다면 반드시 제한된 전용 키로
// 저장 방법은 호스트가 정합니다. 암호화가 필요하면 EncryptedSharedPreferences를 넘기세요.
storage = PulseStorage { value -> prefs.edit().putString("queue", value).apply() },
storageReader = PulseStorageReader { prefs.getString("queue", null) },
environment = PulseEnvironment.PRODUCTION,
flushIntervalSeconds = 10,
onError = { throwable -> Log.w("Pulse", throwable) },
)
)
사용
// 로그인 시점
pulse.identify("damoum-member-1")
// 이벤트 — 반환값이 false면 이름 검증에 걸렸거나 큐가 가득 찬 것입니다
val queued = pulse.track(
eventName = "purchase",
properties = mapOf("revenue" to 39000, "currency" to "KRW"),
consent = mapOf("analytics" to true, "marketing" to true),
)
// 인앱 메시지 — 응답 JSON 문자열을 그대로 돌려줍니다
pulse.pullInAppMessagesAsync(placement = "home", limit = 3).thenAccept { json -> render(json) }
pulse.trackInAppEngagementAsync(deliveryId, type = "IMPRESSION")
// 설치 리퍼러 — 첫 실행에서 한 번. Play 라이브러리를 여기서만 반사적으로 로드합니다.
// Activity 가 아니라 applicationContext 를 넘기세요.
pulse.collectGooglePlayInstallReferrerOnFirstOpen(context.applicationContext)
.thenAccept { result -> Log.d("Pulse", "referrer=$result") }
// 지연 딥링크
pulse.resolveDeferredDeepLink(clickId).thenAccept { result ->
if (result.resolved) navigate(result.deferredDeepLink) else navigateHome()
}
// 앱 종료 시 — AutoCloseable 입니다
pulse.close()
collectGooglePlayInstallReferrerOnFirstOpen은 설치 후 최초 1회만 부르세요. Play 라이브러리는 이 호출 시점에만 반사적으로 로드되므로, 리퍼러를 쓰지 않는 앱은 의존성을 넣지 않아도 됩니다.
track은 메인 스레드에서 불러도 됩니다 — 큐에 넣고 즉시 반환하며 전송은 내부 스케줄러가 합니다. 다만 updateProfile처럼 이름에 Async가 없는 메서드는 동기 호출이니 백그라운드에서 부르거나 …Async 변형을 쓰세요.
iOS
설치
// Swift Package Manager — Package.swift
dependencies: [
.package(url: "https://git.danal.internal/pulse/ios-sdk.git", from: "0.1.0")
]
// 또는 CocoaPods — 저장소를 동봉한 경우
pod 'DanalPulse', :path => '../sdks/ios'
최소 요구: iOS 15 · Swift 6.
초기화
import DanalPulse
let configuration = PulseConfiguration(
endpoint: URL(string: "https://collect.danal.internal")!,
projectId: "prj_danal",
appId: appId,
apiKey: apiKey,
storage: KeychainPulseStorage(), // PulseStorage 를 구현해 넘깁니다
batchSize: 100,
maxQueueSize: 10_000,
environment: .production
)
// 초기화는 던지지 않습니다. 설정이 잘못돼도 앱이 죽지 않도록 검증 결과를 값으로 돌려줍니다.
guard configuration.isValid else {
assertionFailure("Pulse 설정 오류: \(String(describing: configuration.validationError))")
return
}
let pulse = PulseClient(configuration: configuration)
사용
// PulseClient 는 actor 입니다 — 모든 호출에 await 가 필요합니다
await pulse.identify("damoum-member-1")
let queued = await pulse.track(
"purchase",
properties: ["revenue": .number(39000), "currency": .string("KRW")],
consent: ["analytics": .bool(true), "marketing": .bool(true)]
)
// ATT 상태를 함께 보낼 때
await pulse.track("app_open", privacy: PulsePrivacyContext(
trackingAuthorizationStatus: .authorized,
advertisingIdType: .idfa,
advertisingId: idfa,
advertisingIdConsent: true,
limitAdTracking: false,
collectedAt: Date()
))
let messages = await pulse.pullInAppMessages(placement: "home", limit: 3)
await pulse.trackInAppEngagement(deliveryId: messages[0].deliveryId, type: .impression)
let deferred = await pulse.resolveDeferredDeepLink(clickId: clickId)
if deferred.resolved { navigate(deferred.deferredDeepLink) }
await pulse.flush() // 백그라운드 진입 직전
속성 값은 PulseValue 열거형입니다 — .string · .number · .bool · .array · .object · .null. 숫자는 모두 Double로 전달됩니다.
앱이 백그라운드로 갈 때 flush()를 부르지 않으면 큐가 다음 실행까지 남습니다. 데이터가 사라지지는 않지만 콘솔에 늦게 보입니다.
Flutter
설치
# pubspec.yaml
dependencies:
danal_pulse:
path: ../sdks/flutter # 또는 사내 pub 저장소
environment:
sdk: ">=3.4.0 <4.0.0"
flutter: ">=3.22.0"
초기화
import 'package:danal_pulse/danal_pulse.dart';
final pulse = DanalPulse(PulseConfig(
endpoint: Uri.parse('https://collect.danal.internal'),
projectId: 'prj_danal',
appId: appId,
apiKey: apiKey,
source: Platform.isIOS ? 'ios' : 'android', // web·server 는 허용되지 않습니다
environment: PulseEnvironment.production,
batchSize: 100,
maxQueueSize: 10000,
));
사용
pulse.identify('damoum-member-1');
final queued = await pulse.track(
'purchase',
properties: {'revenue': 39000, 'currency': 'KRW'},
consent: {'analytics': true, 'marketing': true},
);
await pulse.trackInAppEngagement(deliveryId, 'IMPRESSION');
final deferred = await pulse.resolveDeferredDeepLink(clickId);
if (deferred.resolved) context.go(deferred.deferredDeepLink!);
await pulse.flush();
final pending = await pulse.refreshPendingEvents(); // 남은 큐 길이
수집·큐·암호화·HTTP는 전부 네이티브 SDK가 수행하고, Dart 층은 메서드 채널로 넘기기만 합니다. 그래서 source에 web이나 server를 넣으면 ArgumentError가 납니다.
엔드포인트는 HTTPS만 허용됩니다. 예외는 localhost·127.0.0.1·::1뿐입니다. 사내 평문 HTTP 주소를 넣으면 생성 시점에 바로 예외가 납니다.
따라서 Flutter 앱이라도 Android/iOS 네이티브 SDK를 각각 붙여야 합니다.
Web
설치
pnpm add @danal/pulse-sdk # 사내 레지스트리 또는 workspace 참조
번들에 들어간 문자열은 개발자 도구로 그대로 보입니다. 웹은 여러분의 서버를 경유하는 구성이 정답입니다 — 브라우저는 자사 API로 보내고, 서버가 키를 붙여 Pulse로 중계합니다.
부득이 브라우저에서 직접 보내야 한다면, 그 키는 수집 전용·짧은 만료·해당 앱 한정이어야 하고 유출을 전제로 운영해야 합니다.
초기화
import { PulseClient } from "@danal/pulse-sdk";
const pulse = new PulseClient({
endpoint: "/api/pulse-proxy", // 자사 서버 경유를 권장
projectId: "prj_danal",
appId: "app_927ab22526a44548985da4404bc197bb",
apiKey: "", // 프록시가 붙이므로 브라우저에는 비워둡니다
source: "web",
environment: "PRODUCTION",
storage: window.localStorage, // getItem/setItem 을 만족하면 무엇이든 됩니다
batchSize: 20,
flushIntervalMs: 10_000,
onError: (error) => console.warn("[pulse]", error),
});
사용
pulse.identify("damoum-member-1");
const queued = await pulse.track("purchase", {
properties: { revenue: 39000, currency: "KRW" },
consent: { analytics: true, marketing: true },
});
// 인앱 메시지는 웹에서도 동작합니다
const messages = await pulse.pullInAppMessages("home", 3);
await pulse.trackInAppEngagement(messages[0].deliveryId, "IMPRESSION");
// 페이지를 떠나기 전에 큐를 비웁니다
window.addEventListener("pagehide", () => { void pulse.flush(); });
Server
결제 승인·환불·정산처럼 앱이 모르는 사건을 보냅니다. 서버는 키를 안전하게 보관할 수 있는 유일한 곳이므로, 웹·앱의 중계도 여기서 맡는 것이 좋습니다.
초기화
import { PulseClient } from "@danal/pulse-sdk";
const pulse = new PulseClient({
endpoint: process.env.PULSE_ENDPOINT!,
projectId: "prj_danal",
appId: process.env.PULSE_APP_ID!,
apiKey: process.env.PULSE_API_KEY!, // 환경변수 이름으로만 참조. 소스에 리터럴 금지
source: "server",
environment: "PRODUCTION",
batchSize: 200,
flushIntervalMs: 5_000,
maxQueueBytes: 2_000_000,
onError: (error) => logger.warn({ error }, "pulse delivery failed"),
});
사용
// 결제 승인 — eventTime 은 반드시 "승인된 시각"이어야 합니다
await pulse.track("purchase", {
eventId: paymentId, // 결제 ID를 그대로 쓰면 재시도가 자연히 멱등해집니다
eventTime: approvedAt,
properties: { revenue: amount, currency: "KRW", channel: "danal-pay" },
consent: { analytics: true, marketing: marketingAgreed },
});
// 브라우저 중계 — 자사 엔드포인트에서 키를 붙여 그대로 넘깁니다
app.post("/api/pulse-proxy/collect/v1/batch", async (req, res) => {
const upstream = await fetch(`${process.env.PULSE_ENDPOINT}/collect/v1/batch`, {
method: "POST",
headers: {
"Content-Type": "application/json; charset=utf-8",
"X-Pulse-Api-Key": process.env.PULSE_API_KEY!,
"X-Pulse-App-Id": process.env.PULSE_APP_ID!,
"X-Pulse-Environment": "PRODUCTION",
},
body: JSON.stringify(req.body),
});
res.status(upstream.status).send(await upstream.text());
});
// 프로세스 종료 전 — 남은 큐를 비우고 타이머를 정리합니다
process.on("SIGTERM", async () => { await pulse.shutdown(); });
shutdown()을 부르지 않으면 이벤트가 사라집니다.
큐는 메모리에 있고 flushIntervalMs마다 비워집니다. 배포로 프로세스가 내려가면 그 사이의 이벤트는 그대로 없어집니다. SIGTERM 처리에 shutdown()을 반드시 넣으세요.
React Native
TypeScript SDK를 그대로 쓰되, 저장소로 AsyncStorage를 넘깁니다. 네이티브 모듈을 함께 붙이면 설치 리퍼러처럼 JS에서 얻을 수 없는 신호도 수집됩니다.
import AsyncStorage from "@react-native-async-storage/async-storage";
import { PulseClient } from "@danal/pulse-sdk";
const pulse = new PulseClient({
endpoint, projectId, appId, apiKey,
source: Platform.OS === "ios" ? "ios" : "android",
environment: "PRODUCTION",
storage: AsyncStorage, // getItem/setItem 시그니처가 그대로 맞습니다
flushIntervalMs: 10_000,
onError: () => setStatus("오프라인 큐에 보관"),
});
// 앱이 백그라운드로 갈 때
AppState.addEventListener("change", (next) => {
if (next !== "active") void pulse.flush();
});
지원 범위는 React Native 0.76 이상 0.84 미만입니다.
① 수집 키 발급
앱이 수집 API를 호출할 때 쓸 키를 만듭니다. 관리 표면 — SSO 세션 또는 개발용 Basic 인증이 필요합니다.
요청 파라미터
| 위치 | 이름 | 타입 | 필수 | 제약 | 설명 |
|---|---|---|---|---|---|
| query | projectId | string | 필수 | — | 대상 프로젝트 |
| query | appId | string | 필수 | — | 키를 쓸 앱 |
| body | name | string | 필수 | 최대 120자 | 키 용도를 알아볼 이름 |
| body | expiresInDays | number | 선택 | 1~730, 기본 365 | 만료까지의 일수. 생략하면 365일 뒤 만료됩니다. |
요청 예시
POST /api/v1/api-keys?projectId=prj_danal&appId=app_927ab22526a44548985da4404bc197bb
Content-Type: application/json; charset=utf-8
X-Pulse-Project-Id: prj_danal
X-Pulse-Environment: PRODUCTION
Origin: https://pulse.danal.internal
X-Change-Reason: PULSE-1234 issue collector key for damoum android
{
"name": "damoum-android-collector",
"expiresInDays": 365
}
응답 파라미터
| 이름 | 타입 | 설명 |
|---|---|---|
key | string | 키 원문. 이 응답에서만 볼 수 있습니다. 다시 조회할 수 없으니 즉시 안전한 저장소에 넣으세요. |
metadata.id | string | 키 식별자. 폐기할 때 씁니다. |
metadata.projectId | string | — |
metadata.appId | string | 이 키가 쓸 수 있는 유일한 앱 |
metadata.name | string | 요청에 보낸 이름 |
metadata.keyPrefix | string | 키 앞부분. 목록에서 어느 키인지 구분하는 용도 |
metadata.status | string | ACTIVE · REVOKED |
metadata.createdBy | string | 발급자 |
metadata.createdAt | string | RFC 3339 UTC |
metadata.expiresAt | string · null | 만료 시각 |
metadata.revokedAt | string · null | 폐기 시각. 살아 있으면 null |
응답 예시
200 OK
{
"key": "pk_live_9f2c8a1d4b7e0c53a6f19d28e4b70c11",
"metadata": {
"id": "key_5c1f0a9e2d834b6f9a77c0e1b2d34f56",
"projectId": "prj_danal",
"appId": "app_927ab22526a44548985da4404bc197bb",
"name": "damoum-android-collector",
"keyPrefix": "pk_live_9f2c",
"status": "ACTIVE",
"createdBy": "pulse-admin",
"createdAt": "2026-08-03T00:10:00Z",
"expiresAt": "2027-08-03T00:10:00Z",
"revokedAt": null
}
}
② 이벤트 수집
구매·조회·로그인 등 사용자 행동을 보냅니다. Pulse의 거의 모든 기능이 이 데이터 위에 올라갑니다.
요청 파라미터
본문은 { "events": [ … ] }이며 이벤트 1~500건입니다. 각 이벤트의 필드는 다음과 같습니다.
| 이름 | 타입 | 필수 | 제약 | 설명 |
|---|---|---|---|---|
eventId | string | 필수 | 최대 100자 | 중복 판정 키. 여러분이 만드는 고유값(UUID 권장). 재시도할 때 같은 값을 쓰면 중복 적재되지 않습니다. |
eventName | string | 필수 | ^[a-z][a-z0-9_]{1,119}$ | 소문자로 시작, 소문자·숫자·밑줄만. 대문자·하이픈·한글은 거절됩니다. |
eventTime | string | 필수 | RFC 3339 UTC | 사건이 실제로 일어난 시각. 서버 도착 시각이 아닙니다. |
source | string | 필수 | android·ios·web·server·oracle | 이 다섯 개만 허용됩니다. |
projectId | string | 필수 | — | 예: prj_danal |
appId | string | 필수 | — | X-Pulse-App-Id 헤더와 같아야 합니다. |
externalCustomerId | string | 선택 | 최대 200자 | 서비스의 회원 식별자. 토큰화되어 저장됩니다. |
anonymousId | string | 선택 | 최대 160자 | 로그인 전 기기 단위 식별자. |
personId | string | 선택 | 최대 64자 | 이미 알고 있을 때만. 보통 비웁니다. |
sessionId | string | 선택 | 최대 100자 | 세션 묶음용. |
properties | object | 선택 | — | 이벤트 속성. 보내면 안 되는 값이 있습니다. |
consent | object | 선택 | 키: analytics·marketing·in_app | 동의 상태. 마케팅 동의가 없으면 광고성 발송에서 자동 제외됩니다. |
privacy | object | 선택 | — | ATT 등 개인정보 신호 |
fraudContext | object | 선택 | — | 부정 트래픽 탐지 보조 신호 |
요청 예시
POST /collect/v1/batch
X-Pulse-Api-Key: pk_live_9f2c8a1d4b7e0c53a6f19d28e4b70c11
X-Pulse-App-Id: app_927ab22526a44548985da4404bc197bb
X-Pulse-Environment: PRODUCTION
Content-Type: application/json; charset=utf-8
{
"events": [
{
"eventId": "0f3b9a12-7c44-4a8e-9a11-2b6d4e5f7c80",
"eventName": "purchase",
"eventTime": "2026-08-03T00:12:34Z",
"source": "android",
"projectId": "prj_danal",
"appId": "app_927ab22526a44548985da4404bc197bb",
"externalCustomerId": "damoum-member-1",
"sessionId": "sess-8821",
"properties": { "revenue": 39000, "currency": "KRW", "item_count": 2 },
"consent": { "analytics": true, "marketing": true }
},
{
"eventId": "1a7c2e55-90b3-4f61-8d2a-77c1e0b9a344",
"eventName": "add_to_cart",
"eventTime": "2026-08-03T00:11:02Z",
"source": "android",
"projectId": "prj_danal",
"appId": "app_927ab22526a44548985da4404bc197bb",
"anonymousId": "anon-4f81c2",
"properties": { "item_id": "SKU-9931" },
"consent": { "analytics": true, "marketing": false }
}
]
}
응답 파라미터
| 이름 | 타입 | 설명 |
|---|---|---|
requestId | string | 이 요청의 추적 ID. 문의할 때 이 값을 알려주세요. |
accepted | number | 적재된 건수 |
duplicates | number | 이미 있던 건수 |
rejected | number | 거절된 건수 |
results[].eventId | string | 요청에 보낸 값 그대로 |
results[].status | string | ACCEPTED · DUPLICATE · REJECTED |
results[].code | string · null | 거절·중복 사유 코드. 전체 목록 |
results[].detail | string · null | 사람이 읽을 설명 또는 문제가 된 속성 경로 |
results[].normalizedShape | object · null | 정규화 후의 속성 타입 |
results[].normalizationDiff | string[] | 타입이 바뀐 속성 목록 |
응답 예시
202 Accepted
{
"requestId": "req_a1f98297aa854c609bcc4c532630d48c",
"accepted": 1,
"duplicates": 0,
"rejected": 1,
"results": [
{
"eventId": "0f3b9a12-7c44-4a8e-9a11-2b6d4e5f7c80",
"status": "ACCEPTED",
"code": null,
"detail": null,
"normalizedShape": { "revenue": "NUMBER", "currency": "STRING", "item_count": "NUMBER" },
"normalizationDiff": []
},
{
"eventId": "1a7c2e55-90b3-4f61-8d2a-77c1e0b9a344",
"status": "REJECTED",
"code": "RAW_EMAIL_FORBIDDEN",
"detail": "$.properties.contact",
"normalizedShape": null,
"normalizationDiff": []
}
]
}
③ 푸시 기기 등록
이 호출을 하지 않으면 그 사용자는 푸시 대상이 되지 않습니다. FCM 토큰이 갱신될 때마다 다시 호출하세요.
요청 파라미터
| 이름 | 타입 | 필수 | 제약 | 설명 |
|---|---|---|---|---|
projectId | string | 필수 | — | — |
appId | string | 필수 | — | 헤더와 같아야 합니다 |
externalCustomerId | string | 필수 | 최대 200자 | 기기를 사람에게 붙입니다 |
platform | string | 필수 | ANDROID · IOS | — |
token | string | 필수 | 최대 1024자 | FCM 등록 토큰 또는 Firebase Installation ID |
identifierKind | string | 선택 | REGISTRATION_TOKEN(기본) · FID | 둘은 문자열만으로 구분되지 않으므로 FID를 쓴다면 반드시 명시해야 합니다. |
pushConsent | boolean | 필수 | — | OS 알림 권한 동의 여부 |
요청 예시
POST /collect/v1/devices
X-Pulse-Api-Key: pk_live_9f2c8a1d4b7e0c53a6f19d28e4b70c11
X-Pulse-App-Id: app_927ab22526a44548985da4404bc197bb
X-Pulse-Environment: PRODUCTION
Content-Type: application/json; charset=utf-8
{
"projectId": "prj_danal",
"appId": "app_927ab22526a44548985da4404bc197bb",
"externalCustomerId": "damoum-member-1",
"platform": "ANDROID",
"token": "fL9xQ2mBS0y…",
"identifierKind": "REGISTRATION_TOKEN",
"pushConsent": true
}
응답 파라미터
| 이름 | 타입 | 설명 |
|---|---|---|
deviceId | string | Pulse가 부여한 기기 ID |
personId | string | 이 기기가 붙은 가명 고객 ID |
status | string | ACTIVE · UNINSTALLED 등 기기 상태 |
pushConsent | boolean | 저장된 동의 상태 |
응답 예시
200 OK
{
"deviceId": "dev_3b7f21c0a95c4f0e8d6a2b1c4e5f7a80",
"personId": "per_7c56f4326a6e4535b23725f31a8639e0",
"status": "ACTIVE",
"pushConsent": true
}
다날 Firebase 프로젝트가 아직 생성되지 않았습니다. 기기 등록·오디언스·캠페인·발송 계획까지는 모두 정상 동작하고 콘솔에도 집계되지만, FCM으로 나가는 마지막 구간은 자격이 등록되기 전까지 성립하지 않습니다.
④ 앱 삭제 신호
더 이상 도달하지 않는 기기를 발송 대상에서 빼기 위한 신호입니다. 앱이 직접 보고할 수도 있고, FCM/APNS가 알려준 무효 토큰을 서버가 중계할 수도 있습니다.
요청 파라미터
| 이름 | 타입 | 필수 | 제약 | 설명 |
|---|---|---|---|---|
projectId | string | 필수 | — | — |
appId | string | 필수 | — | — |
clientSignalId | string | 필수 | ^[A-Za-z0-9._:-]{1,100}$ | 중복 판정 키 |
token | string | 필수 | 최대 1024자 | 무효가 된 토큰. 이 값으로 기기를 찾습니다. |
source | string | 필수 | APP · FCM · APNS | 신호의 출처 |
occurredAt | string | 필수 | RFC 3339 UTC | 신호가 발생한 시각 |
요청 예시
POST /collect/v1/devices/uninstall
X-Pulse-Api-Key: pk_live_9f2c8a1d4b7e0c53a6f19d28e4b70c11
X-Pulse-App-Id: app_927ab22526a44548985da4404bc197bb
X-Pulse-Environment: PRODUCTION
Content-Type: application/json; charset=utf-8
{
"projectId": "prj_danal",
"appId": "app_927ab22526a44548985da4404bc197bb",
"clientSignalId": "uninstall-2026-08-03-0001",
"token": "fL9xQ2mBS0y…",
"source": "FCM",
"occurredAt": "2026-08-03T00:20:00Z"
}
응답 파라미터
| 이름 | 타입 | 설명 |
|---|---|---|
signalId | string | 기록된 신호 ID |
duplicate | boolean | 같은 clientSignalId가 이미 있었으면 true. 오류가 아닙니다. |
matchedDevice | boolean | 토큰으로 기기를 찾았는지. false면 이미 지워졌거나 등록된 적 없는 토큰입니다. |
deviceStatus | string · null | 처리 후 기기 상태. 찾지 못했으면 null |
응답 예시
200 OK
{
"signalId": "uns_2f7b91c04a6d4e1f8b35c2a70d9e1f43",
"duplicate": false,
"matchedDevice": true,
"deviceStatus": "UNINSTALLED"
}
⑤ 인앱 메시지 조회
앱이 화면을 띄울 때 앱이 먼저 물어보는 방식(pull)입니다. Pulse가 밀어 넣지 않습니다. 동의·억제·일일 노출 제한은 이 시점에 서버가 이미 적용하므로, 앱은 받은 것을 그대로 그리면 됩니다.
요청 파라미터
| 이름 | 타입 | 필수 | 제약 | 설명 |
|---|---|---|---|---|
projectId | string | 필수 | — | — |
appId | string | 필수 | — | — |
externalCustomerId | string | 필수 | 최대 200자 | — |
placement | string | 필수 | ^[a-z][a-z0-9_.-]{0,79}$ | 화면 위치 이름. 마케터가 콘솔에서 지정하는 값과 정확히 같아야 합니다. |
limit | number | 선택 | 1~5, 기본 3 | 한 번에 받을 최대 개수 |
요청 예시
POST /collect/v1/in-app/messages
X-Pulse-Api-Key: pk_live_9f2c8a1d4b7e0c53a6f19d28e4b70c11
X-Pulse-App-Id: app_927ab22526a44548985da4404bc197bb
X-Pulse-Environment: PRODUCTION
Content-Type: application/json; charset=utf-8
{
"projectId": "prj_danal",
"appId": "app_927ab22526a44548985da4404bc197bb",
"externalCustomerId": "damoum-member-1",
"placement": "home",
"limit": 3
}
응답 파라미터
| 이름 | 타입 | 설명 |
|---|---|---|
evaluatedAt | string | 서버가 판정한 시각 |
messages[].deliveryId | string | 반응 보고에 그대로 넘겨야 하는 값 |
messages[].campaignId | string | 인앱 캠페인 ID |
messages[].placement | string | 요청한 위치 |
messages[].title | string | 제목 |
messages[].body | string | 본문 |
messages[].imageUrl | string · null | 이미지 주소 |
messages[].buttonLabel | string · null | 버튼 문구. null이면 버튼을 그리지 않습니다. |
messages[].deepLink | string · null | 버튼을 눌렀을 때 이동할 곳 |
messages[].expiresAt | string | 이 시각 이후에는 노출하지 마세요. |
messages[].orchestrationCampaignId | string · null | 푸시 캠페인에서 파생된 경우 그 원본 캠페인 |
messages[].variationKey | string · null | A/B 실험 안 식별자 |
messages[].data | object | 앱이 자유롭게 쓰는 부가 값 |
응답 예시
200 OK
{
"evaluatedAt": "2026-08-03T00:30:00Z",
"messages": [
{
"deliveryId": "ind_6a2c94f10b7d4e83a5619c0f2b3d47e8",
"campaignId": "iac_18b0d7c25e9f4a3c81f6b02d4e7a9c31",
"placement": "home",
"title": "새 리워드가 도착했어요",
"body": "지금 확인하고 받아가세요",
"imageUrl": "https://cdn.danal.co.kr/reward.png",
"buttonLabel": "받으러 가기",
"deepLink": "danalpulse://rewards",
"expiresAt": "2026-08-10T14:59:59Z",
"orchestrationCampaignId": null,
"variationKey": null,
"data": { "screen": "rewards" }
}
]
}
노출할 메시지가 없으면 messages가 빈 배열로 옵니다. 오류가 아닙니다.
⑥ 인앱 반응 보고
메시지를 띄웠는지, 눌렸는지, 닫혔는지를 보고합니다. 이 값이 콘솔의 노출·클릭·CTR이 됩니다.
요청 파라미터
| 이름 | 타입 | 필수 | 제약 | 설명 |
|---|---|---|---|---|
appId | string | 필수 | — | — |
deliveryId | string | 필수 | — | 조회 응답에서 받은 값 |
clientEventId | string | 필수 | 최대 120자 | 중복 판정 키 |
type | string | 필수 | IMPRESSION·CLICKED·DISMISSED | 노출·클릭·닫음 |
occurredAt | string | 필수 | RFC 3339 UTC | — |
요청 예시
POST /collect/v1/in-app/engagements
X-Pulse-Api-Key: pk_live_9f2c8a1d4b7e0c53a6f19d28e4b70c11
X-Pulse-App-Id: app_927ab22526a44548985da4404bc197bb
X-Pulse-Environment: PRODUCTION
Content-Type: application/json; charset=utf-8
{
"appId": "app_927ab22526a44548985da4404bc197bb",
"deliveryId": "ind_6a2c94f10b7d4e83a5619c0f2b3d47e8",
"clientEventId": "imp-ind_6a2c94f1-0001",
"type": "IMPRESSION",
"occurredAt": "2026-08-03T00:30:02Z"
}
응답 파라미터
| 이름 | 타입 | 설명 |
|---|---|---|
deliveryId | string | 요청에 보낸 값 |
type | string | 기록된 반응 종류 |
duplicate | boolean | 같은 clientEventId가 이미 있었으면 true. 집계는 한 번만 됩니다. |
응답 예시
200 OK
{
"deliveryId": "ind_6a2c94f10b7d4e83a5619c0f2b3d47e8",
"type": "IMPRESSION",
"duplicate": false
}
⑦ 설치 리퍼러 수집
Android 전용입니다. Play Install Referrer API로 받은 값을 가공하지 말고 그대로 넘깁니다. 광고 클릭과 설치를 잇는 근거가 됩니다.
요청 파라미터
| 이름 | 타입 | 필수 | 제약 | 설명 |
|---|---|---|---|---|
projectId | string | 필수 | — | — |
appId | string | 필수 | — | — |
clientEventId | string | 필수 | 최대 100자 | 중복 판정 키 |
installKey | string | 필수 | 최대 160자 | 설치 인스턴스를 잇는 값 |
rawReferrer | string | 필수 | 최대 2048자 | Play가 준 원문 그대로 |
referrerClickAt | string | 필수 | RFC 3339 UTC | 광고를 클릭한 시각 |
installBeginAt | string | 필수 | RFC 3339 UTC | 설치가 시작된 시각 |
referrerClickServerAt | string | 선택 | RFC 3339 UTC | 서버 기준 클릭 시각 |
installBeginServerAt | string | 선택 | RFC 3339 UTC | 서버 기준 설치 시각 |
store | string | 선택 | 기본 GOOGLE_PLAY | 스토어 종류 |
collectionSource | string | 선택 | 기본 PLAY_INSTALL_REFERRER_API | 수집 경로 |
installVersion | string | 선택 | 최대 80자 | 설치된 앱 버전 |
요청 예시
POST /collect/v1/install-referrers
X-Pulse-Api-Key: pk_live_9f2c8a1d4b7e0c53a6f19d28e4b70c11
X-Pulse-App-Id: app_927ab22526a44548985da4404bc197bb
X-Pulse-Environment: PRODUCTION
Content-Type: application/json; charset=utf-8
{
"projectId": "prj_danal",
"appId": "app_927ab22526a44548985da4404bc197bb",
"clientEventId": "referrer-9f31c0a2",
"installKey": "install-8c21e0b7a4",
"rawReferrer": "utm_source=naver&utm_medium=paid_search&utm_campaign=summer_growth&pulse_click_id=clk_5f1a…",
"referrerClickAt": "2026-08-02T23:41:10Z",
"installBeginAt": "2026-08-02T23:43:52Z",
"store": "GOOGLE_PLAY",
"collectionSource": "PLAY_INSTALL_REFERRER_API",
"installVersion": "5.2.0"
}
응답 파라미터
| 이름 | 타입 | 설명 |
|---|---|---|
id | string | 기록 ID |
appId | string | — |
clientEventId | string | 요청에 보낸 값 |
store | string | 스토어 |
collectionSource | string | 수집 경로 |
status | string | 리퍼러 상태. 이후 이벤트와 이어지면 CONSUMED가 됩니다. |
duplicate | boolean | 중복 여부 |
mediaSource | string · null | 원문에서 뽑아낸 매체 |
channel | string · null | 원문에서 뽑아낸 채널 |
campaign | string · null | 원문에서 뽑아낸 캠페인 |
hasPulseClickId | boolean | Pulse 트래킹 링크를 거쳐 온 설치인지 |
clickId | string · null | 연결된 클릭 ID |
deferredDeepLink | string · null | 첫 실행에서 열어야 할 목적지 |
referrerClickAt / installBeginAt / collectedAt | string | 각 시각 |
응답 예시
200 OK
{
"id": "ifr_74c2a08e15b94d3f8e0a6c1b2d3e4f50",
"appId": "app_927ab22526a44548985da4404bc197bb",
"clientEventId": "referrer-9f31c0a2",
"store": "GOOGLE_PLAY",
"collectionSource": "PLAY_INSTALL_REFERRER_API",
"status": "COLLECTED",
"duplicate": false,
"mediaSource": "naver",
"channel": "paid_search",
"campaign": "summer_growth",
"hasPulseClickId": true,
"clickId": "clk_5f1ac93b7e2d48a6bf01c8e5d7a29304",
"deferredDeepLink": "danalpulse://cart",
"referrerClickAt": "2026-08-02T23:41:10Z",
"installBeginAt": "2026-08-02T23:43:52Z",
"collectedAt": "2026-08-03T00:05:11Z"
}
⑧ 지연 딥링크 해석
앱이 없던 사용자가 링크를 눌러 설치한 뒤, 첫 실행에서 원래 가려던 화면을 알아내는 호출입니다.
요청 파라미터
| 이름 | 타입 | 필수 | 제약 | 설명 |
|---|---|---|---|---|
projectId | string | 필수 | — | — |
appId | string | 필수 | — | — |
clickId | string | 필수 | ^clk_[a-f0-9]{32}$ | 리퍼러 응답이나 링크에서 얻은 클릭 ID |
clientEventId | string | 필수 | ^[A-Za-z0-9][A-Za-z0-9._:-]{0,99}$ | 중복 판정 키 |
installInstanceId | string | 필수 | 최대 160자, ^[A-Za-z0-9][A-Za-z0-9._:-]{0,159}$ | 설치 인스턴스 식별자 |
요청 예시
POST /collect/v1/deferred-deep-links/resolve
X-Pulse-Api-Key: pk_live_9f2c8a1d4b7e0c53a6f19d28e4b70c11
X-Pulse-App-Id: app_927ab22526a44548985da4404bc197bb
X-Pulse-Environment: PRODUCTION
Content-Type: application/json; charset=utf-8
{
"projectId": "prj_danal",
"appId": "app_927ab22526a44548985da4404bc197bb",
"clickId": "clk_5f1ac93b7e2d48a6bf01c8e5d7a29304",
"clientEventId": "ddl-first-open-0001",
"installInstanceId": "install-8c21e0b7a4"
}
응답 파라미터
| 이름 | 타입 | 설명 |
|---|---|---|
resolved | boolean | true면 목적지를 찾았습니다. false는 실패가 아니라 “유기 설치”라는 뜻이니 기본 화면을 띄우세요. |
duplicate | boolean | 같은 clientEventId로 이미 해석했는지 |
clientEventId | string | 요청에 보낸 값 |
clickId | string · null | 연결된 클릭 |
deferredDeepLink | string · null | 열어야 할 목적지 |
mediaSource | string · null | 유입 매체 |
channel | string · null | 유입 채널 |
campaign | string · null | 유입 캠페인 |
응답 예시
200 OK — 해석 성공
{
"resolved": true,
"duplicate": false,
"clientEventId": "ddl-first-open-0001",
"clickId": "clk_5f1ac93b7e2d48a6bf01c8e5d7a29304",
"deferredDeepLink": "danalpulse://cart?pulse_click_id=clk_5f1ac93b…",
"mediaSource": "naver",
"channel": "paid_search",
"campaign": "summer_growth"
}
200 OK — 해석 대상 없음(유기 설치)
{
"resolved": false,
"duplicate": false,
"clientEventId": "ddl-first-open-0001",
"clickId": null,
"deferredDeepLink": null,
"mediaSource": null,
"channel": null,
"campaign": null
}
⑨ 트래킹 링크 리디렉션
마케터가 콘솔에서 만든 링크입니다. 인증이 없는 공개 경로이고, User-Agent로 기기를 판별해 목적지를 고릅니다. 클릭이 기록되고 clk_…가 발급됩니다.
요청 파라미터
| 위치 | 이름 | 타입 | 필수 | 설명 |
|---|---|---|---|---|
| path | slug | string | 필수 | 콘솔에서 정한 링크 slug |
| query | code | string | 조건부 | 링크에 접근 코드가 걸려 있으면 필요합니다 |
| query | installed | boolean | 선택 | 앱 설치 여부 힌트 |
| header | User-Agent | string | — | Android / iOS / 그 외를 판별합니다. 원문은 저장되지 않습니다. |
요청 예시
GET /r/summer-2026?code=1234 HTTP/1.1
Host: link.danal.co.kr
User-Agent: Mozilla/5.0 (Linux; Android 15; SM-S928N) AppleWebKit/537.36 …
응답 파라미터
본문이 없는 리디렉션입니다. 판단은 상태 코드와 Location 헤더로 합니다.
| 상태 | 헤더 | 의미 |
|---|---|---|
302 | Location | 정상. 기기에 맞는 목적지로 이동합니다. Android면 딥링크 또는 Play 스토어, iOS면 딥링크 또는 App Store, 그 외에는 데스크톱 URL 또는 기본 URL입니다. |
404 | — | slug가 없거나, 링크가 비활성이거나, 커스텀 도메인이 일치하지 않음 |
410 | — | 만료되었거나 최대 클릭 수를 넘김 |
401 | — | 접근 코드 누락 또는 불일치 |
응답 예시
HTTP/1.1 302 Found
Location: danalpulse://cart?pulse_click_id=clk_5f1ac93b7e2d48a6bf01c8e5d7a29304
--- 만료된 링크 ---
HTTP/1.1 410 Gone
Universal Link / App Link로 앱이 바로 열리게 하려면 GET /.well-known/assetlinks.json(Android)과 GET /.well-known/apple-app-site-association(iOS)이 응답되어야 합니다. Pulse가 제공합니다.
응답 코드 전체
HTTP 상태
| 상태 | 언제 | 재시도 |
|---|---|---|
200 / 202 | 정상 처리. 202라도 results를 확인하세요 | — |
400 | JSON 문법 오류, 필드 형식 위반, UTF-8 아님 | 불가 고쳐서 보내세요 |
401 | 키·앱 ID 불일치, 키 만료·폐기 | 불가 |
403 | 다른 프로젝트·환경의 리소스 접근 | 불가 |
404 | 프로젝트·앱·링크 없음 | 불가 |
409 / 412 / 428 | 동시 수정 충돌 / If-Match 불일치 / If-Match 누락 | 최신 버전을 다시 읽고 재시도 |
5xx | 서버 오류 | 가능 같은 eventId로 지수 백오프 재시도 |
건별 판정 코드
| 코드 | status | 의미와 대응 |
|---|---|---|
EVENT_DUPLICATE | DUPLICATE | 같은 eventId가 이미 적재됨. 정상입니다. |
APP_NOT_COLLECTING | REJECTED | 앱이 ACTIVE가 아니거나 수집이 중지됨 |
QUOTA_EXCEEDED | REJECTED | 앱의 이벤트 할당량 초과 |
RAW_CARD_PAN_FORBIDDEN | REJECTED | 카드번호로 보이는 값. detail에 경로가 옵니다. |
RAW_KOREAN_RESIDENT_NUMBER_FORBIDDEN | REJECTED | 주민등록번호 형태의 값 |
RAW_BANK_ACCOUNT_FORBIDDEN | REJECTED | 계좌번호 형태의 값 |
RAW_EMAIL_FORBIDDEN | REJECTED | 이메일 원문 |
RAW_PHONE_FORBIDDEN | REJECTED | 전화번호 원문 |
SECRET_KEY_FORBIDDEN | REJECTED | 속성 이름이 비밀값을 뜻함(password, token 등) |
DIRECT_IDENTIFIER_KEY_FORBIDDEN | REJECTED | 속성 이름이 직접 식별자를 뜻함 |
TOKEN_FORMAT_INVALID | REJECTED | 토큰화되어야 할 값의 형식이 틀림 |
INTERNAL_TOKEN_FORMAT_INVALID | REJECTED | clk_… / view_… 형식 위반 |
보내면 안 되는 데이터
카드번호(PAN)·CVC/CVV·PIN·카드 트랙 데이터·인증 비밀번호는 어떤 필드로도 받지 않습니다.
주민등록번호, 계좌번호, 이메일 원문, 전화번호 원문도 속성으로 보낼 수 없습니다.
원본 IP 주소, User-Agent 원문, IDFA/GAID, 리퍼러 원문은 저장되지 않습니다.
| 보내고 싶은 것 | 대신 보낼 것 |
|---|---|
| 회원 이메일 / 전화번호 | externalCustomerId에 회원번호를 넣으세요. Pulse가 토큰화합니다. |
| 카드 마지막 4자리 | 필요하다면 card_bin_masked처럼 이미 마스킹된 값만 |
| 결제 금액 | properties.revenue — 이건 얼마든지 보내도 됩니다 |
자주 막히는 지점
401이 계속 납니다
수집 키는 앱 단위입니다. X-Pulse-App-Id가 그 키를 발급받은 앱과 다르면 무조건 401입니다. 헤더 두 개가 짝인지 먼저 확인하세요.
인증은 되는데 콘솔에 데이터가 없습니다
순서대로 보세요. ① X-Pulse-Environment가 콘솔에서 보고 있는 환경과 같은가. ② 앱이 ACTIVE인가. ③ 응답의 rejected가 0인가.
재전송했더니 중복으로 쌓였습니다
eventId를 재시도마다 새로 만들고 있을 가능성이 큽니다. 이벤트를 처음 만들 때 한 번 생성해서 재시도에도 같은 값을 쓰세요.
푸시가 성공했다는데 안 옵니다
Pulse가 보고하는 성공은 FCM이 접수했다는 뜻입니다. ① 기기가 등록되어 있는가. ② pushConsent가 true인가. ③ 마케팅 동의가 있는가. ④ 콘솔에서 억제·홀드아웃이 올라가지 않았는가.
대량 발송을 요청했는데 응답이 즉시 왔습니다
정상입니다. 발송은 계획만 커밋하고 반환하며 실제 전송은 워커가 청크 단위로 처리합니다.
시각이 이상하게 찍힙니다
eventTime에 타임존이 없는 문자열을 보내면 거절됩니다. 항상 Z 또는 오프셋을 붙이세요. 콘솔 표시는 Asia/Seoul 24시간제로 통일되어 있습니다.