Home / Guides / Campaigns & journeys
Product guide

Campaigns, journeys & channels

A campaign is one message, in Arabic and English, to an audience on one or more channels: an in-app banner or popup, a push notification, an SMS, a WhatsApp message or an email. A journey is a sequence of steps for each person, such as "welcome push, wait a day, then a banner if they haven't activated their card", started by an event, a segment, inactivity or a Hijri date.

In short
  • A campaign is one Arabic and English message to an audience on one or more channels; a journey runs a sequence of steps for each person.
  • Banners are pulled by the app with getCampaigns(placement). A popup is a block: pick Popup as a channel (the ready-made Standard popup or one of your own popup blocks), and the app receives it on its next sync and getPopup(screen) offers it (see Blocks as channels). Push, SMS, WhatsApp and email are sent by ContentFlow once the campaign is active.
  • Nothing goes live until a second person approves it: approving your own submission is refused.
  • Set a target segment, or All users: a campaign with no segments sends nothing and reports no error.
  • When someone didn't get a message, the skip reason says why; missing channel consent or contact details are the usual causes.
Why teams use them

Business use cases

Onboarding

Welcome series

On sign-up: a welcome push after an hour, then an in-app banner, branching on whether the person is in the activated segment.

Retention

Win-back

After 30 days inactive: an SMS, three days' wait, then a push that rewards are about to expire.

Seasonal

Ramadan and Eid

Journeys that start on ramadan_start and wait until iftar before sending, and Eid greetings on the Hijri date.

Cross-sell

Segment offers

Auto-finance push to Salary > 3,000 SAR; mortgage pre-approval nudge; travel-card reminder before summer.

Service

Education and alerts

Low-balance education by SMS, digital-wallet onboarding, statement-ready messages to people who opted in.

Conversational

WhatsApp follow-ups

Approved WhatsApp templates for Hajj savings follow-ups or Ramadan giving receipts.

Channels

Pull channels and push channels

ChannelHow it reaches the personProviders
Banner, videoPulled by the app: client.getCampaigns('home_top') calls POST /campaigns for a placement and gets the live campaigns, highest priority first, with A/B variants and consent applied. The app draws them.The SDK (useCFCampaigns, CFCampaignBanner)
Popup, and any dynamic blockPulled by the app: the campaign's block is delivered on the app's next sync like any block, and for a popup getPopup(screen) returns the one popup the screen may show after its frequency rule and the workspace popup limits. See Blocks as channels and Popups in your app.The SDK (getPopup, onBlockPopup, useCFPopup)
PushSent to every install the person has (up to 10, newest first). See Push notifications.APNs, FCM, HMS
SMSSent to the profile's phone.Twilio, Taqnyat, or ContentFlow SMS (needs an approved sender name)
WhatsAppSent as an approved template (Twilio Content SID HX…); the body fills variable 1 if you don't map variables.Twilio WhatsApp
EmailHTML with a preheader; merge values are HTML-escaped.SMTP, SendGrid, Mailgun, Resend
In-app notification (campaign or journey)Delivered to each person as a block with key journey_message on the next /sync, until the app reports it dismissed or its button tapped, or it expires (the campaign's end time, or 7 days when there is none). A campaign's block carries source: "campaign" and its campaignId. Your app draws it: see Show in-app messages.The SDK

Each workspace connects a provider per channel in Messaging → Channels (see Connect a messaging provider). A send on a channel with no active provider fails with No active channel configuration found.

Channels

Blocks as channels: popups and your own components

A campaign can deliver a dynamic block with the block's own fields instead of a fixed title, body and button. This is how a campaign makes a popup: in the Channels step, the Popup group lists the workspace's popup blocks. Every workspace has a ready-made Standard popup; any block your developers mark as a popup and link to campaigns is listed beside it. Other blocks linked to campaigns are under Dynamic blocks.

  • Content: the Content step shows the block's own fields. Text fields are written per language; an image, a switch, a number or a choice is one value for every language.
  • Delivery: when the campaign goes live (or to test), the block is published to the app as one ordinary block for that campaign, with the campaign's audience, schedule, frequency rule and priority. The app receives it on its next sync; for a popup, getPopup(screen) returns it as kind: 'block' on the block's own screen. Nothing changes in the app for SDK 2.4.0.
  • Standard popup fields: #title, #body, #image, #cta_label and #cta_url. From SDK 2.4.1 the SDK draws it in its built-in card when the app sets no onBlockPopup handler; with SDK 2.4.0 it reaches your onBlockPopup handler (or getPopup), and your app draws it.
  • Lifecycle: pausing, ending, rejecting, archiving or deleting the campaign takes the block down; editing a live campaign updates it; activating it again brings it back. The block is managed by its campaign: change it there, not in Dynamic blocks.
  • Frequency: the campaign's frequency rule applies to the block. A popup with no rule shows at most once per day, the default every plan allows; the workspace popup limits apply on top.
  • Results: impressions, taps (a tap and a button tap from the same press count once) and conversions on the block count toward the campaign's statistics.
  • Other channels: a campaign can combine a block with push, SMS, WhatsApp or email; those send as usual. A block is never sent as a message.

If a block channel can't be delivered, the campaign page says why under Block channels, for example a block that was archived or unlinked from campaigns, copy missing in the workspace's main language, a segment that no longer exists, or excluded segments (a block channel can't exclude segments yet). The rest of the campaign is unaffected.

Channels

Push or in-app?

A push notification reaches the person outside your app, on the lock screen, even when the app is closed. An in-app message (a banner, a popup or an in-app notification) reaches them inside your app, the next time they use it, and can look like any other part of your app.

Push notificationIn-app message
Where it appearsThe notification tray and lock screenInside your app's screens, where your app draws it
App must be openNoYes
Who delivers itApple (APNs), Google (FCM) or Huawei (HMS), once ContentFlow sends itContentFlow, through the SDK: banners and popups when the app asks for a placement or screen, in-app notifications on the app's next sync
What it needsA device token from registerPush (registerPushToken in Swift and Kotlin), push channel consent and a connected push providerThe SDK running in your app. An in-app notification also needs the app signed in as the person (identify with their user id) and marketing consent
ContentTitle, body and a web linkBanners, popups and blocks: images, buttons and your app's own styling. In-app notifications: title, body, image and one button with a web link
When it failsInvalid or expired tokens (pruned after a permanent provider error), notifications turned off in the phone's settings (the provider can accept a push the person never sees), provider errors (retried up to 3 times)Nothing fails at send time: the message waits for the app and shows on its next sync. It is missed if the app isn't opened before it expires
Typical useTime-sensitive alerts, reminders and bringing people back to the appOffers, onboarding tips and announcements while the person is already in the app

Use push when the message matters now and the person may not open the app on their own. Use in-app when it can wait until they next open the app, needs rich layout, or should not interrupt them. A journey can combine them: for example a push on day one, then an in-app banner if the person hasn't acted (see How journeys work), and one campaign can send on several channels at once.

Channels

Connect a messaging provider

Push, SMS, WhatsApp and email go out through a provider your workspace connects; in-app needs none. Connect them in Messaging → Channels. Everyone can see which channels are connected; only admins can connect, edit, disconnect or test them.

ChannelProviders you can connectTest from the channel card
EmailSMTP, SendGrid, Mailgun, ResendValidate / Send test email: one real email through the provider. If it is accepted, the API key and sender domain work.
SMSContentFlow SMS (managed, needs an approved sender name), Taqnyat, Twilio (your own credentials)Send test to a phone number in E.164 format, such as +9665…
WhatsAppTwilio WhatsAppSend test to a phone number in E.164 format
PushFCM, APNs, HMSSend test to one device token, taken from your app after it calls registerPush()
  • Plan. Connecting an SMS or WhatsApp provider is a plan feature; on a plan without it the request is refused with UPGRADE_REQUIRED.
  • Which provider sends. When a channel has more than one active provider, the one with the highest priority is used. If several share the highest priority, the dashboard warns that delivery may use any of them.
  • Test sends use a fixed test message, not your campaign text, and go through the channel's live provider. They are rate limited per person (CHANNEL_TEST_RATE_LIMITED: wait a few minutes). A disabled channel must be enabled before it can be tested. A test that the provider accepts is not proof of delivery to the handset.
  • Email test from a campaign. Admins and editors can send a campaign's email to themselves with the same route; only an admin can send the test to another address, and then it is the fixed test email.

The same operations on the management API (portal sign-in token, see REST endpoints):

GET    /api/v1/notifications/channels?type=sms     list (any role; secrets are redacted)
GET    /api/v1/notifications/channels/{id}          one config (admin)
POST   /api/v1/notifications/channels               connect: { type, provider, name, configuration } (admin)
PUT    /api/v1/notifications/channels/{id}          edit (admin)
DELETE /api/v1/notifications/channels/{id}          disconnect (admin)
POST   /api/v1/notifications/channels/{id}/test     { "to": "+9665…" } or a device token for push (admin)
POST   /api/v1/notifications/channels/email/test    { "to": "…" }, optional; empty sends to your own account email (admin, editor)

type is email, sms, whatsapp or push, and provider one of smtp, sendgrid, mailgun, resend, contentflow-sms, taqnyat, twilio, twilio-whatsapp, fcm, apns, hms. Push provider setup is covered in Push notifications.

Lifecycle

Lifecycle and approval

Statuses: draft, pending_review, approved, scheduled, active, test, paused, completed, expired, rejected.

Review path (maker-checker)
  1. An editor creates the campaign as a draft. Required: name, type, priority (1 to 10), segments (choose All users to reach everyone) and a schedule object (startTime defaults to now). Optional: content per locale, and dispatchChannels for a multi-channel campaign. A field the API does not define, such as channels, is refused with 400.
  2. Allowed from draft or rejected; otherwise refused with CAMPAIGN_SUBMIT_REVIEW_FAILED. Status becomes pending_review.
  3. An admin or a user with can approve, other than the person who submitted it, so a first campaign needs two people. Send {"activateTarget": "live"} to go live now or {"activateTarget": "test"} for Test mode; with neither it lands in scheduled (start in the future) or approved. Approval is atomic, so two approvers can't both win.

    If this step is skipped: A rejection requires a note (refused with CAMPAIGN_REJECT_FAILED without one) and sets rejected; the editor can fix and resubmit, or recall a pending submission back to draft.

  4. Only active campaigns send. A scheduled one is moved to active by the scheduler when its start arrives.
  5. ContentFlow runs the dispatch pipeline. Push, SMS, WhatsApp and email fan out once per channel. Banners and popups aren't dispatched; apps pull them.
i Review is mandatoryA campaign can only enter active, test, scheduled or approved once an approver has approved it. Any other route there, such as PATCH /campaigns/{id}/status on a draft or creating a campaign as active, is refused with 409 REVIEW_REQUIRED: submit it for review instead. The approver must be someone other than the person who submitted it; approving your own submission is refused with 403 SELF_APPROVAL_NOT_ALLOWED. Pausing, resuming a paused campaign, completing it and moving it back to draft (which clears the approval) need no review, and campaigns that were already live keep running. Editing a campaign that's already approved, scheduled, active or paused creates a campaign_edit approval request for non-approvers. Enterprise workspaces can also turn on launch protection, which requires an admin (LAUNCH_ADMIN_REQUIRED) or a one-time code (LAUNCH_OTP_REQUIRED) to go live.

Outbound

The dispatch pipeline

From active to delivered, per channel
  1. ContentFlow runs this when a campaign becomes active or is updated. It checks the window: start reached (Gregorian or Hijri), end not passed, no prayer blackout. Outside the window the send is deferred and retried by the scheduler every minute.

    If this step is skipped: Approved or scheduled campaigns aren't fanned out until they're active. A campaign more than 24 hours late is marked missed instead of sent, and the workspace is notified.

  2. Pages through the target segments' members and removes duplicates. All users means everyone.

    If this step is skipped: A campaign with no target segments is skipped with only a log line: no error and no status. Always set a segment, or All users.

  3. Consent (pdplOptOut blocks everything; the channel flag must be true), a contact (token, phone or email), content in a usable language, and every {{token}} resolvable. Then a cap of 25,000 recipients per channel.
  4. Push, SMS and WhatsApp are plan features.

    If this step is skipped: On a plan without the channel the channel is blocked with a message such as WhatsApp requires Enterprise plan, and nothing is sent.

  5. One notification per person. The channel claims a lease, so an interrupted send resumes where it stopped, up to five attempts.
  6. ContentFlow re-checks the stored consent at send time and records skipped with pdpl_opt_out, no_consent or no_profile.
  7. Retries with exponential backoff; a permanently invalid token or number isn't retried.
  8. The outcome is recorded per person and summed per channel: sent, skipped (by reason), deferred, failed, uncertain.
Troubleshooting

When it doesn't work

ReasonMeansFix
noConsent / no_consentThe channel flag isn't trueCollect channel consent (setChannelConsent) first.
pdpl_opt_outThe person opted out under PDPL; blocks every channelRespect it.
noContactNo push token, phone or emailRegister the push token; enrich phone/email.
noContentNo content in a usable languageAdd the locale or a default.
unresolvedTokenA {{token}} has no value for this personProvide a default in Shared Values, or fix the attribute.
cappedOver 25,000 recipients on the channelSplit the audience.
frequencyCappedThe campaign's optional frequency rule is on and this person already got it the maximum number of times on this channel in the current hour, day or weekExpected. Raise the limit or turn the rule off if it's too strict. See Popup frequency: the rule.
deferredOutside the window or in a prayer blackoutIt sends when the window opens.
channel blockedThe plan doesn't include the channelUpgrade or change channel.
No active channel configuration foundThe workspace has no active provider for the channelConnect one in Messaging → Channels (how).
Nothing sent, no statusThe campaign has no target segments; it's skipped with only a log lineSet a segment, or All users.
Saudi market

Saudi calendar and prayer times

  • Hijri dates: schedule by Hijri start and end as well as Gregorian. Tag campaigns ramadan, eid_fitr, eid_adha, national_day, founding_day or hajj_season for planning.
  • Prayer-time blackout: turn it on per campaign and pick the city (Riyadh, Jeddah, Mecca, Medina, Dammam). Outbound sends falling within each prayer's window are deferred, not dropped. You can add your own blackout windows too.
  • Time zone: campaigns default to Asia/Riyadh.
  • Journeys: start on ramadan_start or eid_start, and wait until iftar (maghrib for the day) before the next step.
Content

Languages and personalisation

Content is stored per locale with its own text direction. Each person gets their preferredLanguage; if there's no content in it, the order is: the base language, a regional variant, the workspace's source locale, Arabic, English, then any. {{tokens}} are filled from the profile and from Shared Values; a token without a value skips that person rather than sending a broken message.

Content

Personalising in-app campaigns

Outbound messages (push, SMS, WhatsApp, email and journey sends) are personalised on the server, as above. Banners and popups that the app pulls (getCampaigns, getPopup, presentPopup) are not: POST /campaigns returns the text as you wrote it, tokens included, and filling them happens on the device.

  • JavaScript, React and React Native. The SDK fills {{key}} (and the older {key}) in the campaign's title, body and button label from the traits this device sent with identify or updateTraits. first_name, last_name and full_name come from the name trait, and plan_tier from plan. A token with no matching trait stays exactly as typed, braces included. It is on by default; interpolate: false in the client config turns it off. The original text is in the campaign's raw field.
  • Swift, Kotlin and Flutter. These SDKs return campaign text as authored, without filling tokens. Replace them in your own code before you show the campaign, or avoid tokens in pulled campaigns.
  • Shared values. A key from Content Library → Shared values is filled on the device only if the device has a trait with that key. Workspace constants are not substituted in pulled campaigns, unlike in-app blocks, where the server fills constants on delivery (see Dynamic blocks).
  • Unresolved tokens. Outbound sends skip a person whose declared token has no value (unresolvedToken); a pulled campaign is still shown, with the raw token. Preview the campaign with a test device that has the traits you use, or keep tokens out of banner text.

Journeys

How journeys work

A journey is a list of steps with optional yes/no branches. Each person enrolled moves through it independently; the engine checks every minute, moving each person up to 20 steps per check. A person can enter a given journey once. They finish when they reach the end of their path. Pausing a journey freezes everyone in place.

Journeys

Triggers and steps

Dashboard optionTrigger in the APIEnrols
App events: Signs up (app event), Signs in, Sees a banner, Taps content, Clicks a CTA, Dismisses content, Convertsevent with eventType SIGN_UP, SIGN_IN, BANNER_IMPRESSION, BANNER_CLICK, CTA_CLICK, DISMISSED or CONVERSIONPeople whose app sends that event. The impression, tap, CTA and dismiss events can be narrowed to one campaign or block, or a screen (filters.campaignId, blockKey, screen).
Location: Enters a geofence, Leaves a geofence, Stays in a geofenceevent with eventType GEOFENCE_ENTER, GEOFENCE_EXIT or GEOFENCE_DWELLPeople who enter, leave or stay in a geofence, optionally one fence (filters.fenceId). A stay reported by both the server and the app enrols once. Needs location set up: see Location: geofence actions.
Lifecycle: Becomes inactiveinactivityPeople inactive for N days (default 30)
Lifecycle: Enters a segmentsegment_entryCurrent members of a segment
Saudi calendar: Ramadan start, Eid al-Fitr starthijri with ramadan_start or eid_startEveryone, on that Hijri date
Manual: Manual enrollmentmanualPeople you enrol: POST /journeys/{id}/enroll with { "userIds": [...] }

Event triggers only enrol people who have a profile, so the app must identify them with analytics consent. To start a journey on sign-up, use Signs up (app event) and call trackSignUp in the app. The API also still accepts an older signup trigger (profiles created since the last check), which the dashboard no longer offers.

Steps: wait (seconds, minutes, hours, days, or until iftar), action (banner, push, in-app, SMS, WhatsApp, email, Arabic greeting, or send a campaign; one or several channels), condition (in a segment? then the yes or no branch) and exit (the person leaves the journey there).

Exit criteria: set Exit when in segment on the trigger (exitSeg). Someone in that segment leaves before their next step, including one a wait is about to release. Both ways out mark the enrollment exited and count in the journey's stats.exited.

Journeys

Journey sequence: welcome series

Sign-up → wait 1 hour → push → branch → banner
  1. The app reports the sign-up through the SDK. The SDK identifies first, so the event is attributed to the person.
  2. ContentFlow stores the event, attributed to the person.
  3. The journey engine reads new events and enrols matching people into active journeys.

    If this step is skipped: Only events for a real profile enrol people. Without analytics consent no profile exists, so the person is never enrolled.

  4. The enrollment sleeps until its wait ends.
  5. Consent and contact are checked as for campaigns. A per-send marker makes it idempotent.

    If this step is skipped: Each send is skipped with a reason rather than failing the journey: no consent, no profile found, no device token, no phone, empty message, or already sent.

  6. The condition picks the yes or no path.
  7. The SDK receives the in-app step as the journey_message block on the next sync.

Reference

REST endpoints

Management API, https://api.contentflow.click/api/v1, with Authorization: Bearer <token> and X-Tenant-Id: <workspaceId>. The token is a person's portal sign-in token: POST /api/v1/auth/login with their email and password answers { token, user }, or a two-step verification challenge when the account requires one. It lasts one day and ends when that session is signed out. There is no service-account or server key for the management API: SDK keys (the publishable key <workspaceId>_cfpk…, the older <workspaceId>_app / _test keys, pk_live_…, pk_test_…) open only the SDK routes under /sdk/v1. Creating, editing, sending and enrolling need the admin or editor role.

Check success in every response body. Many refusals come back as { "success": false, "error": { "message": "…", "code": "…" } } with the route's normal status (201 for a POST, 200 otherwise), for example CAMPAIGN_SUBMIT_REVIEW_FAILED, CAMPAIGN_REJECT_FAILED, or FORBIDDEN when someone who cannot approve calls approve. Real HTTP errors are 400 for a body that fails validation (a missing required field or an unknown one), 401 without a valid token, 403 for a role the route does not allow and for SELF_APPROVAL_NOT_ALLOWED, and 409 for REVIEW_REQUIRED.

A first campaign, end to end (every call after sign-in also sends Authorization and X-Tenant-Id):

HTTP
# 1. The editor signs in (a person, not a server key)
POST /api/v1/auth/login
{ "email": "editor@example.invalid", "password": "…" }

# 2. Create the draft
POST /api/v1/campaigns
{ "name": "Welcome offer", "type": "push_notification", "priority": 5,
  "segments": ["All users"], "schedule": { "startTime": "2026-11-01T09:00:00.000Z" },
  "content": { "en": { "headline": "Welcome", "body": "Your offer is ready" } } }

# 3. Submit it for review
POST /api/v1/campaigns/{id}/submit-review
{ "note": "Ready for review" }

# 4. A different admin or can-approve user approves it into Test mode
POST /api/v1/campaigns/{id}/approve
{ "activateTarget": "test" }
AreaEndpoints
CampaignsPOST/GET /campaigns, GET/PUT/DELETE /campaigns/{id}, PATCH /campaigns/{id}/status, POST /campaigns/{id}/submit-review | approve | reject | recall, GET /campaigns/{id}/validate, GET /campaigns/pending-review, GET /campaigns/{id}/experiment/results
JourneysPOST/GET /journeys, GET/PUT/DELETE /journeys/{id}, PATCH /journeys/{id}/status, POST /journeys/{id}/enroll, GET /journeys/enrollments/user/{userId}
NotificationsGET /notifications?userId=&campaignId=&status=, POST /notifications (one person), POST /notifications/segment/{segmentId} (approval needed for non-approvers), GET /notifications/campaign/{id}/progress
ChannelsGET/POST /notifications/channels, GET/PUT/DELETE /notifications/channels/{id}, POST /notifications/channels/{id}/test, POST /notifications/channels/email/test (see Connect a messaging provider)
ApprovalsGET /approvals, GET /approvals/summary, POST /approvals/{id}/approve | reject | cancel
SDK (in-app)POST /sdk/v1/campaigns with the SDK key, for a placement

Webhooks campaign.submitted_for_review, campaign.approved, campaign.rejected and campaign.status_changed are described in the REST guide. Activating a journey that uses a campaign which can't send returns 409 JOURNEY_CAMPAIGN_NOT_SENDABLE.

Results

Measuring results

  • Delivery: per-channel sent, skipped by reason, deferred and failed on the campaign, and per-status counts at /notifications/campaign/{id}/progress.
  • Engagement: impressions, taps and conversions from the app land in Analytics; pulled campaigns are tracked with trackCampaignImpression and trackCampaignTap. Live (not test) events that carry a campaignId, and events on a campaign's in-app message, also add to the campaign's own statistics (impressions, clicks, conversions, CTR) and to each A/B variant's.
  • Experiments: A/B results per variant at /campaigns/{id}/experiment/results.
Be aware

Current limits

  • Frequency is optional and off by default: set it with the Frequency field in the campaign's Schedule step, from a preset or under Advanced (rules.frequencyCapping). For in-app campaigns the server applies the whole rule (most views, most taps, a wait between shows, never again after a tap, close or conversion), counted in the workspace time zone: see Popup frequency: the rule. Push, SMS, WhatsApp and email count sends per channel against the rule's most views, so a journey that repeats the campaign stops at the limit, and the extra sends show as frequencyCapped. Those send windows are fixed UTC hours, days or 7-day blocks, not rolling; a month or lifetime period is counted per day for sends. The server counts in-app views from the SDK's campaign impression and tap events, so it never caps a device without analytics consent; from SDK 2.4.0 the device's own count caps it even without consent (see Popup frequency: in the app). On the Free plan a campaign can only use no rule or Once per day; other rules need the Pro or Enterprise plan.
  • Block channels: a block channel targets the campaign's segments (or everyone) and its start and end; excluded segments, Hijri dates, prayer blackouts and send windows are not applied to it, and a campaign with excluded segments shows its block channel as not delivered. A/B variants do not split a block channel.
  • A/B on outbound channels: variants split pulled in-app campaigns only. Push, SMS, WhatsApp and email send the main content.
  • Quiet hours: a person's quiet hours aren't applied to customer messages; use the campaign's prayer blackout and custom windows.
  • Push conversions: a push send has no conversion attribution; campaign statistics count what the app reports.
  • Journeys: a person enters a journey once, ever. Exit criteria are checked when the person's next step is due. Location triggers are geofence events only; there is no trigger on a person's distance from a point.

Next