Home / Guides / Push notifications
Product guide

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.

In short
  • 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, or registerPushToken in 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 with noConsent.
  • 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.

Why teams use push

Business use cases

Security

Security and OTP alerts

The banking consent preset makes push required for security messages, separate from optional marketing topics.

Service

Transaction and statement alerts

Topics such as transaction_alerts, statement_ready and card_updates that people choose in your settings screen.

Marketing

Offers and rewards

Ramadan offers, cashback launches and win-back nudges to segments, with prayer-time blackouts.

Journeys

Timed follow-ups

"Welcome" an hour after sign-up, "complete your application" two days later, "rewards expiring" on day 30.

Concept

A token is an address, not permission

Three separate things decide whether a push arrives:

WhatWho decidesHow ContentFlow learns itWithout it
OS permission and tokenThe person, via the OS promptregisterPush({ token, platform })Skipped: noContact (no device token)
Push consentThe person, in your consent UIsetChannelConsent({ push: true })Skipped: noConsent
PDPL opt-outThe personConsent APIBlocks 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.

In the app

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.

In the app

Registration sequence

Permission → token → register → consent
  1. 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.

  2. APNs returns data the SDK hex-encodes; FCM and HMS return a string.
  3. The SDK validates the platform and provider locally (HMS needs an app id).
  4. 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.

  5. ContentFlow records on the device the token, provider and environment. A token belongs to one device at a time; registering it elsewhere moves it.
  6. ContentFlow keeps on the person's profile one registration per install (id, token, platform, provider, environment).
  7. linkage.status is identified (on a person) or anonymous (on the device). 207 registered: false means the profile write failed: retry.
  8. 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.

Delivery

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.

  1. Window: outside the schedule or inside a prayer blackout, the send is deferred.
  2. Consent: pdplOptOut blocks; consent.push must be true. Re-checked again at send time.
  3. Installs: up to 10 per person, newest first, from the same environment (live sends never reach test installs).
  4. Provider: each install is sent through the workspace's active push channel; installs on a different provider are skipped.
  5. Delivery: up to 3 attempts with backoff. The send counts as delivered if any install accepts it.
Delivery

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.

Hygiene

Token lifecycle

EventCallEffect
OS refreshes the tokenregisterPush againReplaces the old token for this install
Sign-outclient.logout() (POST /logout)Unlinks the user and removes the token; 207 PUSH_LINKAGE_INCOMPLETE means retry
Turn off push on this deviceunregisterPush()Removes the token only; consent is unchanged
Different account signs inidentify()The token moves from the old owner to the new one
App uninstalledNothingThe next send gets a permanent error from APNs or FCM and that one token is pruned; the person's other installs are kept
ErasuredeleteIdentity()Device and registrations deleted
Troubleshooting

When it doesn't work

SituationResponse
Empty token400 token is required
Bad platform or provider400 INVALID_PUSH_PLATFORM / INVALID_PUSH_PROVIDER
HMS without an app id400 hmsConfig.appId is required for HMS tokens
User id given but the device isn't signed in403 DEVICE_NOT_IDENTIFIED
Device signed in as someone else403 USER_MISMATCH (or USER_ID_DEVICE_MISMATCH)
Test key on a live device409 DEVICE_ENVIRONMENT_MISMATCH
Identity changed during the request409 DEVICE_STATE_CHANGED; retry
More than 3 new push devices for one user in 24 hours429 USER_RATE_LIMITED; wait for Retry-After
Profile write failed207 registered: false; retry
No push consent at send timeSkipped, no_consent
No token at send timeSkipped, noContact
No active push providerFailed, No active channel configuration found
Be aware

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

Next: Audience & privacy → Campaigns & journeys