vc-cdp-rn-sdk
v0.4.8
Published
CDP SDK for React Native - Customer Data Platform tracking for iOS and Android
Maintainers
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ả iOS và Android.
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
- Khởi tạo
- Bắt đầu nhanh
- Luồng hoạt động
- User / Profile
- Event Tracking
- Notification Tracking
- Bảo mật
- Tích hợp React Navigation đầy đủ
- API Reference
Cài đặt
npm install vc-cdp-rn-sdkPeer 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-infoiOS
cd ios && pod installpod 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ạosession_idmớ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ước2. 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 eventStorage 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ọiidentifyUser()/add_email()...) — API từ chối request vớitraitsrỗ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ọisetPushToken()sớm (vd ngay sauCdpRnSdk.create(), trước khi user login), token chỉ được lưu vào state cục bộ — vẫn tự động đi kèmplatforms.push_tokenở lầnidentifyUser()/ 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-handling — mụ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ùngattachAutoNotificationHandling()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êmtrackNotification<Event>()thủ công cho cùng message (sẽ trùng), và banner App tự vẽ dùng đúngdata: { trackingId, clickAction }như mục 4.4 để listenerclick/dismisscủ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ọiparsePushNotification()/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 tradata.sourceđã được SDK tự làm bên trong (đổi giá trị so sánh quanotificationSourcetrong 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 traremoteMessage.data?.sourcetrước khi gọiparsePushNotification()/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 quagetCdp()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ằngCdpRnSdk.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,
// }parsePushNotification là static, 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 fieldnotification(như mẫu ở mục 1, hoặc trên iOS làapns.payload.aps.alert— RNFB tự map field này vàoremoteMessage.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. Khidebug: 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 fieldnotification/aps.alert) — trên iOS việc này cũng tắt luôn NSE (mục 8.3 — NSE cầnalertmới chạy được), nên chỉ áp dụng nếu không cần trackdeliveredlúc app bị force-quit. Nếu không đổi được payload CRM, bậtautoSuppressWhenOsWillDisplay: truetrong 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 trackingdismissqua Notifee cho banner đó. Xem chi tiết + một bug thật liên quan (dismiss bị track nhầm thànhopen) ở mục 7.4. Mặc địnhautoSuppressWhenOsWillDisplaylàfalse— 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' }là 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à channelimportance: HIGH(để banner pop-up thay vì chỉ vào tray) đều cần cấu hình đúng field — xem code thật trongCdpRnSdk.displayNotification()(src/CdpRnSdk.ts) để copy chính xác. Nếu không cần tuỳ biến UI riêng, bậtautoDisplayNotification: truesẽ đỡ 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()và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-levelbackgroundEventHandler = observer, không phải mảng listener), không phải của SDK này. Handler CDP tự lọc theodata.source/data.trackingIdnê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 trongattachAutoNotificationHandling()bên dưới) không có giới hạn này — cài quaNativeEventEmitter.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/onOpennhậnparsed: ParsedPushNotificationđầy đủ (từparsePushNotification()trên message FCM gốc).onClick/onDismisschỉ nhậnNotificationInteractionInfo(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ớidisplayNotification()— 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ỏ fieldnotification) 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
CdpRnSdkcù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/trackhoặ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_screen và
metadata (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ậtnativeNotificationHandling(Android) hoặciosAppGroupId/iosNativeDismissTracking(iOS, mục 8),delivered/click/dismisstrack 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 unsubscribe6. 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()cócta_text/cta_deeplinkphẳng, còngetNotificationInboxDetail()lồng trongcta: { 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 / dismiss và
autoDisplayNotification 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
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Á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'Android 13+ (API 33) cần quyền
POST_NOTIFICATIONS. RNFB/Notifee thường tự merge quyền này vàoAndroidManifest.xmlqua autolinking, nhưng App vẫn phải chủ động xin runtime permission — thiếu bước này thìautoDisplayNotification: truesẽ không hiển thị được banner (dùdeliveredvẫ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); }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/messagingtự đăng ký sẵn khi autolinking.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èmconsumer-rules.pronê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:
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).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.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.
Với các bản
@react-native-firebase/appmới (autolinking), thường không cần sửa thêmAppDelegate. 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-firebasetheo đú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.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-availablepush 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/unregisteredkhi gọimessaging().getToken()(để lấy token chosetPushToken()).requestPermission()chỉ xin quyền hiển thị, KHÔNG đảm bảo app đã hoàn tất đăng ký APNs — gọigetToken()ngay sau đó có thể vẫn lỗi nếuregisterForRemoteNotifications()(native) chưa kịp gọi callback. Gọi thêmmessaging().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: truetrong 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:
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ầnalertmới chạy), nên chỉ áp dụng nếu không cần trackdeliveredlúc force-quit.Xử lý ở phía app —
autoSuppressWhenOsWillDisplay: true: nếu không đổi được payload CRM (vd vẫn cầnalertđể 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
dismissqua Notifee nữa —deliveredvẫn có (qua NSE nếu đã cấu hìnhiosAppGroupId),click/openvẫn có (quamessaging().onNotificationOpenedApp()).
⚠️ Message có action buttons (
data.buttons) — hành vi phụ thuộciosAppGroupId. NếuiosAppGroupIdđã 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ênautoSuppressWhenOsWillDisplay: truesuppress bình thường, chỉ còn 1 banner, không mất nút. Nếu chưa cấu hìnhiosAppGroupId(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 quaparsed.buttons.lengthvà 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ậndidReceiveNotificationResponsecho 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, xemNotifeeCore+UNUserNotificationCenter.m, hàmdidReceiveNotificationResponse). RNFB (RNFBMessaging+UNUserNotificationCenter.m, cùng hàm) chỉ kiểm tragcm.message_idtrước khi bắnmessaging_notification_opened— không kiểm traresponse.actionIdentifier— nên coi cả swipe-dismiss là "user mở app từ thông báo", khiếnattachAutoNotificationHandling()/createBackgroundMessageHandler()gọi nhầmtrackNotificationOpen(). 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(quapatch-packagehoặcbun patchnếu project dùng Bun) — thêm điều kiện bỏ qua khiresponse.actionIdentifierlàUNNotificationDismissActionIdentifier:// 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 installrồi build lại), Metro reload không đủ. DùngautoSuppressWhenOsWillDisplay(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ưngdelivered/click/dismisskhô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_dismissedsang CDP event pipeline chính (/v2/events/track), giống hệt hành vi JS mô tả ở mục 5 — xemCdpEventApi.kt/`Cdp
