개발자 연동 가이드 · 앱 / 웹 / 서버

Danal Pulse API 명세

연동에 필요한 API 9개의 전체 명세입니다. 각 API마다 요청 파라미터 · 요청 예시 · 응답 파라미터 · 응답 예시 네 가지를 빠짐없이 실었습니다. 필드의 형식과 제약은 서버 검증 규칙을 그대로 옮긴 것입니다.

먼저 알아야 할 모델

Pulse의 모든 데이터는 프로젝트 → 앱 → 환경 세 층으로 갈립니다. 이걸 먼저 이해하지 않으면 인증은 통과하는데 데이터가 안 보이는 상황을 만납니다.

프로젝트 prj_danal 권한과 데이터 격리의 최상위 경계 앱 다모음 Android 식별자 네임스페이스 · Firebase 자격이 앱 단위 DEVELOPMENT STAGING PRODUCTION 환경이 다르면 데이터가 섞이지 않습니다 앱 다모음 iOS 같은 사람도 앱이 다르면 다른 토큰으로 저장 DEVELOPMENT STAGING PRODUCTION 앱 간 사용자 대조는 되지 않습니다
수집 요청은 언제나 이 세 값이 모두 맞아야 원하는 곳에 쌓입니다.

그리고 사람을 가리키는 값이 두 개입니다. 이 구분이 연동 실수의 절반을 차지합니다.

누가 만드나설명
externalCustomerId여러분이 보냅니다다날 회원번호처럼 서비스가 이미 가진 식별자입니다. Pulse는 이 값을 앱 네임스페이스로 HMAC 토큰화한 뒤에 저장하며, 원문은 저장하지 않습니다.
personIdPulse가 만듭니다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:packagesdks/packages/pulse-sdk에서 그때그때 만듭니다. 커밋된 사본이었다면 SDK가 바뀌어도 아무 신호 없이 낡아갔을 것이고, 여러분은 지금 읽는 API와 다른 소스를 받게 됩니다.

생성은 결정적입니다 — 소스가 같으면 몇 번을 다시 만들어도 바이트가 같습니다. 그래서 SHA-256이 바뀌었다는 것은 SDK가 바뀌었다는 뜻이지 다시 만들었다는 뜻이 아닙니다.

수집 키는 아카이브에 들어 있지 않습니다.

당연한 이야기지만 확인차 적습니다. 키는 ① 수집 키 발급으로 여러분의 앱에 대해 직접 발급받아야 하고, 발급 응답에서 한 번만 볼 수 있습니다.

플랫폼별 SDK 연동

아래 API를 직접 호출해도 되지만, 대부분은 SDK를 쓰는 편이 낫습니다. SDK가 대신 처리하는 일이 적지 않습니다.

플랫폼 → SDK → 수집 API Android 네이티브 iOS 네이티브 Flutter Web 브라우저 서버 (Node) 네이티브 SDK Kotlin · Swift Flutter는 이 위에 얹힙니다 TypeScript SDK @danal/pulse-sdk Web · Server · React Native SDK가 대신 하는 일 · 이벤트를 큐에 쌓기 · 배치로 묶어 전송 · 실패 시 재시도 · 오프라인 보관 · 익명 ID 생성·보관 · 큐 상한·만료·샘플링 · gzip 압축 Pulse /collect/v1
직접 호출하면 큐·재시도·오프라인 보관을 전부 스스로 만들어야 합니다. SDK를 쓰면 track() 한 줄입니다.

어느 SDK를 쓰나

플랫폼SDK버전요구 사항source
AndroidKotlin 네이티브0.1.0Kotlin 2.4 · minSdk 26 · jvmTarget 17android
iOSDanalPulse0.1.0Swift 6 · iOS 15+ios
Flutterdanal_pulse0.1.0Dart ≥3.4 <4.0 · Flutter ≥3.22android 또는 ios
Web@danal/pulse-sdk0.1.0ES2022web
Server@danal/pulse-sdk0.1.0Node 24server
React Native@danal/pulse-sdk + 네이티브0.1.0RN ≥0.76 <0.84android / ios
SDK는 공개 저장소에 배포되어 있지 않습니다.

온프레미스 제품이라 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()
Android만의 주의점

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()   // 백그라운드 진입 직전
iOS만의 주의점

속성 값은 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();   // 남은 큐 길이
Flutter는 얇은 껍데기입니다

수집·큐·암호화·HTTP는 전부 네이티브 SDK가 수행하고, Dart 층은 메서드 채널로 넘기기만 합니다. 그래서 sourceweb이나 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 미만입니다.

① 수집 키 발급

POST/api/v1/api-keys?projectId={projectId}&appId={appId}

앱이 수집 API를 호출할 때 쓸 키를 만듭니다. 관리 표면 — SSO 세션 또는 개발용 Basic 인증이 필요합니다.

요청 파라미터

위치이름타입필수제약설명
queryprojectIdstring필수대상 프로젝트
queryappIdstring필수키를 쓸 앱
bodynamestring필수최대 120자키 용도를 알아볼 이름
bodyexpiresInDaysnumber선택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
}

응답 파라미터

이름타입설명
keystring키 원문. 이 응답에서만 볼 수 있습니다. 다시 조회할 수 없으니 즉시 안전한 저장소에 넣으세요.
metadata.idstring키 식별자. 폐기할 때 씁니다.
metadata.projectIdstring
metadata.appIdstring이 키가 쓸 수 있는 유일한 앱
metadata.namestring요청에 보낸 이름
metadata.keyPrefixstring키 앞부분. 목록에서 어느 키인지 구분하는 용도
metadata.statusstringACTIVE · REVOKED
metadata.createdBystring발급자
metadata.createdAtstringRFC 3339 UTC
metadata.expiresAtstring · null만료 시각
metadata.revokedAtstring · 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
  }
}

② 이벤트 수집

POST/collect/v1/batch

구매·조회·로그인 등 사용자 행동을 보냅니다. Pulse의 거의 모든 기능이 이 데이터 위에 올라갑니다.

요청 파라미터

본문은 { "events": [ … ] }이며 이벤트 1~500건입니다. 각 이벤트의 필드는 다음과 같습니다.

이름타입필수제약설명
eventIdstring필수최대 100자중복 판정 키. 여러분이 만드는 고유값(UUID 권장). 재시도할 때 같은 값을 쓰면 중복 적재되지 않습니다.
eventNamestring필수^[a-z][a-z0-9_]{1,119}$소문자로 시작, 소문자·숫자·밑줄만. 대문자·하이픈·한글은 거절됩니다.
eventTimestring필수RFC 3339 UTC사건이 실제로 일어난 시각. 서버 도착 시각이 아닙니다.
sourcestring필수android·ios·web·server·oracle이 다섯 개만 허용됩니다.
projectIdstring필수예: prj_danal
appIdstring필수X-Pulse-App-Id 헤더와 같아야 합니다.
externalCustomerIdstring선택최대 200자서비스의 회원 식별자. 토큰화되어 저장됩니다.
anonymousIdstring선택최대 160자로그인 전 기기 단위 식별자.
personIdstring선택최대 64자이미 알고 있을 때만. 보통 비웁니다.
sessionIdstring선택최대 100자세션 묶음용.
propertiesobject선택이벤트 속성. 보내면 안 되는 값이 있습니다.
consentobject선택키: analytics·marketing·in_app동의 상태. 마케팅 동의가 없으면 광고성 발송에서 자동 제외됩니다.
privacyobject선택ATT 등 개인정보 신호
fraudContextobject선택부정 트래픽 탐지 보조 신호

요청 예시

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 }
    }
  ]
}

응답 파라미터

이름타입설명
requestIdstring이 요청의 추적 ID. 문의할 때 이 값을 알려주세요.
acceptednumber적재된 건수
duplicatesnumber이미 있던 건수
rejectednumber거절된 건수
results[].eventIdstring요청에 보낸 값 그대로
results[].statusstringACCEPTED · DUPLICATE · REJECTED
results[].codestring · null거절·중복 사유 코드. 전체 목록
results[].detailstring · null사람이 읽을 설명 또는 문제가 된 속성 경로
results[].normalizedShapeobject · null정규화 후의 속성 타입
results[].normalizationDiffstring[]타입이 바뀐 속성 목록

응답 예시

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": []
    }
  ]
}

③ 푸시 기기 등록

POST/collect/v1/devices

이 호출을 하지 않으면 그 사용자는 푸시 대상이 되지 않습니다. FCM 토큰이 갱신될 때마다 다시 호출하세요.

요청 파라미터

이름타입필수제약설명
projectIdstring필수
appIdstring필수헤더와 같아야 합니다
externalCustomerIdstring필수최대 200자기기를 사람에게 붙입니다
platformstring필수ANDROID · IOS
tokenstring필수최대 1024자FCM 등록 토큰 또는 Firebase Installation ID
identifierKindstring선택REGISTRATION_TOKEN(기본) · FID둘은 문자열만으로 구분되지 않으므로 FID를 쓴다면 반드시 명시해야 합니다.
pushConsentboolean필수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
}

응답 파라미터

이름타입설명
deviceIdstringPulse가 부여한 기기 ID
personIdstring이 기기가 붙은 가명 고객 ID
statusstringACTIVE · UNINSTALLED 등 기기 상태
pushConsentboolean저장된 동의 상태

응답 예시

200 OK

{
  "deviceId": "dev_3b7f21c0a95c4f0e8d6a2b1c4e5f7a80",
  "personId": "per_7c56f4326a6e4535b23725f31a8639e0",
  "status": "ACTIVE",
  "pushConsent": true
}
현재 실제 푸시는 나가지 않습니다.

다날 Firebase 프로젝트가 아직 생성되지 않았습니다. 기기 등록·오디언스·캠페인·발송 계획까지는 모두 정상 동작하고 콘솔에도 집계되지만, FCM으로 나가는 마지막 구간은 자격이 등록되기 전까지 성립하지 않습니다.

④ 앱 삭제 신호

POST/collect/v1/devices/uninstall

더 이상 도달하지 않는 기기를 발송 대상에서 빼기 위한 신호입니다. 앱이 직접 보고할 수도 있고, FCM/APNS가 알려준 무효 토큰을 서버가 중계할 수도 있습니다.

요청 파라미터

이름타입필수제약설명
projectIdstring필수
appIdstring필수
clientSignalIdstring필수^[A-Za-z0-9._:-]{1,100}$중복 판정 키
tokenstring필수최대 1024자무효가 된 토큰. 이 값으로 기기를 찾습니다.
sourcestring필수APP · FCM · APNS신호의 출처
occurredAtstring필수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"
}

응답 파라미터

이름타입설명
signalIdstring기록된 신호 ID
duplicateboolean같은 clientSignalId가 이미 있었으면 true. 오류가 아닙니다.
matchedDeviceboolean토큰으로 기기를 찾았는지. false면 이미 지워졌거나 등록된 적 없는 토큰입니다.
deviceStatusstring · null처리 후 기기 상태. 찾지 못했으면 null

응답 예시

200 OK

{
  "signalId": "uns_2f7b91c04a6d4e1f8b35c2a70d9e1f43",
  "duplicate": false,
  "matchedDevice": true,
  "deviceStatus": "UNINSTALLED"
}

⑤ 인앱 메시지 조회

POST/collect/v1/in-app/messages

앱이 화면을 띄울 때 앱이 먼저 물어보는 방식(pull)입니다. Pulse가 밀어 넣지 않습니다. 동의·억제·일일 노출 제한은 이 시점에 서버가 이미 적용하므로, 앱은 받은 것을 그대로 그리면 됩니다.

요청 파라미터

이름타입필수제약설명
projectIdstring필수
appIdstring필수
externalCustomerIdstring필수최대 200자
placementstring필수^[a-z][a-z0-9_.-]{0,79}$화면 위치 이름. 마케터가 콘솔에서 지정하는 값과 정확히 같아야 합니다.
limitnumber선택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
}

응답 파라미터

이름타입설명
evaluatedAtstring서버가 판정한 시각
messages[].deliveryIdstring반응 보고에 그대로 넘겨야 하는 값
messages[].campaignIdstring인앱 캠페인 ID
messages[].placementstring요청한 위치
messages[].titlestring제목
messages[].bodystring본문
messages[].imageUrlstring · null이미지 주소
messages[].buttonLabelstring · null버튼 문구. null이면 버튼을 그리지 않습니다.
messages[].deepLinkstring · null버튼을 눌렀을 때 이동할 곳
messages[].expiresAtstring이 시각 이후에는 노출하지 마세요.
messages[].orchestrationCampaignIdstring · null푸시 캠페인에서 파생된 경우 그 원본 캠페인
messages[].variationKeystring · nullA/B 실험 안 식별자
messages[].dataobject앱이 자유롭게 쓰는 부가 값

응답 예시

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가 빈 배열로 옵니다. 오류가 아닙니다.

⑥ 인앱 반응 보고

POST/collect/v1/in-app/engagements

메시지를 띄웠는지, 눌렸는지, 닫혔는지를 보고합니다. 이 값이 콘솔의 노출·클릭·CTR이 됩니다.

요청 파라미터

이름타입필수제약설명
appIdstring필수
deliveryIdstring필수조회 응답에서 받은 값
clientEventIdstring필수최대 120자중복 판정 키
typestring필수IMPRESSION·CLICKED·DISMISSED노출·클릭·닫음
occurredAtstring필수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"
}

응답 파라미터

이름타입설명
deliveryIdstring요청에 보낸 값
typestring기록된 반응 종류
duplicateboolean같은 clientEventId가 이미 있었으면 true. 집계는 한 번만 됩니다.

응답 예시

200 OK

{
  "deliveryId": "ind_6a2c94f10b7d4e83a5619c0f2b3d47e8",
  "type": "IMPRESSION",
  "duplicate": false
}

⑦ 설치 리퍼러 수집

POST/collect/v1/install-referrers

Android 전용입니다. Play Install Referrer API로 받은 값을 가공하지 말고 그대로 넘깁니다. 광고 클릭과 설치를 잇는 근거가 됩니다.

요청 파라미터

이름타입필수제약설명
projectIdstring필수
appIdstring필수
clientEventIdstring필수최대 100자중복 판정 키
installKeystring필수최대 160자설치 인스턴스를 잇는 값
rawReferrerstring필수최대 2048자Play가 준 원문 그대로
referrerClickAtstring필수RFC 3339 UTC광고를 클릭한 시각
installBeginAtstring필수RFC 3339 UTC설치가 시작된 시각
referrerClickServerAtstring선택RFC 3339 UTC서버 기준 클릭 시각
installBeginServerAtstring선택RFC 3339 UTC서버 기준 설치 시각
storestring선택기본 GOOGLE_PLAY스토어 종류
collectionSourcestring선택기본 PLAY_INSTALL_REFERRER_API수집 경로
installVersionstring선택최대 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"
}

응답 파라미터

이름타입설명
idstring기록 ID
appIdstring
clientEventIdstring요청에 보낸 값
storestring스토어
collectionSourcestring수집 경로
statusstring리퍼러 상태. 이후 이벤트와 이어지면 CONSUMED가 됩니다.
duplicateboolean중복 여부
mediaSourcestring · null원문에서 뽑아낸 매체
channelstring · null원문에서 뽑아낸 채널
campaignstring · null원문에서 뽑아낸 캠페인
hasPulseClickIdbooleanPulse 트래킹 링크를 거쳐 온 설치인지
clickIdstring · null연결된 클릭 ID
deferredDeepLinkstring · null첫 실행에서 열어야 할 목적지
referrerClickAt / installBeginAt / collectedAtstring각 시각

응답 예시

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"
}

⑧ 지연 딥링크 해석

POST/collect/v1/deferred-deep-links/resolve

앱이 없던 사용자가 링크를 눌러 설치한 뒤, 첫 실행에서 원래 가려던 화면을 알아내는 호출입니다.

요청 파라미터

이름타입필수제약설명
projectIdstring필수
appIdstring필수
clickIdstring필수^clk_[a-f0-9]{32}$리퍼러 응답이나 링크에서 얻은 클릭 ID
clientEventIdstring필수^[A-Za-z0-9][A-Za-z0-9._:-]{0,99}$중복 판정 키
installInstanceIdstring필수최대 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"
}

응답 파라미터

이름타입설명
resolvedbooleantrue면 목적지를 찾았습니다. false는 실패가 아니라 “유기 설치”라는 뜻이니 기본 화면을 띄우세요.
duplicateboolean같은 clientEventId로 이미 해석했는지
clientEventIdstring요청에 보낸 값
clickIdstring · null연결된 클릭
deferredDeepLinkstring · null열어야 할 목적지
mediaSourcestring · null유입 매체
channelstring · null유입 채널
campaignstring · 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
}

⑨ 트래킹 링크 리디렉션

GET/r/{slug}

마케터가 콘솔에서 만든 링크입니다. 인증이 없는 공개 경로이고, User-Agent로 기기를 판별해 목적지를 고릅니다. 클릭이 기록되고 clk_…가 발급됩니다.

요청 파라미터

위치이름타입필수설명
pathslugstring필수콘솔에서 정한 링크 slug
querycodestring조건부링크에 접근 코드가 걸려 있으면 필요합니다
queryinstalledboolean선택앱 설치 여부 힌트
headerUser-AgentstringAndroid / 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 헤더로 합니다.

상태헤더의미
302Location정상. 기기에 맞는 목적지로 이동합니다. Android면 딥링크 또는 Play 스토어, iOS면 딥링크 또는 App Store, 그 외에는 데스크톱 URL 또는 기본 URL입니다.
404slug가 없거나, 링크가 비활성이거나, 커스텀 도메인이 일치하지 않음
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를 확인하세요
400JSON 문법 오류, 필드 형식 위반, UTF-8 아님불가 고쳐서 보내세요
401키·앱 ID 불일치, 키 만료·폐기불가
403다른 프로젝트·환경의 리소스 접근불가
404프로젝트·앱·링크 없음불가
409 / 412 / 428동시 수정 충돌 / If-Match 불일치 / If-Match 누락최신 버전을 다시 읽고 재시도
5xx서버 오류가능 같은 eventId로 지수 백오프 재시도

건별 판정 코드

코드status의미와 대응
EVENT_DUPLICATEDUPLICATE같은 eventId가 이미 적재됨. 정상입니다.
APP_NOT_COLLECTINGREJECTED앱이 ACTIVE가 아니거나 수집이 중지됨
QUOTA_EXCEEDEDREJECTED앱의 이벤트 할당량 초과
RAW_CARD_PAN_FORBIDDENREJECTED카드번호로 보이는 값. detail에 경로가 옵니다.
RAW_KOREAN_RESIDENT_NUMBER_FORBIDDENREJECTED주민등록번호 형태의 값
RAW_BANK_ACCOUNT_FORBIDDENREJECTED계좌번호 형태의 값
RAW_EMAIL_FORBIDDENREJECTED이메일 원문
RAW_PHONE_FORBIDDENREJECTED전화번호 원문
SECRET_KEY_FORBIDDENREJECTED속성 이름이 비밀값을 뜻함(password, token 등)
DIRECT_IDENTIFIER_KEY_FORBIDDENREJECTED속성 이름이 직접 식별자를 뜻함
TOKEN_FORMAT_INVALIDREJECTED토큰화되어야 할 값의 형식이 틀림
INTERNAL_TOKEN_FORMAT_INVALIDREJECTEDclk_… / 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이 접수했다는 뜻입니다. ① 기기가 등록되어 있는가. ② pushConsenttrue인가. ③ 마케팅 동의가 있는가. ④ 콘솔에서 억제·홀드아웃이 올라가지 않았는가.

대량 발송을 요청했는데 응답이 즉시 왔습니다

정상입니다. 발송은 계획만 커밋하고 반환하며 실제 전송은 워커가 청크 단위로 처리합니다.

시각이 이상하게 찍힙니다

eventTime에 타임존이 없는 문자열을 보내면 거절됩니다. 항상 Z 또는 오프셋을 붙이세요. 콘솔 표시는 Asia/Seoul 24시간제로 통일되어 있습니다.