npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

vc-cdp-rn-sdk

v0.4.8

Published

CDP SDK for React Native - Customer Data Platform tracking for iOS and Android

Readme

vc-cdp-rn-sdk

CDP SDK cho React Native — tracking events và user profiles cho hệ thống CDP nội bộ VietCredit. Hỗ trợ cả iOSAndroid.

Tài liệu này là nguồn duy nhất cho toàn bộ SDK: cài đặt, cấu hình, API, bảo mật và ví dụ tích hợp. Không cần đọc thêm file nào khác.


Mục lục


Cài đặt

npm install vc-cdp-rn-sdk

Peer Dependencies

SDK yêu cầu các package sau phải được cài riêng trong project:

npm install @react-native-async-storage/async-storage
npm install react-native-device-info

iOS

cd ios && pod install

pod install tự cài luôn pod CdpRnSdkNative (config bridge cho tính năng Native auto-handling — không cần bước nào thêm nếu bạn không dùng tính năng này). Riêng phần Notification Service Extension của mục 8.3 thì pod install không tạo tự động được — CocoaPods chỉ thêm code vào target có sẵn, còn NSE bắt buộc phải là target mới, phải tạo thủ công 1 lần trong Xcode (xem mục 8.3). Đây là giới hạn của chính CocoaPods/Xcode, không riêng gì SDK này — các SDK push lớn khác (OneSignal, Braze...) trên bare React Native cũng yêu cầu bước này.

Android

Không cần thêm cấu hình nếu dùng React Native >= 0.68 (auto-linking) — bao gồm cả module native cho nativeNotificationHandling: bun install/npm install, rebuild lại app (Gradle tự merge AndroidManifest.xml, tự đăng ký native module), xong — không cần sửa file native nào.

Push notification (tuỳ chọn): SDK không kèm sẵn FCM/push. Nếu app cần nhận và hiển thị push notification, cài thêm @react-native-firebase/app + @react-native-firebase/messaging + @notifee/react-native — xem Notification Tracking. Để tracking hoạt động cả khi app ở nền/bị kill, còn cần cấu hình thêm ở native (Android + iOS) — xem mục 7, hoặc bật hẳn Native auto-handling (mục 8) để không phụ thuộc Headless JS/background fetch JS nữa.


Khởi tạo

Khởi tạo SDK một lần duy nhất tại entry point của app (thường là App.tsx). SDK dùng AsyncStorage nên init là async.

// src/lib/cdp.ts
import { CdpRnSdk } from 'vc-cdp-rn-sdk';

let _cdp: CdpRnSdk | null = null;

export async function initCdp(): Promise<CdpRnSdk> {
  _cdp = await CdpRnSdk.create({
    apiKey:     'YOUR_API_KEY',
    secretKey:  'YOUR_SECRET_KEY',
    source:     'DOP',
    serviceName: 'VC_APP',
    baseUrl:    'https://ingestlog.vietcredit.com.vn',
    debug:      __DEV__,
  });
  return _cdp;
}

export function getCdp(): CdpRnSdk {
  if (!_cdp) throw new Error('CDP SDK chưa được khởi tạo. Gọi initCdp() trước.');
  return _cdp;
}
// App.tsx
import { useEffect } from 'react';
import { initCdp } from './lib/cdp';

export default function App() {
  useEffect(() => {
    initCdp();
  }, []);
  // ...
}

Lưu ý: Mỗi lần CdpRnSdk.create() được gọi, SDK tự động tạo session_id mới — tương ứng với 1 lần mở app.

Cấu hình đầy đủ

| Tham số | Kiểu | Mặc định | Mô tả | |---|---|---|---| | apiKey | string | — | Bắt buộc. API Key do CDP cấp | | secretKey | string | — | Bắt buộc. Secret Key để ký HMAC và mã hoá AES | | source | string | — | Bắt buộc. Kênh tích hợp (khai báo với CDP trước) | | serviceName | string | — | Bắt buộc. VC_APP | DOP | LOS | LMS | | baseUrl | string | Staging URL | URL API CDP (không có /v1) | | isTest | boolean | false | Đánh dấu môi trường test | | debug | boolean | false | In log ra console | | batchSize | number | 10 | Số events tối đa trong 1 batch | | batchInterval | number | 5000 | Thời gian (ms) giữa các lần flush tự động | | enableEncryption | boolean | true | Mã hoá PII bằng AES-256-CBC | | notificationApiBaseUrl | string | — | Domain API notification-inbox (khác baseUrl) — dùng cho Notification Tracking | | autoDisplayNotification | boolean | false | SDK tự hiển thị banner notification bằng Notifee khi gọi displayNotification() — xem mục 4 | | notificationChannelId | string | 'vc_cdp_notifications' | Android notification channel id, dùng khi autoDisplayNotification: true hoặc nativeNotificationHandling: true. SDK tự tạo channel với importance: HIGH (banner pop-up), không cần tự tạo channel riêng. Không đặt trùng với channel id App/SDK push khác (MoEngage, OneSignal...) đã dùng — channel bị tạo trước sẽ quyết định importance chung cho mọi thông báo cùng id, dù nguồn khác nhau | | notificationChannelName | string | 'Default' | Android notification channel name (hiển thị trong Settings), dùng khi autoDisplayNotification: true | | notificationSource | string | 'VC_CRM' | Giá trị data.source để nhận diện message thuộc phạm vi SDK — dùng bởi createBackgroundMessageHandler() / attachAutoNotificationHandling() | | nativeNotificationHandling | boolean | false | Android — track delivered/click/dismiss hoàn toàn ở tầng native (không qua Headless JS/Notifee), đáng tin cậy hơn hẳn trên các máy OEM diệt background aggressive. Xem mục 8 | | iosAppGroupId | string | — | iOS — App Group id để chia sẻ config với Notification Service Extension, track delivered kể cả khi app bị force-quit. Xem mục 8.3 | | autoSuppressWhenOsWillDisplay | boolean | false | iOS, chỉ trong background/kill handler — bỏ qua vẽ banner Notifee khi payload có notification/aps.alert (lúc đó OS đã tự vẽ banner mặc định), tránh 2 banner trùng nhau. Đánh đổi: banner đó mất tracking dismiss qua Notifee. Không có hiệu lực với message có action buttons — luôn vẽ qua Notifee để giữ nút, xem mục 7.4 | | iosNativeDismissTracking | boolean | false | iOS — track dismiss hoàn toàn ở tầng native (không qua Headless JS/Notifee), đáng tin cậy hơn khi app bị suspend và chỉ được đánh thức trong một khoảng ngân sách ngắn. Độc lập với nativeNotificationHandling/iosAppGroupId, không cần tạo NSE thủ công. Xem mục 8.4 |

Môi trường

| Môi trường | baseUrl | |---|---| | Staging | https://stg-ingestlog.vietcredit.com.vn | | Production | https://ingestlog.vietcredit.com.vn |


Bắt đầu nhanh

Đi từ zero đến có event/profile đầu tiên trên CDP trong vài bước:

1. Khởi tạo (một lần, ở entry point — xem Khởi tạo):

import { cdp } from './lib/cdp'; // đã initCdp() từ trước

2. Track event/màn hình đầu tiên (kể cả khi user chưa đăng nhập — event sẽ gắn anonymous_id):

cdp.screen('SplashScreen');
cdp.track('app_open', { platform: 'android', version: '2.1.0' });

3. Identify user sau khi đăng nhập:

await cdp.identifyUser('user_001', {
  phone:     '0901234567',
  email:     '[email protected]',
  full_name: 'Nguyễn Văn A',
});

console.log(cdp.getProfileId()); // → "177671850461184000"

4. Track events sau login — từ đây mọi event tự động gắn kèm profile_id:

cdp.track('loan_viewed', { loan_id: 'LOAN_001', amount: 50_000_000, term: 24 });
cdp.screen('LoanDetailScreen', { loan_code: 'CONSUMER_24M' });

5. Đăng xuất:

await cdp.destroy_session();

Xem chi tiết từng API ở các mục bên dưới, hoặc tra nhanh signature ở API Reference.


Luồng hoạt động

App khởi động
  └─> CdpRnSdk.create(config)   ← async
        │  - Load/tạo device_id    → AsyncStorage (persist vĩnh viễn)
        │  - Load/tạo anonymous_id → AsyncStorage (persist vĩnh viễn)
        │  - Tạo session_id mới    → AsyncStorage (mỗi lần mở app)
        │  - Load profile_id       → AsyncStorage (nếu đã login)
        │
        ├─ User CHƯA login
        │     └─> cdp.track() / cdp.screen()
        │           profile_id = null, gắn anonymous_id + device_id + session_id
        │
        └─ User login
              └─> cdp.identifyUser(userId, traits)
                    └─> POST /v1/profiles/track → nhận profile_id
                          └─> cdp.track() gắn profile_id vào mọi event

Storage scope (AsyncStorage):

| | Vòng đời | |---|---| | device_id | Vĩnh viễn (đến khi xoá app) | | anonymous_id | Vĩnh viễn (rotate khi logout) | | session_id | Tạo mới mỗi lần create() | | profile_id | Xoá khi destroy_session() |


User / Profile

identifyUser — Đăng nhập

await cdp.identifyUser(userId, traits?, partyType?)

| Tham số | Kiểu | Bắt buộc | Mô tả | |---|---|---|---| | userId | string | ✓ | Mã user trong hệ thống nội bộ | | traits | UserTraits | | Thông tin nhân khẩu học | | partyType | 'PERSON' \| 'ORG' | | Mặc định 'PERSON' |

// Cá nhân
await cdp.identifyUser('user_001', {
  phone:        '0901234567',
  email:        '[email protected]',
  full_name:    'Nguyễn Văn A',
  gender:       'M',
  dob:          '1990-01-15',
  idcard:       '012345678901',
  address:      '123 Lê Lợi, Q.1, TP.HCM',
  occupation:   'Kỹ sư phần mềm',
  nationality:  'Vietnamese',
  marital_status: 'single',
});

console.log(cdp.getProfileId()); // → "177671850461184000"

// Tổ chức
await cdp.identifyUser('org_001', {
  full_name: 'Công ty TNHH ABC',
  phone:     '0281234567',
  email:     '[email protected]',
}, 'ORG');

identify() là alias của identifyUser().


Trait Setters — Cập nhật từng thuộc tính

Chainable. Mỗi setter gọi ngay POST /v1/profiles/track (type: update).

cdp
  .add_first_name('Văn A')
  .add_last_name('Nguyễn')
  .add_user_name('Nguyễn Văn A')
  .add_email('[email protected]')
  .add_mobile('0901234567')
  .add_gender('M')
  .add_birthday('1990-01-15')       // hoặc new Date('1990-01-15')
  .add_idcard('012345678901')
  .add_old_idcard('123456789')
  .add_address('123 Lê Lợi, Q.1, TP.HCM')
  .add_occupation('Kỹ sư phần mềm')
  .add_nationality('Vietnamese')
  .add_marital_status('single')
  .add_religion('Buddhist')
  .add_zalo_id('zalo_user_001')
  .add_tiktok_id('tiktok_user_001');

| Method | Trường | Ghi chú | |---|---|---| | add_first_name(v) | first_name | | | add_last_name(v) | last_name | | | add_user_name(v) | full_name | | | add_email(v) | email | 🔒 PII | | add_mobile(v) | phone | 🔒 PII | | add_gender(v) | gender | 'M' | 'F' | 'O' | | add_birthday(v) | dob | Date hoặc 'yyyy-MM-dd' | | add_idcard(v) | idcard | 🔒 PII | | add_old_idcard(v) | old_idcard | 🔒 PII | | add_address(v) | address | 🔒 PII | | add_occupation(v) | occupation | | | add_nationality(v) | nationality | | | add_marital_status(v) | marital_status | | | add_religion(v) | religion | 🔒 PII | | add_zalo_id(v) | zalo_id | | | add_tiktok_id(v) | tiktok_id | | | add_user_attribute(name, val) | custom | Bất kỳ trường nào | | add_user_attributes(obj) | nhiều trường | Batch update | | setUserAttributes(obj) | nhiều trường | Alias của add_user_attributes() |


updateProfile — Force-update

Dùng khi cần đẩy thay đổi cụ thể (consents/scores/products/metadata) mà không muốn kích hoạt lại identity resolution như identifyUser().

await cdp.updateProfile({
  traits: { kyc_status: 'verified', mps_verified: true },
  consents: [{ consent_type: 'marketing_sms', status: true, consent_date: '2025-06-01' }],
});

Consents

cdp.setConsents([
  { consent_type: 'marketing_sms',   status: true,  consent_date: '2025-06-01' },
  { consent_type: 'marketing_email', status: false },
  { consent_type: 'data_sharing',    status: true },
]);

Scores

cdp.setScores([
  { score_type: 'credit_score', score_value: 720, score_date: '2025-06-01' },
]);

Products

cdp.setProducts([
  { product_code: 'LOAN_CONSUMER', product_name: 'Vay tiêu dùng', status: 'active' },
]);

Metadata, Campaign, Push Token

cdp.setMetadata({ schema_version: '2.0', referral_code: 'REF123' });

cdp.setCampaign({
  utm_source:   'facebook',
  utm_medium:   'cpc',
  utm_campaign: 'summer_loan_2025',
});

// Push notification token (Expo hoặc FCM)
cdp.setPushToken('ExponentPushToken[xxxxxxxxxxxxxxxxxxxxxx]');
cdp.setPushToken('fcm_token_abc123...');

setPushToken() chỉ gửi ngay một profile update (POST /v1/profiles/track) nếu đã có ít nhất 1 trait (vd đã gọi identifyUser() / add_email()...) — API từ chối request với traits rỗng (400 traits must not be empty), quy tắc áp dụng cho mọi request /v1/profiles/track, không riêng push token. Nếu gọi setPushToken() sớm (vd ngay sau CdpRnSdk.create(), trước khi user login), token chỉ được lưu vào state cục bộ — vẫn tự động đi kèm platforms.push_token ở lần identifyUser() / trait setter / track() thành công tiếp theo, không bị mất.

Pattern khuyến nghị — lấy token ngay lúc app khởi động:

// App.tsx — sau khi CdpRnSdk.create() xong, TRƯỚC khi user kịp đăng nhập
try {
  const token = await messaging().getToken(); // xem mục 7.2 nếu gặp lỗi 'messaging/unregistered'
  cdp.setPushToken(token);
} catch (e) {
  console.warn('getToken()/setPushToken() failed', e);
}

Gọi lúc boot (trước khi có traits) chỉ lưu token vào state cục bộ — không tốn request thừa. Khi user đăng nhập ngay sau đó, identifyUser() tự động đính kèm platforms.push_token này trong payload — không cần gọi lại setPushToken() sau identifyUser().


getUserIdentities

const ids = cdp.getUserIdentities();
// {
//   user_id:      'user_001',
//   profile_id:   '177671850461184000',
//   anonymous_id: 'uuid-...',
//   device_id:    'uuid-...',
//   session_id:   'uuid-...',
// }

cdp.getProfileId();
cdp.getDeviceId();
cdp.getSessionId();
cdp.getUserAttribute('phone');

destroy_session — Đăng xuất

await cdp.destroy_session();
// hoặc alias:
await cdp.reset();

| | Hành động | |---|---| | userId, profileId, userTraits | Xoá | | consents, scores, products, campaign | Xoá | | anonymous_id | Tạo mới, lưu AsyncStorage | | session_id | Tạo mới, lưu AsyncStorage | | device_id | Giữ nguyên |


Event Tracking

track — Custom event

cdp.track(eventName, properties?)
cdp.track('loan_viewed', {
  loan_id: 'LOAN_001',
  amount:  50_000_000,
  term:    24,
  type:    'consumer',
});

cdp.track('button_click', { button: 'apply_now', screen: 'LoanDetailScreen' });

cdp.track('form_submitted', { form: 'loan_application', success: true });

Nếu gọi track() trước khi CdpRnSdk.create() hoàn tất (rất hiếm, vd race condition lúc app khởi động), event được xếp hàng chờ và tự gửi ngay khi init xong — không bị mất.


screen — Màn hình

Track lượt xem màn hình. Gửi event screen_view.

cdp.screen('HomeScreen');
cdp.screen('LoanDetailScreen', { loan_code: 'CONSUMER_24M' });
cdp.screen('ProfileScreen',    { tab: 'personal_info' });

Tích hợp React Navigation (auto-track mỗi lần chuyển màn hình) — xem ví dụ đầy đủ tại Tích hợp React Navigation đầy đủ. Bản rút gọn:

// App.tsx
import { NavigationContainer, useNavigationContainerRef } from '@react-navigation/native';
import { getCdp } from './lib/cdp';

export default function App() {
  const navRef = useNavigationContainerRef();

  return (
    <NavigationContainer
      ref={navRef}
      onReady={() => {
        const name = navRef.getCurrentRoute()?.name;
        if (name) getCdp().screen(name);
      }}
      onStateChange={() => {
        const name = navRef.getCurrentRoute()?.name;
        if (name) getCdp().screen(name);
      }}
    >
      {/* Stack / Tab / Drawer navigators */}
    </NavigationContainer>
  );
}

Batch Processing

Events được gom vào queue rồi flush theo điều kiện:

| Điều kiện | Mô tả | |---|---| | batchSize đạt ngưỡng | Mặc định 10 events | | batchInterval timeout | Mặc định 5 000 ms | | flush() gọi thủ công | Ngay lập tức | | App vào background | Gọi flush() trước AppState change |

const cdp = await CdpRnSdk.create({
  batchSize:     20,
  batchInterval: 3000,
});

Đặt batchSize: 1 để gửi mỗi event ngay lập tức, không gom batch (phù hợp lúc debug, không khuyến nghị cho production vì tốn request).


flush — Gửi ngay

await cdp.flush();
// Đảm bảo event được gửi trước khi app vào background
import { AppState } from 'react-native';

AppState.addEventListener('change', async (state) => {
  if (state === 'background') {
    await cdp.flush();
  }
});

Event Context

Mỗi event tự động gắn kèm định danh (user/anonymous/device/session), thông tin thiết bị, campaign và context (locale/timezone) — không cần tự thêm:

{
  "event_id":    "uuid-...",
  "type":        "track",
  "event_name":  "loan_viewed",
  "service_name": "VC_APP",
  "user_id":     "user_001",
  "profile_id":  "177671850461184000",
  "anonymous_id": "uuid-...",
  "device_id":   "uuid-...",
  "session_id":  "uuid-...",
  "properties":  { "loan_id": "LOAN_001" },
  "platforms": {
    "platform":  "android",
    "brand":     "Samsung",
    "model":     "Galaxy S24",
    "os_name":   "android",
    "os_version": "14.0",
    "app_version": "2.1.0",
    "device_id": "uuid-..."
  },
  "campaign":    { "utm_source": "facebook" },
  "context":     { "locale": "vi_VN", "timezone": "Asia/Ho_Chi_Minh" },
  "event_time":  "2025-06-25T10:30:00.000Z"
}

destroy — Dọn dẹp

await cdp.destroy();

Flush queue còn lại và xoá timer. Gọi khi unload SDK hoàn toàn (hiếm khi cần trong vòng đời thông thường của app). Không xoá user data.


Notification Tracking

vc-cdp-rn-sdk không tích hợp sẵn FCM SDK — App tự chọn thư viện push (khuyến nghị @react-native-firebase/messaging + @notifee/react-native). SDK hỗ trợ 3 cách tích hợp, chọn một cho mỗi message (không trộn lẫn, sẽ track trùng sự kiện) — chọn trước khi bắt đầu code, vì cách 3 cần cấu hình native ngay từ đầu chứ không chỉ là JS:

| Cách | Phù hợp khi | Độ tin cậy khi app ở nền/bị kill | |---|---|---| | Tự quản lýmục 4.1–4.5 | Muốn tự kiểm soát UI banner và/hoặc thời điểm gọi track | Qua Headless JS (Android) / background fetch JS (iOS) — có retry/persist tự động nhưng vẫn phụ thuộc JS engine có được OS cho chạy hay không | | SDK tự động (JS)mục 4.6 | Muốn tích hợp nhanh, không cần tự viết handler | Như trên — cùng cơ chế, chỉ khác là SDK tự viết handler hộ | | Native auto-handlingmục 8 | Cần độ tin cậy cao nhất, chấp nhận cấu hình native 1 lần (Android: tự động qua autolink; iOS delivered/nút hành động: cần tạo Notification Service Extension thủ công, iOS dismiss: tự autolink như Android) | Android: hoàn toàn native, không qua JS. iOS: delivered qua NSE (sống sót cả khi force-quit), click thân banner vẫn cần JS, click nút hành động native nếu message có buttons (mục 8.5), dismiss mặc định qua Notifee (JS) hoặc hoàn toàn native với iosNativeDismissTracking (mục 8.4, không cần NSE) |

Với 2 cách đầu (JS), trackNotificationRead() (mục 4.5) luôn cần App tự gọi — sự kiện này gắn với lúc user mở màn hình inbox, không phải một sự kiện push nên không nằm trong phạm vi tự động hoá của mục 4.6 (và cũng không thuộc phạm vi native ở mục 8, vì lúc đó JS chắc chắn đang chạy).

Vẽ banner riêng, track tự động (SDK/App) — có trộn được không? Có: autoDisplayNotification: false + vẫn dùng attachAutoNotificationHandling() nghĩa là App tự vẽ banner nhưng để SDK lo tracking. Chỉ cần đảm bảo App không tự gọi thêm trackNotification<Event>() thủ công cho cùng message (sẽ trùng), và banner App tự vẽ dùng đúng data: { trackingId, clickAction } như mục 4.4 để listener click/dismiss của SDK nhận diện được.

Phạm vi: SDK này chỉ phục vụ xử lý/tracking cho thông báo do hệ thống VC CRM gửi xuống — nhận diện bằng data.source === "VC_CRM" trong payload FCM (xem mục 1). Các loại thông báo khác (app tự đẩy, tích hợp bên thứ ba khác...) không thuộc phạm vi SDK — bên gửi/nhận tự custom xử lý và hiển thị, không gọi parsePushNotification() / trackNotificationEvent() cho các thông báo đó. Với cách "SDK tự động hoàn toàn" (mục 4.6), việc kiểm tra data.source đã được SDK tự làm bên trong (đổi giá trị so sánh qua notificationSource trong config nếu CRM dùng tên khác 'VC_CRM'). Với cách "Tự quản lý" (mục 4.1–4.5), App vẫn phải tự kiểm tra remoteMessage.data?.source trước khi gọi parsePushNotification() / trackNotificationEvent(), như trong các ví dụ.

API notification-inbox nằm trên domain khác với API ingest event/profile → cấu hình riêng qua notificationApiBaseUrl (xem Cấu hình đầy đủ).

Trong toàn bộ ví dụ ở mục 4, biến cdp (không có CdpRnSdk.create() đứng trước) là instance dùng chung của app, lấy qua getCdp() như đã thiết lập ở Khởi tạo. Riêng background handler (mục 4.1) luôn tự tạo instance mới bằng CdpRnSdk.create() vì chạy trong headless JS context, tách biệt hoàn toàn với vòng đời của app chính.

1. Payload mẫu từ CRM

Push notification từ hệ thống CRM (source: "VC_CRM") có dạng RemoteMessage của FCM như sau:

{
  "messageId": "1784545951060390",
  "from": "831684032641",
  "data": {
    "tracking_id": "c07de51d-93ea-4d45-aa9f-70b8cdf349c8",
    "body": "Nội dung",
    "click_action": "https://tinvay.com.vn",
    "type": "basic",
    "source": "VC_CRM",
    "image": "https://.../image.jpg",
    "logo_url": "https://.../logo.jpg",
    "gif_url": "https://.../logo.gif",
    "title": "Tiêu đề bài viêt",
    "buttons": [
      { "name": "Sao chép 2", "type": "copy", "value": "03658700513" },
      { "name": "Chia sẻ", "type": "share", "value": "tinvay://notification" }
    ],
    "logo_type": "upload",
    "fcm_options": { "image": "https://.../image.jpg" },
    "summary": "Tóm tắt bài viết"
  },
  "contentAvailable": true,
  "mutableContent": true,
  "notification": { "body": "Nội dung", "title": "Tiêu đề bài viêt" }
}

Nhận diện thông báo từ VC CRM bằng data.source === "VC_CRM" — đây là điều kiện App cần kiểm tra trước khi gọi parsePushNotification() / trackNotificationEvent(), vì SDK không tự lọc payload theo source. Thông báo có source khác (hoặc không có source) không thuộc phạm vi SDK này.

Lưu ý: CRM luôn gửi data.tracking_id (như payload mẫu trên) — SDK ưu tiên lấy trackingId từ field này. Trường hợp payload không có data.tracking_id, SDK dùng messageId làm fallback để vẫn định danh được sự kiện tracking.

buttons[].type (copy, share, ...) là quy ước phía CRM — SDK không diễn giải hành vi, App tự xử lý (copy vào clipboard, mở share sheet, mở deeplink...) và tự gọi trackNotificationClick() với buttonId tương ứng.

2. Cấu hình domain notification-inbox

const cdp = await CdpRnSdk.create({
  apiKey:      'YOUR_API_KEY',
  secretKey:   'YOUR_SECRET_KEY',
  source:      'DOP',
  serviceName: 'VC_APP',
  baseUrl:     'https://ingestlog.vietcredit.com.vn',       // API events/profile

  notificationApiBaseUrl: 'https://consolecdp-dev.ovp.vn', // API notification-inbox — domain khác (tạm thời dùng domain dev)
});

Nếu không set notificationApiBaseUrl, hoặc thiếu apiKey/secretKey, các hàm tracking/inbox bên dưới sẽ bỏ qua và log lỗi (không throw).

3. parsePushNotification — chuẩn hoá payload để dựng UI

import { CdpRnSdk } from 'vc-cdp-rn-sdk';

const parsed = CdpRnSdk.parsePushNotification(remoteMessage);
// {
//   trackingId: 'c07de51d-93ea-4d45-aa9f-70b8cdf349c8',   // data.tracking_id, hoặc messageId nếu thiếu
//   title: 'Tiêu đề bài viêt',
//   body: 'Nội dung',
//   summary: 'Tóm tắt bài viết',
//   image: 'https://.../image.jpg',
//   logoUrl: 'https://.../logo.jpg',
//   logoType: 'upload',
//   clickAction: 'https://tinvay.com.vn',
//   buttons: [{ name: 'Sao chép 2', type: 'copy', value: '03658700513' }, ...],
//   raw: remoteMessage,
// }

parsePushNotificationstatic, không gọi network, không cần instance SDK — dùng được ngay trong headless background handler (index.js) trước khi app render. App dùng parsed để tự dựng UI (banner tuỳ biến qua Notifee, màn hình chi tiết, danh sách nút hành động…).

4. Track đủ 5 sự kiện — kể cả khi app ở background/quit

Yêu cầu quan trọng: delivered phải được track ngay cả khi app đang ở nền hoặc bị kill, vì FCM data message vẫn đánh thức app (Android) hoặc gọi content-available handler (iOS) mà không cần user tương tác. Việc này bắt buộc phải xử lý bằng background message handler đăng ký ở top-level index.js, không phải trong component React.

4.1. index.js — background handler (bắt buộc, kể cả app bị kill)

// index.js — đăng ký TRƯỚC AppRegistry.registerComponent
import messaging from '@react-native-firebase/messaging';
import { CdpRnSdk } from 'vc-cdp-rn-sdk';
import { cdpConfig } from './src/cdpConfig';

messaging().setBackgroundMessageHandler(async (remoteMessage) => {
  // Chỉ xử lý thông báo từ VC CRM — loại khác để App tự custom xử lý riêng.
  if (remoteMessage.data?.source !== 'VC_CRM') return;

  const parsed = CdpRnSdk.parsePushNotification(remoteMessage);
  if (!parsed.trackingId) return;

  // Headless JS context: mỗi lần chạy là instance mới, nhưng device_id/
  // profile_id được đọc lại từ AsyncStorage nên vẫn nhất quán với app chính.
  const cdp = await CdpRnSdk.create(cdpConfig);
  await cdp.trackNotificationDelivered(parsed.trackingId, {
    deeplink: parsed.clickAction,
  });

  // displayNotification() chỉ thực sự hiển thị khi autoDisplayNotification: true
  // trong cdpConfig; nếu không, trả về false và App tự hiển thị bên dưới.
  const displayed = await cdp.displayNotification(parsed);
  if (!displayed) {
    await displayNotifeeNotification(parsed); // App tự hiển thị — xem mục 4.4
  }
});

Android: background handler luôn chạy khi app ở background hoặc bị kill. iOS: yêu cầu contentAvailable: true (đã có trong payload CRM) và app bật capability Background Modes → Remote notifications; hệ điều hành có thể trì hoãn/giới hạn tần suất đánh thức app theo chính sách riêng. Xem đầy đủ các bước cấu hình native (Android + iOS) cần thiết để phần này hoạt động ở mục 7.

⚠ Nếu bật autoDisplayNotification: true, payload có kèm field notification (như mẫu ở mục 1, hoặc trên iOS là apns.payload.aps.alert — RNFB tự map field này vào remoteMessage.notification, xem ghi chú mục 8.3) sẽ khiến OS tự hiển thị thêm một banner mặc định ở background/kill, độc lập với banner Notifee — không có cách nào ở phía app/SDK ngăn được banner này (hành vi cố định của FCM/APNs). SDK vẫn chủ động vẽ banner Notifee như bình thường (đủ ảnh, action buttons, track được click/dismiss) thay vì bỏ qua — bỏ vẽ Notifee không hết trùng lặp (banner OS vẫn hiện) mà còn mất tính năng. Khi debug: true, SDK log cảnh báo nếu phát hiện tình huống này. Cách duy nhất hết trùng lặp hoàn toàn: cấu hình CRM gửi payload data-only (bỏ hẳn field notification/aps.alert) — trên iOS việc này cũng tắt luôn NSE (mục 8.3 — NSE cần alert mới chạy được), nên chỉ áp dụng nếu không cần track delivered lúc app bị force-quit. Nếu không đổi được payload CRM, bật autoSuppressWhenOsWillDisplay: true trong config — createBackgroundMessageHandler() tự bỏ qua vẽ Notifee cho message này (không cần tự viết handler riêng), chỉ còn 1 banner do OS/NSE vẽ, đánh đổi mất tracking dismiss qua Notifee cho banner đó. Xem chi tiết + một bug thật liên quan (dismiss bị track nhầm thành open) ở mục 7.4. Mặc định autoSuppressWhenOsWillDisplayfalse — giữ hành vi hiện tại (luôn vẽ đủ qua Notifee).

4.2. App.tsx — foreground

FCM không tự hiển thị banner khi app đang mở — App phải tự hiển thị bằng Notifee, đồng thời track delivered:

import messaging from '@react-native-firebase/messaging';
import { CdpRnSdk } from 'vc-cdp-rn-sdk';

useEffect(() => {
  const unsubscribe = messaging().onMessage(async (remoteMessage) => {
    if (remoteMessage.data?.source !== 'VC_CRM') return; // loại khác: App tự xử lý

    const parsed = CdpRnSdk.parsePushNotification(remoteMessage);
    if (parsed.trackingId) {
      await cdp.trackNotificationDelivered(parsed.trackingId, { deeplink: parsed.clickAction });
    }

    const displayed = await cdp.displayNotification(parsed);
    if (!displayed) {
      await displayNotifeeNotification(parsed); // App tự hiển thị — xem mục 4.4
    }
  });
  return unsubscribe;
}, []);

4.3. Track open — user nhấn vào thông báo để mở app

// App đang ở background, user tap thông báo để mở lại app
useEffect(() => {
  const unsubscribe = messaging().onNotificationOpenedApp((remoteMessage) => {
    if (remoteMessage.data?.source !== 'VC_CRM') return; // loại khác: App tự xử lý

    const parsed = CdpRnSdk.parsePushNotification(remoteMessage);
    if (parsed.trackingId) {
      cdp.trackNotificationOpen(parsed.trackingId, { deeplink: parsed.clickAction });
    }
    // navigate theo parsed.clickAction nếu cần
  });
  return unsubscribe;
}, []);

// App bị kill hoàn toàn, user tap thông báo để mở app từ đầu
useEffect(() => {
  messaging().getInitialNotification().then((remoteMessage) => {
    if (!remoteMessage || remoteMessage.data?.source !== 'VC_CRM') return;

    const parsed = CdpRnSdk.parsePushNotification(remoteMessage);
    if (parsed.trackingId) {
      cdp.trackNotificationOpen(parsed.trackingId, { deeplink: parsed.clickAction });
    }
  });
}, []);

4.4. Track click (nút hành động) và dismiss

Hàm displayNotifeeNotification() dưới đây là fallback App tự hiển thị khi cdp.displayNotification() trả về false (xem mục 4.1/4.2) — dùng chung shape data: { trackingId, clickAction, buttons } với displayNotification() của SDK, nên listener onForegroundEvent / onBackgroundEvent bên dưới hoạt động đúng như nhau bất kể banner do SDK hay App hiển thị.

android.pressAction: { id: 'default' }bắt buộc — thiếu field này, Notifee không tự mở app khi nhấn vào thân banner (chỉ dismiss, không launch), vì Notifee không có hành vi mặc định nào cho việc nhấn notification. Ảnh (AndroidStyle.BIGPICTURE + ios.attachments), nút hành động (android.actions + ios.categoryId/setNotificationCategories()) và channel importance: HIGH (để banner pop-up thay vì chỉ vào tray) đều cần cấu hình đúng field — xem code thật trong CdpRnSdk.displayNotification() (src/CdpRnSdk.ts) để copy chính xác. Nếu không cần tuỳ biến UI riêng, bật autoDisplayNotification: true sẽ đỡ phải tự viết lại toàn bộ đoạn này.

import notifee, { EventType } from '@notifee/react-native';

async function displayNotifeeNotification(parsed: ReturnType<typeof CdpRnSdk.parsePushNotification>) {
  await notifee.displayNotification({
    id: parsed.trackingId, // FCM redeliver cùng message => cập nhật lại banner, không tạo bản trùng
    title: parsed.title,
    body: parsed.body,
    data: {
      trackingId: parsed.trackingId,
      clickAction: parsed.clickAction ?? '',
      buttons: JSON.stringify(parsed.buttons), // cần lại để resolve nút lúc click, xem handleNotifeeEvent
    },
    android: {
      pressAction: { id: 'default' }, // bắt buộc để nhấn thân banner mở được app — xem cảnh báo phía trên
      // + style/actions — xem CdpRnSdk.displayNotification()
    },
    // ios.attachments/categoryId — xem CdpRnSdk.displayNotification()
  });
}

// Foreground: app đang mở, user tương tác với notification
notifee.onForegroundEvent(({ type, detail }) => handleNotifeeEvent(type, detail));
// Background/quit: đăng ký ở index.js cùng background handler của FCM
notifee.onBackgroundEvent(async ({ type, detail }) => handleNotifeeEvent(type, detail));

async function handleNotifeeEvent(type: EventType, detail: { notification?: any; pressAction?: { id: string } }) {
  const trackingId = detail.notification?.data?.trackingId as string | undefined;
  if (!trackingId) return;

  if (type === EventType.DISMISSED) {
    await cdp.trackNotificationDismiss(trackingId);
  } else if (type === EventType.ACTION_PRESS || type === EventType.PRESS) {
    await cdp.trackNotificationClick(trackingId, { buttonId: detail.pressAction?.id });
    // Resolve lại nút gốc để biết type (copy/share/deeplink) mà tự xử lý hành vi:
    const buttons: PushNotificationButton[] = JSON.parse(detail.notification?.data?.buttons ?? '[]');
    const index = detail.pressAction?.id ? Number(detail.pressAction.id.replace('btn_', '')) : NaN;
    const button = buttons[index];
    // if (button?.type === 'copy') Clipboard.setString(button.value ?? ''); ...
  }
}

4.5. Track read — user đọc thông báo trong màn hình inbox

cdp.trackNotificationRead(trackingId, { currentScreen: 'NotificationInboxScreen' });

4.6. Tự động hoá toàn bộ (tuỳ chọn)

Thay vì tự viết handler và gọi từng hàm tracking như 4.1–4.4, có thể để SDK làm hết — parse, lọc theo data.source (mặc định 'VC_CRM', đổi qua notificationSource), track delivered/open/click/dismiss, và hiển thị banner nếu autoDisplayNotification: true. Chọn cách này thì bỏ hẳn phần gọi thủ công ở 4.1–4.4 cho message thuộc phạm vi SDK — dùng cả hai sẽ track trùng sự kiện.

Yêu cầu cài @react-native-firebase/messaging (bắt buộc — cần để nhận event push) và @notifee/react-native (tuỳ chọn — chỉ cần nếu bật autoDisplayNotification).

index.js — background/kill (bắt buộc đăng ký ở top-level, trước AppRegistry.registerComponent):

import messaging from '@react-native-firebase/messaging';
import notifee from '@notifee/react-native';
import { CdpRnSdk } from 'vc-cdp-rn-sdk';
import { cdpConfig } from './src/cdpConfig';

messaging().setBackgroundMessageHandler(CdpRnSdk.createBackgroundMessageHandler(cdpConfig));
notifee.onBackgroundEvent(CdpRnSdk.createNotifeeBackgroundEventHandler(cdpConfig));

⚠️ App đã tích hợp sẵn SDK push khác (MoEngage, OneSignal...) — kể cả khi SDK đó cũng dùng @notifee/react-native: cả messaging().setBackgroundMessageHandler()notifee.onBackgroundEvent() chỉ nhận ĐÚNG 1 handler — gọi lần thứ 2 (ở bất kỳ đâu trong app, kể cả bên trong 1 SDK khác) sẽ ghi đè im lặng lần gọi trước, không có cảnh báo. Đây là giới hạn của chính 2 API đó (notifee.onBackgroundEvent() implementation thật chỉ là gán biến module-level backgroundEventHandler = observer, không phải mảng listener), không phải của SDK này. Handler CDP tự lọc theo data.source/data.trackingId nên an toàn khi gộp — chỉ cần đảm bảo chỉ 1 lời gọi cho mỗi API:

messaging().setBackgroundMessageHandler(async message => {
  await CdpRnSdk.createBackgroundMessageHandler(cdpConfig)(message);
  await otherSdkBackgroundHandler(message);
});
notifee.onBackgroundEvent(async event => {
  await CdpRnSdk.createNotifeeBackgroundEventHandler(cdpConfig)(event);
  await otherSdkNotifeeBackgroundHandler(event);
});

notifee.onForegroundEvent() (dùng trong attachAutoNotificationHandling() bên dưới) không có giới hạn này — cài qua NativeEventEmitter.addListener(), nhiều nơi đăng ký độc lập vẫn nhận đủ sự kiện.

App.tsx — foreground (gọi một lần, sau khi có cdp):

useEffect(() => {
  const unsubscribe = cdp.attachAutoNotificationHandling();
  return unsubscribe;
}, []);

Hàm này tự đăng ký messaging().onMessage() (track delivered + hiển thị), messaging().onNotificationOpenedApp() / getInitialNotification() (track open), và notifee.onForegroundEvent() (track click/dismiss cho banner do SDK hiển thị).

Callback cho App tự xử lý (điều hướng, custom logic...)

SDK tự track xong mới gọi callback tương ứng — App không cần tự gọi lại trackNotification<Event>() bên trong. Truyền callbacks vào cả 3 hàm ở trên (cùng shape NotificationAutoHandlingCallbacks, đều tuỳ chọn):

import type { NotificationAutoHandlingCallbacks } from 'vc-cdp-rn-sdk';

const notificationCallbacks: NotificationAutoHandlingCallbacks = {
  // Sau khi track `delivered` + hiển thị (nếu bật autoDisplayNotification).
  onDelivered: (parsed, displayed) => {
    console.log('delivered', parsed.trackingId, 'displayed:', displayed);
  },

  // Sau khi track `open` — dùng để điều hướng theo clickAction.
  onOpen: (parsed) => {
    if (parsed.clickAction) navigation.navigate('WebView', { url: parsed.clickAction });
  },

  // Sau khi track `click` — info.button chỉ có nếu user nhấn nút hành động
  // (không có khi nhấn thân banner). Tự xử lý theo button.type (quy ước CRM).
  onClick: (info) => {
    if (info.button?.type === 'copy') {
      Clipboard.setString(info.button.value ?? '');
    } else if (info.button?.type === 'deeplink' && info.button.value) {
      Linking.openURL(info.button.value);
    } else if (info.clickAction) {
      navigation.navigate('WebView', { url: info.clickAction }); // nhấn thân banner
    }
  },

  // Sau khi track `dismiss` (vuốt bỏ banner).
  onDismiss: (info) => {
    console.log('dismissed', info.trackingId);
  },
};

// index.js
messaging().setBackgroundMessageHandler(
  CdpRnSdk.createBackgroundMessageHandler(cdpConfig, notificationCallbacks),
);
notifee.onBackgroundEvent(
  CdpRnSdk.createNotifeeBackgroundEventHandler(cdpConfig, notificationCallbacks),
);

// App.tsx
useEffect(() => {
  const unsubscribe = cdp.attachAutoNotificationHandling(notificationCallbacks);
  return unsubscribe;
}, []);

onDelivered/onOpen nhận parsed: ParsedPushNotification đầy đủ (từ parsePushNotification() trên message FCM gốc). onClick/onDismiss chỉ nhận NotificationInteractionInfo (trackingId, clickAction, buttonId, button) — vì tại thời điểm này message FCM gốc không còn, chỉ còn lại phần SDK đã lưu kèm trong banner Notifee lúc hiển thị. Lỗi trong callback được SDK bắt và log (debug: true), không làm gián đoạn phần track.

Payload có kèm notification (như mẫu ở mục 1) khiến Android/iOS tự hiển thị banner mặc định của hệ điều hành khi app ở nền/bị kill, độc lập với displayNotification() — không cách nào ở app/SDK ngăn được. createBackgroundMessageHandler()/CdpMessagingReceiver (native Android) vẫn chủ động vẽ banner đầy đủ (ảnh, action buttons, track click/dismiss) như bình thường thay vì bỏ qua, vì banner OS tự vẽ không có các tính năng này — bỏ vẽ không hết trùng lặp mà chỉ mất thêm tính năng. Cấu hình CRM gửi data-only (bỏ field notification) là cách duy nhất hết trùng lặp hoàn toàn mà vẫn giữ đủ tính năng.

4.7. Độ bền khi mất mạng và app bị kill giữa chừng

Áp dụng cho cả mục 4.1–4.6. trackNotificationEvent() (và các wrapper trackNotification<Event>()) không chỉ gọi fetch() một lần rồi thôi — trước khi gọi network, SDK ghi một bản ghi "pending" xuống AsyncStorage; chỉ xoá bản ghi đó sau khi server xác nhận thành công. Nếu app bị kill giữa lúc đang chờ response (rất hay gặp trong Headless JS), bản ghi vẫn còn trên đĩa. Lần CdpRnSdk.create() kế tiếp — app mở lại bình thường, hoặc một Headless JS wake khác — sẽ tự thử gửi lại toàn bộ bản ghi còn sót (tối đa 5 lần thử/bản ghi, tối đa 50 bản ghi lưu cùng lúc). App không cần làm gì thêm, cơ chế này luôn bật.

Tương tự, event từ track() (batch queue — Batch Processing) cũng được ghi xuống AsyncStorage ngay khi enqueue, xoá khi server xác nhận — nếu app bị kill khi đang chờ đủ batchSize hoặc chờ batchInterval, event không mất, được khôi phục và gửi lại ở lần create() kế tiếp.

Giới hạn: cơ chế trên bảo vệ khỏi việc mất event khi request đang treo hoặc app bị kill, nhưng không khoá giữa nhiều instance CdpRnSdk cùng tồn tại song song (vd một Headless JS task tạo instance mới trong khi app vẫn còn một instance khác đang chạy nền) — hai instance ghi đè AsyncStorage gần như đồng thời có thể làm mất bản ghi pending của nhau. Trường hợp này hiếm (cần 2 instance thao tác đúng lúc mili-giây với nhau) và không ảnh hưởng tới lần gọi network đầu tiên (vẫn gửi bình thường) — chỉ ảnh hưởng tới việc bản ghi retry có được lưu đúng hay không nếu lần gọi đầu đó thất bại.

5. API tracking

Tất cả các hàm dưới đây gọi chung POST {notificationApiBaseUrl}/api/v1/notification-inbox/track-by-tracking-id với event_type tương ứng. trackNotificationEvent() là hàm gốc; các hàm trackNotification<Event>() chỉ là wrapper tiện dụng.

🆕 MỚI — song song với request notification-inbox ở trên, mỗi lần gọi cũng tự động track() một event tương ứng sang CDP event pipeline chính (POST {baseUrl}/v2/events/track hoặc /v2/events/batch, tuỳ batchSize — xem Batch Processing), theo bảng ánh xạ sau:

| eventType | CDP event_name | Ý nghĩa | | ------------ | ------------------------- | ------------------------------------------ | | delivered | notification_delivered | Thông báo đã được gửi/đến thiết bị | | open | notification_opened | User mở thông báo | | click | notification_clicked | User click vào thông báo | | dismiss | notification_dismissed | User đóng/bỏ qua thông báo | | read | notification_read | User đã đọc nội dung |

properties của event này gồm tracking_id, button_id, deeplink, current_screenmetadata (lấy từ options truyền vào trackNotificationEvent()/wrapper). Việc gửi CDP event này độc lập với request notification-inbox ở trên — dùng cơ chế enqueue/persist/ retry riêng của track() (mục 4.7), nên vẫn được gửi (và giữ lại để retry nếu mất mạng/app bị kill) kể cả khi request notification-inbox thất bại, và ngược lại.

Bảng ánh xạ trên áp dụng cho đường JS (trackNotificationEvent()/wrapper). Khi bật nativeNotificationHandling (Android) hoặc iosAppGroupId/iosNativeDismissTracking (iOS, mục 8), delivered/click/dismiss track hoàn toàn ở tầng native (không qua JS) cũng tự push CDP event tương ứng — xem ghi chú "CDP event song song ở native" ở mục 8.

async trackNotificationEvent(
  trackingId: string,
  eventType:  'delivered' | 'open' | 'click' | 'dismiss' | 'read',
  options?: {
    userAppId?:     string;                  // mặc định: user_id hiện tại (identifyUser), '' nếu chưa login
    deviceId?:      string;                  // mặc định: device_id của SDK
    platform?:      string;                  // mặc định: Platform.OS ('android' | 'ios')
    buttonId?:      string;
    deeplink?:      string;
    currentScreen?: string;
    metadata?:      Record<string, unknown>; // tối đa 10KB
  }
): Promise<{ success?: boolean; code?: number | string; message?: string; data?: { event_id?: number } } | undefined>

// Wrapper tiện dụng — mỗi hàm chỉ gọi trackNotificationEvent() với eventType tương ứng
async trackNotificationDelivered(trackingId: string, options?: NotificationTrackOptions): Promise<NotificationTrackResponse | undefined>
async trackNotificationOpen(trackingId: string, options?: NotificationTrackOptions): Promise<NotificationTrackResponse | undefined>
async trackNotificationClick(trackingId: string, options?: NotificationTrackOptions): Promise<NotificationTrackResponse | undefined>
async trackNotificationRead(trackingId: string, options?: NotificationTrackOptions): Promise<NotificationTrackResponse | undefined>
async trackNotificationDismiss(trackingId: string, options?: NotificationTrackOptions): Promise<NotificationTrackResponse | undefined>

static parsePushNotification(message: PushNotificationMessage): ParsedPushNotification

// Hiển thị banner bằng Notifee nếu autoDisplayNotification: true trong config,
// ngược lại không làm gì và trả về false (App tự hiển thị) — xem mục 4.
async displayNotification(
  parsed: ParsedPushNotification,
  options?: DisplayNotificationOptions,
): Promise<boolean>

interface DisplayNotificationOptions {
  // Bỏ qua vẽ qua Notifee nếu payload có field `notification` (OS sẽ tự hiển thị banner
  // của nó) — mặc định KHÔNG bật, kể cả trong createBackgroundMessageHandler(). Chỉ dùng
  // nếu chấp nhận đánh đổi mất ảnh/action buttons/dismiss-click tracking (Notifee không
  // tạo ra banner đó nên không track được sự kiện của nó) để đổi lấy hết trùng lặp banner
  // ngay lập tức, không cần đổi payload CRM — xem cảnh báo mục 4.1.
  suppressIfOsWillDisplay?: boolean;
}

// Tự động hoá toàn bộ (tuỳ chọn) — xem mục 4.6. `callbacks` đều tuỳ chọn.
static createBackgroundMessageHandler(
  config: CdpRnConfig,
  callbacks?: NotificationAutoHandlingCallbacks,
): (message: PushNotificationMessage) => Promise<void>

static createNotifeeBackgroundEventHandler(
  config: CdpRnConfig,
  callbacks?: NotificationAutoHandlingCallbacks,
): (event: {
  type: number;
  detail: { notification?: { data?: Record<string, unknown> }; pressAction?: { id: string } };
}) => Promise<void>

attachAutoNotificationHandling(callbacks?: NotificationAutoHandlingCallbacks): () => void // trả về hàm unsubscribe

6. Danh sách, chi tiết, đánh dấu đọc và xoá thông báo

getNotificationInboxList() — danh sách thông báo, phân trang bằng cursor:

const page1 = await cdp.getNotificationInboxList({ filter: 'all', perPage: 20 });

console.log(page1?.data);         // NotificationInboxItem[]
console.log(page1?.next_cursor);  // dùng cho trang tiếp theo

const page2 = await cdp.getNotificationInboxList({
  filter: 'all',
  perPage: 20,
  cursor: page1?.next_cursor ?? undefined,
});

getNotificationInboxDetail() — chi tiết 1 thông báo theo tracking_id:

const detail = await cdp.getNotificationInboxDetail('b7e6c1a2-...');
console.log(detail?.data);

⚠ Server hiện trả CTA không nhất quán giữa 2 endpoint: item trong getNotificationInboxList()cta_text / cta_deeplink phẳng, còn getNotificationInboxDetail() lồng trong cta: { text, deeplink }. Đọc field tương ứng theo đúng method đang gọi.

markAllNotificationsRead() — đánh dấu tất cả thông báo trong inbox là đã đọc. userAppId là tham số bắt buộc (API yêu cầu phải có user_app_id hoặc device_id; device_id tự lấy từ SDK nên chỉ cần truyền userAppId):

const result = await cdp.markAllNotificationsRead('user_014');
console.log(result?.data?.updated_count);

deleteAllNotifications() — xoá toàn bộ thông báo trong inbox của user. userAppId cũng là tham số bắt buộc:

const result = await cdp.deleteAllNotifications('user_014');
console.log(result?.data?.deleted_count);

deleteNotificationByTrackingId() — xoá 1 thông báo theo tracking_id:

await cdp.deleteNotificationByTrackingId('b7e6c1a2-...');

userAppId / userProfileId / deviceId tự lấy từ state hiện tại của SDK (identifyUser() / getDeviceId()) nếu không truyền vào — giống trackNotificationEvent().

async getNotificationInboxList(options?: {
  userAppId?:     string;   // mặc định: user_id hiện tại, '' nếu chưa login
  userProfileId?: string;   // mặc định: profile_id hiện tại, '' nếu chưa có
  deviceId?:      string;   // mặc định: device_id của SDK
  filter?:        string;   // mặc định 'all'
  perPage?:       number;   // mặc định 20 (1–100)
  cursor?:        string;   // phân trang — lấy từ next_cursor
}): Promise<{
  success?: boolean;
  code?: number | string;
  message?: string;
  data?: NotificationInboxItem[];
  next_cursor?: string | null;
  has_more?: boolean;
  unread_count?: number;
} | undefined>

async getNotificationInboxDetail(
  trackingId: string,
  options?: { userAppId?: string; deviceId?: string }
): Promise<{ success?: boolean; code?: number | string; message?: string; data?: NotificationInboxItem } | undefined>

async markAllNotificationsRead(
  userAppId: string,
  options?: { deviceId?: string }
): Promise<{ success?: boolean; code?: number | string; message?: string; data?: { updated_count?: number } } | undefined>

async deleteAllNotifications(
  userAppId: string,
  options?: { deviceId?: string }
): Promise<{ success?: boolean; code?: number | string; message?: string; data?: unknown[] } | undefined>

async deleteNotificationByTrackingId(
  trackingId: string,
  options?: { userAppId?: string; deviceId?: string }
): Promise<{ success?: boolean; code?: number | string; message?: string; data?: unknown[] } | undefined>

| Method | Endpoint | |---|---| | getNotificationInboxList() | POST {notificationApiBaseUrl}/api/v1/notification-inbox/list | | getNotificationInboxDetail() | POST {notificationApiBaseUrl}/api/v1/notification-inbox/by-tracking-id | | markAllNotificationsRead() | POST {notificationApiBaseUrl}/api/v1/notification-inbox/mark-all-read | | deleteAllNotifications() | POST {notificationApiBaseUrl}/api/v1/notification-inbox/delete-all | | deleteNotificationByTrackingId() | POST {notificationApiBaseUrl}/api/v1/notification-inbox/delete-by-tracking-id | | trackNotificationEvent() (và các wrapper) | POST {notificationApiBaseUrl}/api/v1/notification-inbox/track-by-tracking-id |

Cả 6 endpoint đều là POST (kể cả các thao tác đọc/liệt kê) — mọi tham số nằm trong body JSON. Xác thực bằng chữ ký HMAC-SHA256 riêng, khác cơ chế dùng cho API ingest event/profile (xem Bảo mật).

7. Cấu hình native cho push nền (Android & iOS)

Để trackNotificationDelivered() / open / click / dismissautoDisplayNotification hoạt động đúng khi app đang ở nền hoặc bị kill, ngoài việc cài package qua npm/pod (@react-native-firebase/messaging + @notifee/react-native), App cần cấu hình thêm ở phía native. Đây là yêu cầu chuẩn của bản thân các thư viện push này (không phải riêng vc-cdp-rn-sdk), nhưng rất dễ bị bỏ sót khi tích hợp.

7.1. Android

  1. Tạo project Firebase (nếu chưa có), tải file cấu hình và đặt đúng vị trí:

    android/app/google-services.json
  2. Áp dụng Google Services Gradle plugin:

    android/build.gradle:

    buildscript {
      dependencies {
        classpath 'com.google.gms:google-services:4.4.2'
      }
    }

    android/app/build.gradle (thêm ở cuối file):

    apply plugin: 'com.google.gms.google-services'
  3. Android 13+ (API 33) cần quyền POST_NOTIFICATIONS. RNFB/Notifee thường tự merge quyền này vào AndroidManifest.xml qua autolinking, nhưng App vẫn phải chủ động xin runtime permission — thiếu bước này thì autoDisplayNotification: true sẽ không hiển thị được banner (dù delivered vẫn track bình thường, vì OS âm thầm bỏ qua lệnh hiển thị khi thiếu quyền):

    import { PermissionsAndroid, Platform } from 'react-native';
    
    if (Platform.OS === 'android' && Platform.Version >= 33) {
      await PermissionsAndroid.request(PermissionsAndroid.PERMISSIONS.POST_NOTIFICATIONS);
    }
  4. Không cần sửa AndroidManifest.xml / mã Java-Kotlin thủ công cho phần nhận message nền — messaging().setBackgroundMessageHandler() chạy qua headless JS task mà @react-native-firebase/messaging tự đăng ký sẵn khi autolinking.

  5. Nếu build release có bật ProGuard/R8, kiểm tra android/app/proguard-rules.pro đã đủ rule cho Firebase/Notifee — các phiên bản plugin mới thường tự kèm consumer-rules.pro nên hiếm khi phải tự viết thêm, nhưng vẫn nên build thử bản release để chắc chắn tracking không bị strip nhầm.

Không có giới hạn như iOS: trên Android, FCM data message vẫn đánh thức được app kể cả khi bị kill hoàn toàn (user vuốt khỏi recent apps) — xem mục 7.2 để so sánh với giới hạn của iOS.

7.2. iOS

Phần này bắt buộc sửa native/Xcode — không chỉ cài package qua npm/pod:

  1. Mở project trong Xcode → chọn target app → tab Signing & Capabilities:

    • Bấm + Capability → thêm Push Notifications.
    • Bấm + Capability → thêm Background Modes → tick Remote notifications.

    Thiếu Background Modes → Remote notifications, hệ điều hành sẽ không đánh thức app để chạy background handler khi app ở nền/bị kill bởi OS — dù payload đã có contentAvailable: true (đã có sẵn trong payload mẫu CRM, xem mục 1).

  2. Kéo file GoogleService-Info.plist (tải từ Firebase Console) vào Xcode Project Navigator, tick "Copy items if needed" và chọn đúng target. Chỉ copy file vào thư mục bằng Finder mà không add qua Xcode sẽ khiến app không đọc được file này lúc runtime.

  3. Cấu hình APNs Authentication Key (.p8) trong Firebase Console → Project settings → Cloud Messaging → Apple app configuration → APNs Authentication Key. Thiếu bước này, FCM không thể chuyển tiếp push xuống thiết bị iOS dù cấu hình phía app đã đúng hết.

  4. Với các bản @react-native-firebase/app mới (autolinking), thường không cần sửa thêm AppDelegate. Nếu dự án dùng bản cũ hơn hoặc theo hướng dẫn cài đặt tuỳ biến riêng, kiểm tra lại tài liệu cài đặt chính thức của @react-native-firebase theo đúng version đang dùng — bước này thay đổi khá nhiều giữa các major version nên không liệt kê cứng code ở đây để tránh sai lệch.

  5. Xin quyền hiển thị thông báo — chỉ cần nếu muốn banner hiển thị, không ảnh hưởng tới việc track delivered ở nền (silent/content-available push vẫn được giao độc lập với quyền notification):

    import messaging from '@react-native-firebase/messaging';
    await messaging().requestPermission();

Lỗi thật thường gặp: messaging/unregistered khi gọi messaging().getToken() (để lấy token cho setPushToken()). requestPermission() chỉ xin quyền hiển thị, KHÔNG đảm bảo app đã hoàn tất đăng ký APNs — gọi getToken() ngay sau đó có thể vẫn lỗi nếu registerForRemoteNotifications() (native) chưa kịp gọi callback. Gọi thêm messaging().registerDeviceForRemoteMessages() cho chắc (no-op an toàn trên Android):

await messaging().requestPermission();
await messaging().registerDeviceForRemoteMessages();
const token = await messaging().getToken();
cdp.setPushToken(token);

Nguyên nhân phổ biến hơn nếu lỗi vẫn còn sau khi gọi cả 2 hàm trên: target app thiếu hẳn capability Push Notifications (entitlement aps-environment) — thiếu capability này thì registerForRemoteNotifications() ở tầng native không bao giờ hoàn tất bất kể gọi hàm gì ở JS. Kiểm tra Xcode → target app chính → Signing & Capabilities.

⚠️ Giới hạn không thể khắc phục bằng cấu hình: nếu user tự vuốt tắt app khỏi danh sách app đang chạy (force-quit), Apple chặn hoàn toàn mọi background wake-up — kể cả silent push (content-available: true). trackNotificationDelivered() sẽ không chạy trong trường hợp này, và không có cấu hình native nào khắc phục được — đây là hành vi cố định của iOS, khác với Android (mục 7.1) không bị giới hạn này.

7.3. Kiểm tra nhanh

  • Bật debug: true trong config để xem log [CDP RN SDK] khi test.
  • Gửi thử một push khi app đang ở background (không force-quit) → kiểm tra trackNotificationDelivered() có chạy.
  • Gửi thử khi app bị kill do OS (không phải user tự tắt) → Android nên vẫn nhận được; iOS phụ thuộc chính sách quản lý pin/bộ nhớ của hệ điều hành, có thể bị trễ hoặc gộp.
  • Luôn test trên thiết bị thật — hành vi push khi app ở nền/bị kill không đáng tin cậy trên simulator/emulator, đặc biệt iOS Simulator không nhận được push thật (cần giả lập bằng file APNs payload qua Xcode).

7.4. Tránh trùng lặp banner trên iOS

Nếu payload CRM có kèm apns.payload.aps.alert (thường đi cùng mutable-content: 1 để NSE chạy được), iOS tự vẽ thêm 1 banner mặc định ở background/kill — độc lập hoàn toàn với banner do Notifee vẽ (xem cảnh báo ở mục 4.1). Có 2 hướng xử lý, không loại trừ nhau:

  1. Tốt nhất — đổi payload phía CRM: gửi data-only (bỏ hẳn field notification/aps.alert). Hết trùng lặp hoàn toàn, không cần đổi gì ở app. Đánh đổi: tắt luôn NSE (NSE bắt buộc cần alert mới chạy), nên chỉ áp dụng nếu không cần track delivered lúc force-quit.

  2. Xử lý ở phía app — autoSuppressWhenOsWillDisplay: true: nếu không đổi được payload CRM (vd vẫn cần alert để NSE chạy), bật cờ này trong config — createBackgroundMessageHandler() sẽ tự bỏ qua vẽ Notifee cho đúng message có notification/aps.alert, chỉ còn 1 banner (do OS/NSE vẽ). Không có tác dụng trên Android (không áp dụng) và không áp dụng cho foreground (OS không tự vẽ gì khi app đang mở, nên foreground luôn phải tự vẽ qua Notifee).

    const cdpConfig: CdpRnConfig = {
      // ...
      iosAppGroupId: 'group.your.bundle.id',
      autoSuppressWhenOsWillDisplay: true,
    };

    Đánh đổi: banner còn lại (do OS/NSE vẽ, không phải do Notifee tạo ra) sẽ không track được dismiss qua Notifee nữa — delivered vẫn có (qua NSE nếu đã cấu hình iosAppGroupId), click/open vẫn có (qua messaging().onNotificationOpenedApp()).

⚠️ Message có action buttons (data.buttons) — hành vi phụ thuộc iosAppGroupId. Nếu iosAppGroupId đã cấu hình VÀ payload có mutable-content: 1 (NSE chạy): NSE tự đăng ký action buttons thật cho banner OS tự vẽ (xem mục 8.5) — banner OS đã có đủ nút, nên autoSuppressWhenOsWillDisplay: true suppress bình thường, chỉ còn 1 banner, không mất nút. Nếu chưa cấu hình iosAppGroupId (hoặc NSE không chạy): banner OS tự vẽ không có cách nào có nút (không ai đăng ký action cho nó) — displayNotification() tự phát hiện qua parsed.buttons.length và bỏ qua suppress trong trường hợp này, vẫn vẽ qua Notifee để giữ nút, chấp nhận 2 banner tạm thời (1 do OS vẽ không nút, 1 do Notifee vẽ có đủ nút) — mất nút hành động là đánh đổi nặng hơn nhiều so với việc né trùng lặp. Không cần App tự xử lý gì thêm ở cả 2 trường hợp.

⚠️ Lỗi thật đã gặp: dismiss trên banner do OS tự vẽ bị track nhầm thành open. Nguyên nhân nằm ở @react-native-firebase/messaging, không phải ở SDK này: khi Notifee nhận didReceiveNotificationResponse cho 1 notification không phải do chính nó tạo ra (đúng trường hợp banner OS tự vẽ từ aps.alert), Notifee forward nguyên response đó — kể cả dismiss — sang delegate gốc mà nó bọc quanh (thường là RNFB, xem NotifeeCore+UNUserNotificationCenter.m, hàm didReceiveNotificationResponse). RNFB (RNFBMessaging+UNUserNotificationCenter.m, cùng hàm) chỉ kiểm tra gcm.message_id trước khi bắn messaging_notification_openedkhông kiểm tra response.actionIdentifier — nên coi cả swipe-dismiss là "user mở app từ thông báo", khiến attachAutoNotificationHandling()/createBackgroundMessageHandler() gọi nhầm trackNotificationOpen(). Bug lặp lại cho từng thông báo khi user "Clear All" nhiều thông báo cùng lúc.

Cách khắc phục triệt để: patch native @react-native-firebase/messaging (qua patch-package hoặc bun patch nếu project dùng Bun) — thêm điều kiện bỏ qua khi response.actionIdentifierUNNotificationDismissActionIdentifier:

// ios/RNFBMessaging/RNFBMessaging+UNUserNotificationCenter.m
NSDictionary *remoteNotification = response.notification.request.content.userInfo;
BOOL isDismissAction =
    [response.actionIdentifier isEqualToString:UNNotificationDismissActionIdentifier];
if (remoteNotification[@"gcm.message_id"] && !isDismissAction) {
  // ... (giữ nguyên phần còn lại)
}

Patch native .m — sau khi sửa/generate patch, phải rebuild app qua Xcode thật (pod install rồi build lại), Metro reload không đủ. Dùng autoSuppressWhenOsWillDisplay (mục trên) chỉ giảm bề mặt xảy ra bug (còn 1 banner thay vì 2), không thay thế được patch này — banner OS/NSE còn lại vẫn đi qua đúng đường lỗi này khi bị dismiss.

8. Native auto-handling (độ tin cậy cao nhất)

Mục 4 dựa vào Headless JS (Android) / background fetch JS (iOS) — cả hai đều không đáng tin cậy 100%:

  • Android: nhiều ROM OEM (MIUI, ColorOS, FuntouchOS, EMUI, One UI ở chế độ tiết kiệm pin...) diệt Headless JS task trước khi kịp chạy xong, nếu app không nằm trong danh sách autostart/chạy nền của máy đó. Banner vẫn hiện được (OS tự vẽ nếu payload có field notification) nhưng delivered/click/dismiss không track được — tuỳ theo từng máy, không phải lỗi chung.
  • iOS: nếu user tự force-quit app, Apple chặn hoàn toàn mọi background wake-up kể cả silent push — không có cấu hình nào khắc phục được (mục 7.2). Ngay cả khi không force-quit, việc app có được đánh thức hay không còn phụ thuộc switch Background App Refresh (bật/tắt theo từng máy, độc lập với quyền hiển thị notification).

nativeNotificationHandling (Android) + iosAppGroupId (iOS) giải quyết phần lớn các trường hợp trên bằng cách track hoàn toàn ở tầng native, không qua JS/Headless JS:

| | Android (nativeNotificationHandling: true) | iOS (iosAppGroupId) | |---|---|---| | delivered | ✅ Native, không qua Headless JS | ✅ Qua Notification Service Extension — chạy được kể cả khi app bị force-quit | | click (thân banner) | ✅ Native (banner tự vẽ, không qua Notifee) | ❌ Vẫn cần JS (attachAutoNotificationHandling/mục 4.3) | | click (nút hành động) | ✅ Native | ✅ Native nếu message có buttons — NSE tự đăng ký + xử lý (mục 8.5), fallback qua Notifee/JS nếu không | | dismiss | ✅ Native | ✅ Qua NSE + Notifee (xem ghi chú bên dưới), hoặc ✅ hoàn toàn native với iosNativeDismissTracking (mục 8.4) | | Ảnh khi payload có alert (banner do OS tự vẽ, không qua Notifee) | N/A — banner luôn do CdpNotificationDisplay vẽ, có ảnh sẵn | ✅ NSE tự tải và gắn UNNotificationAttachment (mục 8.3) |

Đây là tính năng opt-in, không bật thì hành vi SDK không đổi so với mục 4.

🆕 MỚI — CDP event song song ở native: mọi ô ✅ ở bảng trên (track hoàn toàn native, không qua JS) cũng tự push notification_delivered/notification_clicked/notification_dismissed sang CDP event pipeline chính (/v2/events/track), giống hệt hành vi JS mô tả ở mục 5 — xem CdpEventApi.kt/`Cdp