Identity and consent
Every ContentFlow install has a device id. Identify links that device to your user id and records traits. Two separate consent decisions sit on top: analytics consent decides what ContentFlow may store about the person, and channel consent decides what may be sent to them on each channel. This page is the one place that explains both, plus sign-out and erasure.
- An install is anonymous until you identify it with your user id:
setUserIdin JavaScript and Flutter,identify(userId:)in Swift and Kotlin. - Analytics consent (
setConsent, orsetAnalyticsConsentin Swift and Kotlin) gates every event; until it is granted the SDK tracks nothing and does not replay events later. - Channel consent (
setChannelConsent) is a separate decision per channel: push, SMS, WhatsApp, email, location and marketing. Registering a push token never grants it. - On sign-out call
logout(); for the right to erasure calldeleteIdentity(), which wipes local data only after the server confirms a complete erasure. - The most common trap: setting channel consent for a signed-in user before the device is identified as that user, which is refused with
403 DEVICE_NOT_IDENTIFIED.
Anonymous and signed-in
POST /identify sends the device id, the signed-in user id when there is one, traits, and the analytics consent decision when the app has made one. Traits, segments and the user link are only written while analytics consent is granted. Identify on app start and after sign-in.
- Anonymous install: the JavaScript
start(), the Kotlinstart()and the FlutterContentFlow.create()identify the device for you without a user id. The Swift SDK registers the device on the firstidentifyorsync, so callidentify(userId: nil)for an anonymous install. - Signed-in user: after sign-in, link the install to your user id. A consented anonymous profile then merges into the signed-in one; see Anonymous to signed-in.
- Traits: an empty set means "leave traits unchanged", and sending
traits: {}never erases stored traits. In Swift and Kotlin a non-empty set replaces the stored set, so send every trait you want to keep. - Consent on identify: omitting
consentkeeps the previous decision. A non-boolean value is refused with400 consent must be a boolean when provided.
// 1. Anonymous: start() identifies the device without a user id.
await client.start();
// 2. After sign-in: link the install to your user id.
await client.setUserId(userId);
// 3. Send traits for targeting (consent omitted = unchanged).
await client.identify({ traits: { plan_tier: 'gold' } });import { useEffect } from 'react';
import { useCF } from '@contentflow/sdk/react';
// 1. Anonymous: <CFProvider> calls start(), which identifies the device without a user id.
function AfterSignIn({ userId }: { userId: string }) {
const cf = useCF();
useEffect(() => {
void (async () => {
// 2. After sign-in: link the install to your user id.
await cf.setUserId(userId);
// 3. Send traits for targeting (consent omitted = unchanged).
await cf.identify({ traits: { plan_tier: 'gold' } });
})();
}, [cf, userId]);
return null;
}import { useEffect } from 'react';
import { useCF } from '@contentflow/sdk/react-native';
// 1. Anonymous: <CFProvider> calls start(), which identifies the device without a user id.
function AfterSignIn({ userId }: { userId: string }) {
const cf = useCF();
useEffect(() => {
void (async () => {
// 2. After sign-in: link the install to your user id.
await cf.setUserId(userId);
// 3. Send traits for targeting (consent omitted = unchanged).
await cf.identify({ traits: { plan_tier: 'gold' } });
})();
}, [cf, userId]);
return null;
}// 1. Anonymous: Swift registers the device on the first identify or sync.
try await client.identify(userId: nil)
// 2 and 3. After sign-in: link the install to your user id and send traits.
// A non-empty traits set replaces the stored one.
try await client.identify(userId: userId, traits: ["plan_tier": "gold"])// 1. Anonymous: start() identifies the device without a user id.
client.start()
// 2 and 3. After sign-in, from a coroutine: link the install and send traits.
// A non-empty traits map replaces the stored one.
client.identify(userId, mapOf("plan_tier" to JsonValue.of("gold")))// 1. Anonymous: ContentFlow.create() identifies the device without a user id.
await ContentFlow.create(const ContentFlowConfig(publicKey: CONTENTFLOW_SDK_KEY));
final cf = ContentFlow.instance;
// 2. After sign-in: link the install to your user id.
await cf.setUserId(userId);
// 3. Send traits for targeting.
await cf.identify(userId: userId, traits: {'plan_tier': 'gold'});# 1. Anonymous: identify with the device id only.
curl -X POST https://api.contentflow.click/sdk/v1/identify \
-H "X-CF-Key: $CF_SDK_KEY" -H "X-CF-Device: $CF_DEVICE" -H 'Content-Type: application/json' \
-d "{\"deviceId\":\"$CF_DEVICE\"}"
# 2 and 3. After sign-in: the same call with your user id and traits.
curl -X POST https://api.contentflow.click/sdk/v1/identify \
-H "X-CF-Key: $CF_SDK_KEY" -H "X-CF-Device: $CF_DEVICE" -H 'Content-Type: application/json' \
-d "{\"deviceId\":\"$CF_DEVICE\",\"userId\":\"cust_123\",\"traits\":{\"plan_tier\":\"gold\"}}"An identify that applied the identity but could not re-link the device's push token answers 207 with success: false and code: "PUSH_LINKAGE_INCOMPLETE": keep the new identity and retry identify. Full contract: REST guide: Identify.
Two kinds of consent
| Decision | Sent with | Governs |
|---|---|---|
| Analytics consent (one yes or no per install) | identify({ consent }), setConsent() → POST /identify | Whether a profile, traits, segments, events and location are stored |
| Channel consent: push, SMS, WhatsApp, email, location, marketing | setChannelConsent() → POST /consent | Whether each channel may be used to message the person. In-app messages use marketing consent. |
| Marketing preferences | Full consent API (requestConsent → POST /consent/full) | Promotional, transactional, service updates, third-party offers |
| PDPL opt-out | Full consent API | Blocks every outbound channel |
Channel consent never turns analytics on, and analytics consent never grants a channel. Content delivery works without either. Always set both from a choice the person actually made, and never default them to granted.
Analytics consent decides what's stored
| Analytics consent | Device | Profile, traits, segments | Events and location | Content |
|---|---|---|---|---|
| Unknown (new device, no decision) | Stored | Not written | Dropped in the SDK | Untargeted (All users) content |
Denied (consent: false) | Stored | Not written; an account switch unlinks the old user and push token | Dropped; a revoke purges the SDK's queues | Untargeted content |
Granted (consent: true) | Stored | Written and kept fresh | Accepted | Targeted content |
The SDK treats a grant as effective only once the server echoes consent: true. A revoke takes effect locally at once and stays in force even if the request fails. Every failed consent write rejects or throws, including an HTTP 200 carrying success: false, so keep showing the last confirmed setting until a write succeeds.
Channel consent
Record each channel decision from your consent UI. A channel is used only when its flag is exactly true.
// 1. Analytics consent, from the person's choice.
await client.setConsent(userAcceptedAnalytics);
// 2. Signed in: identify as the user first (see above).
await client.setUserId(userId);
// 3. Channel consent, independent of analytics consent.
// With a user id it is stored on the profile; without one, on this device only.
await client.setChannelConsent(userId, { push: true, sms: false });import { useCF } from '@contentflow/sdk/react';
function ConsentForm({ userId }: { userId: string }) {
const cf = useCF();
async function save(userAcceptedAnalytics: boolean) {
// 1. Analytics consent, from the person's choice.
await cf.setConsent(userAcceptedAnalytics);
// 2. Signed in: identify as the user first (see above).
await cf.setUserId(userId);
// 3. Channel consent, independent of analytics consent.
// With a user id it is stored on the profile; without one, on this device only.
await cf.setChannelConsent(userId, { push: true, sms: false });
}
return <button onClick={() => save(true)}>Save choices</button>;
}import { Button } from 'react-native';
import { useCF } from '@contentflow/sdk/react-native';
function ConsentForm({ userId }: { userId: string }) {
const cf = useCF();
async function save(userAcceptedAnalytics: boolean) {
// 1. Analytics consent, from the person's choice.
await cf.setConsent(userAcceptedAnalytics);
// 2. Signed in: identify as the user first (see above).
await cf.setUserId(userId);
// 3. Channel consent, independent of analytics consent.
// With a user id it is stored on the profile; without one, on this device only.
await cf.setChannelConsent(userId, { push: true, sms: false });
}
return <Button title="Save choices" onPress={() => save(true)} />;
}// 1. Analytics consent, from the person's choice.
try await client.setAnalyticsConsent(.granted)
// 2. Signed in: identify as the user first (see above).
try await client.identify(userId: userId)
// 3. Channel consent, one channel per call. Sent with the linked user when
// the client is identified, otherwise stored for this device only.
try await client.setChannelConsent(.granted, for: .push)
try await client.setChannelConsent(.denied, for: .sms)// 1. Analytics consent, from the person's choice.
client.setAnalyticsConsent(ConsentStatus.Granted)
// 2. Signed in: identify as the user first (see above).
client.identify(userId)
// 3. Channel consent, one channel per call.
// Kotlin requires an identified user: otherwise this throws InvalidConfiguration.
client.setChannelConsent(ConsentChannel.Push, ConsentStatus.Granted)
client.setChannelConsent(ConsentChannel.Sms, ConsentStatus.Denied)final cf = ContentFlow.instance;
// 1. Analytics consent, from the person's choice.
await cf.setConsent(true);
// 2. Signed in: identify as the user first, or step 3 throws StateError.
await cf.setUserId(userId);
// 3. Channel consent. Every channel is sent: a flag you leave out is sent as false,
// and locationTracking: false also stops location collection on the device.
await cf.setChannelConsent(userId, const CFChannelConsent(push: true, sms: false));# 1 and 2. Identify as the user, with the analytics decision.
curl -X POST https://api.contentflow.click/sdk/v1/identify \
-H "X-CF-Key: $CF_SDK_KEY" -H "X-CF-Device: $CF_DEVICE" -H 'Content-Type: application/json' \
-d "{\"deviceId\":\"$CF_DEVICE\",\"userId\":\"cust_123\",\"consent\":true}"
# 3. Channel consent: flags must be nested under channels.
# The user is sent as username; userId is accepted as an alias (username wins if both are sent).
curl -X POST https://api.contentflow.click/sdk/v1/consent \
-H "X-CF-Key: $CF_SDK_KEY" -H "X-CF-Device: $CF_DEVICE" -H 'Content-Type: application/json' \
-d "{\"deviceId\":\"$CF_DEVICE\",\"username\":\"cust_123\",\"channels\":{\"push\":true,\"sms\":false}}"| Channel | JavaScript, Flutter, REST | Swift | Kotlin |
|---|---|---|---|
| Push | push | .push | ConsentChannel.Push |
| SMS | sms | .sms | ConsentChannel.Sms |
whatsapp | .whatsapp | ConsentChannel.Whatsapp | |
email | .email | ConsentChannel.Email | |
| Location | locationTracking | .location | ConsentChannel.Location |
| Marketing | marketing | .marketing | ConsentChannel.Marketing |
Channel consent for a named user is accepted only once this device is identified as that user, with the same X-CF-Device:
| Status | code | Meaning |
|---|---|---|
| 400 | DEVICE_REQUIRED | A user is named but no device was sent. |
| 400 | CHANNELS_REQUIRED | Channel flags were sent at the top level of the body instead of under channels. |
| 403 | DEVICE_NOT_IDENTIFIED | The device is not signed in as a user. Identify first. |
| 403 | USER_MISMATCH | The device is signed in as a different user. |
| 409 | DEVICE_ENVIRONMENT_MISMATCH | The device was first identified with the other environment's key. See Test and live keys. |
Registering a push token never grants push consent, and revoking push consent never removes the token. See A token is an address, not permission.
Consent presets
A preset describes what a consent screen for a regulated industry should offer: required and optional items, default topics, and the legal framework. GET /sdk/v1/consent/config/{preset} needs the SDK key but no device header and no identify call, so you can show the screen before the person has agreed to anything. Reading a preset never changes anyone's consent; only an explicit write does, and the server validates what you send against the preset.
| Preset | Framework | Example topics |
|---|---|---|
banking_ksa | SAMA + PDPL | Security alerts (push required), transaction alerts, statement ready, offers and rewards, card updates |
fintech_ksa | SAMA + PDPL | Security alerts (required), transaction alerts, payment reminders, product updates |
insurance_ksa | CCHI + PDPL | Policy updates, claim status |
healthcare_ksa | CBAHI + PDPL | Appointment and medication reminders, lab results |
travel_ksa | PDPL | Booking updates, flight status, gate changes, travel deals, loyalty updates |
retail_ksa | PDPL | Order updates, delivery status, promotions, back in stock, loyalty rewards |
food_delivery_ksa | PDPL | Order status (required), driver location, deals and discounts, recommendations |
import { consentPathsToConfig } from '@contentflow/sdk/web';
// 1. Read the preset to build your consent screen.
// preset.required / preset.optional are paths such as 'channels.push' or 'marketing.promotional'.
const preset = await client.getConsentConfig('banking_ksa');
// 2. Record what the person accepted, against the preset.
await client.requestConsent({ preset: preset.preset, ...consentPathsToConfig(userAcceptedPaths) });import { useEffect, useState } from 'react';
import { useCF, consentPathsToConfig, type ConsentPreset } from '@contentflow/sdk/react';
function ConsentScreen({ userAcceptedPaths }: { userAcceptedPaths: string[] }) {
const cf = useCF();
const [preset, setPreset] = useState<ConsentPreset | null>(null);
// 1. Read the preset to build your consent screen.
// preset.required / preset.optional are paths such as 'channels.push' or 'marketing.promotional';
// preset.availableTopics lists the push topics.
useEffect(() => { void cf.getConsentConfig('banking_ksa').then(setPreset); }, [cf]);
// 2. Record what the person accepted, against the preset.
async function accept() {
if (!preset) return;
await cf.requestConsent({ preset: preset.preset, ...consentPathsToConfig(userAcceptedPaths) });
}
return <button disabled={!preset} onClick={accept}>Accept</button>;
}import { useEffect, useState } from 'react';
import { Button } from 'react-native';
import { useCF, consentPathsToConfig, type ConsentPreset } from '@contentflow/sdk/react-native';
function ConsentScreen({ userAcceptedPaths }: { userAcceptedPaths: string[] }) {
const cf = useCF();
const [preset, setPreset] = useState<ConsentPreset | null>(null);
// 1. Read the preset to build your consent screen.
// preset.required / preset.optional are paths such as 'channels.push' or 'marketing.promotional';
// preset.availableTopics lists the push topics.
useEffect(() => { void cf.getConsentConfig('banking_ksa').then(setPreset); }, [cf]);
// 2. Record what the person accepted, against the preset.
async function accept() {
if (!preset) return;
await cf.requestConsent({ preset: preset.preset, ...consentPathsToConfig(userAcceptedPaths) });
}
return <Button title="Accept" disabled={!preset} onPress={accept} />;
}// 1. Read the preset to build your consent screen.
let preset = try await client.getConsentConfig(preset: "banking_ksa")
// 2. Record what the person accepted, against the preset.
try await client.requestConsent(options: ConsentRequestOptions(
preset: "banking_ksa",
channels: .init(push: true, sms: false)))// 1. Read the preset to build your consent screen (presets live on client.extensions).
val preset = client.extensions.getConsentConfig("banking_ksa")
// 2. Record what the person accepted, against the preset.
client.extensions.requestConsent(ConsentRequestOptions(
preset = "banking_ksa",
channels = FullConsentConfig.ChannelConsent(push = true, sms = false),
))final cf = ContentFlow.instance;
// 1. Read the preset to build your consent screen. Returns null if the request fails.
final preset = await cf.getConsentConfig('banking_ksa');
// 2. Record what the person accepted, against the preset.
// Every channel is sent: a flag you leave out is sent as false.
await cf.requestConsent(preset: 'banking_ksa', channels: const CFChannelConsent(push: true, sms: false));# 1. Read the preset: the key alone, no device header needed.
curl https://api.contentflow.click/sdk/v1/consent/config/banking_ksa -H "X-CF-Key: $CF_SDK_KEY"
# 2. Record what the person accepted: channels, marketing and topics in one write.
curl -X POST https://api.contentflow.click/sdk/v1/consent/full \
-H "X-CF-Key: $CF_SDK_KEY" -H "X-CF-Device: $CF_DEVICE" -H 'Content-Type: application/json' \
-d "{\"deviceId\":\"$CF_DEVICE\",\"preset\":\"banking_ksa\",\"channels\":{\"push\":true,\"sms\":false}}"Contracts: GET /consent/config/{preset} and POST /consent/full.
What happens to events without consent
Events are consent-gated twice. Before analytics consent the SDK drops them locally and does not hold them for a later grant. If they still reach ContentFlow for a device without consent, the batch is acknowledged with 202 and the drops are counted in dropReasons. A 202 is not proof of delivery: read accepted and dropReasons.
| Drop reason | Where | Why |
|---|---|---|
consent_not_granted | SDK (source: 'local') or server | No analytics consent for the device: never granted, or identified with consent: false |
device_not_identified | Server | The device never identified, identify failed, or its 24 h server-side identity entry expired |
How each SDK tells you: JavaScript reports every drop through subscribeEventDrops (or onEventsDropped) with source and dropReasons; Swift's trackEngagement returns .droppedNoConsent and flushEvents() returns the server's dropReasons; Kotlin's tracking calls return TrackingDisposition.DroppedNoConsent; Flutter logs Event not tracked: consent not granted with debug: true. Location points follow the same gate. Every drop reason is in Analytics: accepted, dropped, rejected.
Frequency caps follow the same gate on the server: a dropped impression is not counted, so the server never caps a device without analytics consent. From SDK 2.4.0 the SDK also keeps a local count on the device that works without analytics consent, because nothing leaves the device; see Popup frequency: in the app.
Sign-out
logout() (POST /logout) unlinks the user from this install and removes the device's push token from the profile. Channel consent is kept. Local identity is cleared only after the server confirms, so a failed call can be retried. Never share one client between different people's requests on a server.
// 1. On sign-out: unlink the user and remove the push token.
// A 207 PUSH_LINKAGE_INCOMPLETE throws a partial-write error: retry logout.
await client.logout();import { useCF } from '@contentflow/sdk/react';
function SignOutButton() {
const cf = useCF();
// 1. On sign-out: unlink the user and remove the push token.
// A 207 PUSH_LINKAGE_INCOMPLETE throws a partial-write error: retry logout.
return <button onClick={() => cf.logout()}>Sign out</button>;
}import { Button } from 'react-native';
import { useCF } from '@contentflow/sdk/react-native';
function SignOutButton() {
const cf = useCF();
// 1. On sign-out: unlink the user and remove the push token.
// A 207 PUSH_LINKAGE_INCOMPLETE throws a partial-write error: retry logout.
return <Button title="Sign out" onPress={() => cf.logout()} />;
}// 1. On sign-out: unlink the user and remove the push token.
// A partial push clear throws partialLogout: retry logout.
try await client.logout()// 1. On sign-out, from a coroutine: unlink the user and remove the push token.
client.logout()final cf = ContentFlow.instance;
// 1. On sign-out: unlink the user and remove the push token.
// Throws on failure and leaves local state unchanged, so retry.
await cf.logout();# 1. On sign-out: unlink the user and remove the push token.
curl -X POST https://api.contentflow.click/sdk/v1/logout \
-H "X-CF-Key: $CF_SDK_KEY" -H "X-CF-Device: $CF_DEVICE" -H 'Content-Type: application/json' \
-d "{\"deviceId\":\"$CF_DEVICE\"}"Erasure
deleteIdentity() (DELETE /identify) erases everything the device id addresses: the device record, its linked user profile, KYC, location history, geofence events, enrichment and analytics events. Analytics collection and location stop, and unsent events are discarded, before the request. Local identity, cache and consent are cleared only after the server confirms a complete erasure; otherwise the call throws and keeps the device id so you can retry.
// 1. Erase this device and its linked user's data.
// Throws unless the server confirms a complete erasure; retry on failure.
await client.deleteIdentity();
// 2. On success the client is disposed: create a new one (new device id) if the app continues.import { useState } from 'react';
import { CFProvider, useCF } from '@contentflow/sdk/react';
function EraseButton({ onErased }: { onErased: () => void }) {
const cf = useCF();
async function erase() {
// 1. Erase this device and its linked user's data.
// Throws unless the server confirms a complete erasure; retry on failure.
await cf.deleteIdentity();
onErased();
}
return <button onClick={erase}>Delete my data</button>;
}
export function App() {
// 2. On success the client is disposed: a new key remounts CFProvider with a new client (new device id).
const [session, setSession] = useState(0);
return (
<CFProvider key={session} config={{ publicKey: CONTENTFLOW_SDK_KEY }}>
<EraseButton onErased={() => setSession((n) => n + 1)} />
</CFProvider>
);
}import { useState } from 'react';
import { Button } from 'react-native';
import { CFProvider, useCF } from '@contentflow/sdk/react-native';
function EraseButton({ onErased }: { onErased: () => void }) {
const cf = useCF();
async function erase() {
// 1. Erase this device and its linked user's data.
// Throws unless the server confirms a complete erasure; retry on failure.
await cf.deleteIdentity();
onErased();
}
return <Button title="Delete my data" onPress={erase} />;
}
export function App() {
// 2. On success the client is disposed: a new key remounts CFProvider with a new client (new device id).
const [session, setSession] = useState(0);
return (
<CFProvider key={session} config={{ publicKey: CONTENTFLOW_SDK_KEY }}>
<EraseButton onErased={() => setSession((n) => n + 1)} />
</CFProvider>
);
}// 1. Erase this device and its linked user's data.
// Throws when the server cannot confirm the erasure; retry on failure.
try await client.deleteIdentity()
// 2. On success, local identity, consent and cache are cleared.// 1. Erase this device and its linked user's data.
// Throws if the server cannot confirm the erasure; retry on failure.
client.deleteIdentity()
// 2. On success, local identity, cached segments and cached content are cleared.final cf = ContentFlow.instance;
// 1. Erase this device and its linked user's data.
// Throws (CFRequestException on a partial erasure) unless the server
// confirms a complete erasure; local state is kept, so retry.
await cf.deleteIdentity();
// 2. On success, local data is cleared and the SDK starts over with a new device id.# 1. Erase everything the device id addresses. The device id is read only from X-CF-Device.
curl -X DELETE https://api.contentflow.click/sdk/v1/identify \
-H "X-CF-Key: $CF_SDK_KEY" -H "X-CF-Device: $CF_DEVICE"
# 2. Clear the device id on the client only after a 200; otherwise retry with the same id.- The SDK sends the device id it is erasing.
If this step is skipped: Without the X-CF-Device header the request is refused with 400 X-CF-Device header is required for identity deletion.
- ContentFlow deletes the device record.
- The profile, KYC records, location history, geofence events and enrichment for this device or user.
- Analytics events for the device.
- The device's popup frequency counters.
- Only on full success does the SDK wipe its local storage and dispose the client.
If this step is skipped: If some parts failed, ContentFlow answers 207 with localWipeSafe: false and the parts still remaining, but only to an SDK that declares the erasure-partial-v1 capability, which promises to keep its local id on a 207. Every other SDK gets 502 instead, as it does when nothing was erased or the outcome is unknown, because an SDK without the capability may treat any 2xx as a completed erasure (Flutter 2.2.2 and earlier do). Either way the device record is kept so a retry can finish, and the SDK keeps its local id and data until a full success. Every SDK declares the capability from 2.3.0; earlier releases get the 502.
Partial erasure (207): success: false and localWipeSafe: false mean some data remains. Keep the device id, because it is the only key the server can use to reach what is left, and retry. Until the erasure completes, identify for that device answers 409 with code ERASURE_PENDING: retry deleteIdentity() first.
After an erasure: the erasure is recorded for audit, and the person's profile is not recreated, from this device or any other, until consent is granted again: an identify with consent: true sent after the erasure. Until then identify stores nothing about them (no user link, traits or profile) and reports results.identity as {applied: false, status: "erased"}. Only call identify with consent: true when the person has actually agreed again.
PDPL notes
- Nothing by default. Fresh installs are analytics-denied, and no identify call can grant consent. Over REST, omitting
consentkeeps the previous decision. The JavaScript, Swift and Kotlin SDKs omit it until the app records a choice, so a launch cannot silently revoke consent granted on another surface; Flutter always sends its last decision,falseuntil you set it. - Every decision is a record. Each one is stamped with when it was made, how it was collected (app, web, SDK, import) and its legal basis (consent, contract, legitimate interest).
- Suppression (bulk, from the dashboard or
POST /users/bulk/suppress) switches marketing and the four outbound channels off while remembering the previous settings; unsuppress restores them. - PDPL opt-out blocks every outbound channel; sends are skipped with
pdpl_opt_out. Neither suppression nor opt-out deletes data; for that, use erasure. - Retention. Events are kept for 365 days; erasure deletes a device's events. See Analytics: privacy and retention.
When it doesn't work
| What you see | Why, and the fix |
|---|---|
Every event is dropped with consent_not_granted | Analytics consent was never granted. Call setConsent(true) (Swift and Kotlin: setAnalyticsConsent) from your consent UI. |
Channel consent refused with 403 DEVICE_NOT_IDENTIFIED or USER_MISMATCH | The device is not identified as that user. Call setUserId or identify(userId:) first. Flutter throws StateError and Kotlin InvalidConfiguration before sending. |
400 CHANNELS_REQUIRED | Channel flags were sent at the top level of a REST body. Nest them under channels. |
409 DEVICE_ENVIRONMENT_MISMATCH | The device was first identified with the other environment's key. Use a separate install or a fresh device id for test builds. |
409 DEVICE_STATE_CHANGED | The device's identity changed while the request was in flight. Re-identify, then retry. |
429 USER_RATE_LIMITED | More than 10 consent changes for one user in an hour, counted across /consent and /consent/full. Wait for the Retry-After seconds, and don't write consent on every screen view. |
getConsentConfig fails with an unknown preset | The name is not one of the valid presets (the JavaScript SDK throws CFUnknownConsentPresetError, the route answers 404). Use a name from the table above. |
deleteIdentity() throws | The server could not confirm a complete erasure (a 502, or a partial 207). The device id and local data are kept; retry the call. |
Next
- Push notifications: register tokens and set push consent so campaigns reach the person.
- Testing your integration: check identify, consent and events end to end with a test key.