Push notifications
ContentFlow sends push notifications to every app install a person has, through Apple (APNs), Google (FCM) and Huawei (HMS), from campaigns, journeys and one-off sends. Your app's job is to get permission and a token from the operating system and hand the token to the SDK. ContentFlow's job is deciding who may receive what, and delivering it.
- ContentFlow sends push to every app install a person has, through APNs, FCM and HMS.
- Your app gets permission and a token from the OS and hands it to the SDK (
registerPush, orregisterPushTokenin Swift and Kotlin), and again whenever the OS refreshes the token. - Registering a token never grants consent: set push consent separately with
setChannelConsent, or every send skips the person withnoConsent. - On sign-out,
logout()unlinks the user and removes the token from the device.
Push reaches people when your app is closed. A message that can wait until they next open the app can go in-app instead, as a banner, popup or in-app notification, with no token or push consent needed: Push or in-app? compares the two and shows when to use which.
Business use cases
Security and OTP alerts
The banking consent preset makes push required for security messages, separate from optional marketing topics.
Transaction and statement alerts
Topics such as transaction_alerts, statement_ready and card_updates that people choose in your settings screen.
Offers and rewards
Ramadan offers, cashback launches and win-back nudges to segments, with prayer-time blackouts.
Timed follow-ups
"Welcome" an hour after sign-up, "complete your application" two days later, "rewards expiring" on day 30.
A token is an address, not permission
Three separate things decide whether a push arrives:
| What | Who decides | How ContentFlow learns it | Without it |
|---|---|---|---|
| OS permission and token | The person, via the OS prompt | registerPush({ token, platform }) | Skipped: noContact (no device token) |
| Push consent | The person, in your consent UI | setChannelConsent({ push: true }) | Skipped: noConsent |
| PDPL opt-out | The person | Consent API | Blocks every channel: pdpl_opt_out |
Registering a token never grants consent, and revoking push consent never removes the token. That's deliberate: a person can keep the app's notifications switched on at OS level for security alerts while declining marketing.
Registering a device
The SDK never shows the permission prompt itself (except the Expo helper). Ask at a moment that makes sense to the user, then register:
// 1. After the OS grants permission and gives you a token, register it.
await client.registerPush({
token, // APNs (hex), FCM or HMS token
platform: 'ios', // 'ios' | 'android' | 'web'
provider: 'apns', // optional: 'apns' | 'fcm' | 'hms' | 'web_push'
// hmsConfig: { appId } is required for HMS tokens
});
// 2. Separately, when the person agrees to push messages:
await client.setChannelConsent({ push: true });
// 3. Register again whenever the OS refreshes the token.import { useCF } from '@contentflow/sdk/react';
// Web Push. Your app owns the service worker and the VAPID key pair.
// Web tokens can be registered, but there's no web-push sender yet (see Current limits).
function EnablePush() {
const cf = useCF();
// 1. After the browser grants permission and gives you a subscription, register it.
const enable = async () => {
if ((await Notification.requestPermission()) !== 'granted') return;
const registration = await navigator.serviceWorker.ready;
const subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: VAPID_PUBLIC_KEY,
});
await cf.registerPush({
token: JSON.stringify(subscription),
platform: 'web', // provider defaults to 'web_push' for 'web'
});
};
// 2. Separately, when the person agrees to push messages:
const agree = () => cf.setChannelConsent({ push: true });
// 3. Register again whenever the browser replaces the subscription.
return <><button onClick={enable}>Turn on notifications</button><button onClick={agree}>Send me offers</button></>;
}import * as Notifications from 'expo-notifications';
import { registerExpoPush } from '@contentflow/sdk/expo';
// 1. Asks for permission, reads the device token and registers it.
const result = await registerExpoPush(client, Notifications, { requestPermission: true });
if (result.status !== 'registered') {
// 'permission_denied', 'permission_not_requested' or 'unsupported_token': nothing was registered.
}
// 2. Separately, when the person agrees to push messages:
await client.setChannelConsent({ push: true });
// 3. Call registerExpoPush again whenever the OS refreshes the token.// 1. In application(_:didRegisterForRemoteNotificationsWithDeviceToken:):
try await client.registerPushToken(deviceToken) // APNs by default; platform: .fcm for Firebase
// 2. Separately, when the person agrees to push messages:
try await client.setChannelConsent(.granted, for: .push)
// 3. Register again whenever the OS refreshes the token.// 1. In FirebaseMessagingService.onNewToken(token), from a coroutine:
client.registerPushToken(token, PushPlatform.Fcm) // (HMS is not usable yet: no app id parameter)
// 2. Separately, when the person agrees to push messages:
client.setChannelConsent(ConsentChannel.Push, ConsentStatus.Granted)
// 3. onNewToken runs again when the token is refreshed: register there.final cf = ContentFlow.instance;
// 1. After the OS grants permission and gives you a token (e.g. from firebase_messaging):
await cf.registerPush(CFPushConfig(
token: token,
platform: CFPushPlatform.android, // ios | android | web
provider: CFPushProvider.fcm, // apns | fcm | hms
));
// 2. Separately, when the person agrees to push messages (after identify):
// every channel is sent, and a flag you leave out is sent as false.
await cf.setChannelConsent(userId, const CFChannelConsent(push: true));
// 3. Register again whenever the OS refreshes the token.# 1. Register the token for this device.
curl -X POST https://api.contentflow.click/sdk/v1/register-push \
-H "X-CF-Key: $CF_SDK_KEY" -H "X-CF-Device: $CF_DEVICE" -H 'Content-Type: application/json' \
-d '{"token":"'"$DEVICE_TOKEN"'","platform":"ios","provider":"apns"}'
# 2. Separately, when the person agrees to push messages:
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\",\"channels\":{\"push\":true}}"
# 3. Register again whenever the OS refreshes the token.Order relative to sign-in doesn't matter: before identify, the token is filed under the device; when the person later identifies with consent, ContentFlow moves it onto their profile. If that move fails, identify answers 207 PUSH_LINKAGE_INCOMPLETE and you should retry.
Registration sequence
- Your app asks at a meaningful moment (for example, when enabling transaction alerts).
If this step is skipped: Without permission the OS gives no token; nothing can be registered, and campaigns skip this person with noContact.
- APNs returns data the SDK hex-encodes; FCM and HMS return a string.
- The SDK validates the platform and provider locally (HMS needs an app id).
- ContentFlow checks that any user id in the body is the one this device identified as.
If this step is skipped: With a user id the device isn't signed in as, ContentFlow refuses with 403 DEVICE_NOT_IDENTIFIED or USER_MISMATCH. Identify first, or register without a user id.
- ContentFlow records on the device the token, provider and environment. A token belongs to one device at a time; registering it elsewhere moves it.
- ContentFlow keeps on the person's profile one registration per install (id, token, platform, provider, environment).
linkage.statusisidentified(on a person) oranonymous(on the device).207 registered: falsemeans the profile write failed: retry.- A separate decision, sent to
POST /consent.If this step is skipped: With a token but no push consent, every campaign and journey skips this person with noConsent. Registration alone never opts anyone in.
Sending a push
Campaigns, journeys and one-off sends (POST /notifications, or POST /notifications/segment/{id} for a segment) all go through the same checks:
Push goes out through the workspace's provider connection (FCM, APNs, HMS); SMS, WhatsApp and email providers are connected the same way: Connect a messaging provider.
- Window: outside the schedule or inside a prayer blackout, the send is deferred.
- Consent:
pdplOptOutblocks;consent.pushmust be true. Re-checked again at send time. - Installs: up to 10 per person, newest first, from the same environment (live sends never reach test installs).
- Provider: each install is sent through the workspace's active push channel; installs on a different provider are skipped.
- Delivery: up to 3 attempts with backoff. The send counts as delivered if any install accepts it.
Opens and deep links
Notification records move through sent, delivered, read, failed and skipped. When a person opens a push, report it from the app as an analytics event. On the JavaScript ContentFlow singleton that is ContentFlow.system({ action: 'push_opened', messageId, campaignId }); a createCFClient client has no system() equivalent.
A push carries its destination as deepLink: a custom key in the APNs payload, and in the message data for FCM. Campaigns and journeys take it from the call-to-action and keep it only when it is a web address (http:// or https://, and the OS blocks http:// by default). An app path such as /offers or a custom-scheme link such as myapp://offers is left out of the push, even though blocks and in-app campaigns deliver those to your app unchanged. Use https:// universal links or app links, and check the link against your allow-list before you open it: see Buttons, links and deep links for why the rules differ and how to route a link safely.
Token lifecycle
| Event | Call | Effect |
|---|---|---|
| OS refreshes the token | registerPush again | Replaces the old token for this install |
| Sign-out | client.logout() (POST /logout) | Unlinks the user and removes the token; 207 PUSH_LINKAGE_INCOMPLETE means retry |
| Turn off push on this device | unregisterPush() | Removes the token only; consent is unchanged |
| Different account signs in | identify() | The token moves from the old owner to the new one |
| App uninstalled | Nothing | The next send gets a permanent error from APNs or FCM and that one token is pruned; the person's other installs are kept |
| Erasure | deleteIdentity() | Device and registrations deleted |
When it doesn't work
| Situation | Response |
|---|---|
| Empty token | 400 token is required |
| Bad platform or provider | 400 INVALID_PUSH_PLATFORM / INVALID_PUSH_PROVIDER |
| HMS without an app id | 400 hmsConfig.appId is required for HMS tokens |
| User id given but the device isn't signed in | 403 DEVICE_NOT_IDENTIFIED |
| Device signed in as someone else | 403 USER_MISMATCH (or USER_ID_DEVICE_MISMATCH) |
| Test key on a live device | 409 DEVICE_ENVIRONMENT_MISMATCH |
| Identity changed during the request | 409 DEVICE_STATE_CHANGED; retry |
| More than 3 new push devices for one user in 24 hours | 429 USER_RATE_LIMITED; wait for Retry-After |
| Profile write failed | 207 registered: false; retry |
| No push consent at send time | Skipped, no_consent |
| No token at send time | Skipped, noContact |
| No active push provider | Failed, No active channel configuration found |
Current limits
- Web push: web tokens can be registered, but there's no web-push sender yet; sends go through APNs, FCM and HMS.
- Personal quiet hours, prayer-time preference, urgent-only and digest are saved on the profile (
PATCH /push/preferences) but not applied to sends yet. Campaign-level prayer blackouts are applied. - Topics are stored for your settings UI; campaigns target segments, not topics.
- Deep links: campaign and journey pushes carry only web addresses (see Opens and deep links).
- Open attribution: the push payload doesn't yet carry the campaign or notification id, so pass your own id in the message data if you need to attribute opens.
- HMS: invalid HMS tokens aren't pruned automatically, and the Kotlin SDK can't register HMS tokens yet (no app id parameter).
Next
- Audience & privacy: consent: how push consent and PDPL opt-out are recorded and checked.
- Campaigns & journeys: build the campaigns and journeys that send the push.