Buttons, links and deep links
A marketer can change where a button goes without an app release: a block's destination field, an in-app campaign's destination, or a push's deep link. ContentFlow delivers that value to your app as a plain string. Your app decides what it opens. This page covers where each destination comes from, what ContentFlow accepts in each place, how to open one safely, and which event to record when the person taps.
- A block's destination is a field you read by tag, usually
#cta_url; an in-app campaign's isactionUrl(from the campaign's Destination); a push carries it asdeepLink. - ContentFlow refuses
javascript:,data:and similar schemes on save, but accepts any other app scheme, so check every destination against your own allow-list before you open it. - Record
trackCtaClickfor a button,trackTapfor a tap on the whole block, andtrackCampaignTapfor a campaign; one call per gesture, because each counts as a click. - A push carries only web addresses, so use an
https://universal link or app link when the same destination must also work from a push.
Where a destination comes from
| Source | Set by | Reaches the app as | Checked by ContentFlow |
|---|---|---|---|
A block field of type url, by convention tagged #cta_url | Developers declare the field; marketers fill it in Dynamic Blocks | A string in the block's values: block.get('cta_url') | On save, and again on delivery: an unsafe stored value is delivered as an empty string |
A block field of type text that happens to hold a link | Same | A string in the block's values | Not at all: a text field is delivered verbatim |
| An in-app campaign's Destination | Marketers, in the campaign's content | actionUrl on the campaign the SDK returns (actionURL in Swift) | On save; a link saved before this check existed is kept until someone changes that link |
| A push sent by a campaign or journey | The campaign's or journey step's destination | deepLink in the push payload | Only web addresses are kept (see the next section) |
The block field type is described in Blocks and fields: field types. Declare destination fields as url, not text, so the dashboard shows what kind of link was entered and refuses unsafe ones.
What each surface accepts
| Destination | Block url field | In-app campaign | Push from a campaign or journey |
|---|---|---|---|
https://host/path | Accepted (not with user:pass@ before the host) | Accepted | Kept |
http://host/path | Rejected: iOS and Android block cleartext HTTP by default | Accepted | Kept, but the OS blocks it by default |
An app path: /in-app/offers, /offers | Accepted (any single-slash path; //host is rejected) | Accepted (//host is rejected) | Left out of the push |
Your app's deep link: myapp://offers/42 | Accepted in scheme://… form, for any scheme not on the blocked list | Accepted in scheme://… form | Left out of the push |
mailto:, tel: | Accepted with a complete address or a dialable number | Accepted | Left out of the push |
javascript:, data:, vbscript:, file:, blob: | Rejected (also about:, filesystem:, view-source:, jar:, chrome:, ws: and others) | Rejected | Left out |
Spaces, control or invisible characters; no scheme at all (example.com) | Rejected, never guessed or rewritten; at most 2,048 characters | Spaces and a missing scheme are rejected | Kept only if it starts with http:// or https:// |
A rejected value is never stored: the dashboard shows the problem on the field and nothing is saved. A valid value is stored exactly as typed, apart from trimming spaces at either end.
Why the rules differ
The three surfaces are checked by three different pieces of ContentFlow, and they hand the value to different readers:
- Blocks and in-app campaigns hand the destination to your code, which can route an app path or your own scheme to a screen. So both accept paths and deep links. The block field is the stricter of the two (no
http://) because it is delivered on every sync to every device; the campaign check allowshttp://and keeps older links until someone changes them. - A push is opened from the notification, and ContentFlow passes on a destination only when it is an absolute web address (
http://orhttps://). An app path or amyapp://link on the same campaign is delivered to in-app placements but silently left out of the push.
So one campaign can show a button that opens /offers in the app and send a push with no link at all. If a destination has to work everywhere, use a universal link (iOS) or an app link (Android): one https:// address on a domain you own, which the OS opens in your app.
scheme://… as a deep link. That includes schemes that belong to other apps, and Android's intent://. Only your app knows which schemes and hosts are yours, so it has to check.How a tap flows
- The dashboard sends the value on save.
- ContentFlow checks the value (through the Management API) and stores it exactly as typed.
If this step is skipped: An unsafe value (javascript:, http:// on a block, a missing scheme) is refused with a message naming the field; nothing is stored.
- The SDK fetches blocks and campaigns from the SDK API as usual.
- The destination arrives as a plain string.
If this step is skipped: A url-typed value that no longer passes the check (for example one stored before the field was retyped from text) is delivered as an empty string.
- Queued with the other events; campaign taps are sent at once.
If this step is skipped: Without analytics consent the click is dropped in the SDK; routing still works.
- Your code decides whether this is something it opens.
If this step is skipped: A destination that isn't an app path, your own scheme, a host you own, mailto: or tel: is ignored.
- An app path or your own scheme goes to your router; an allowed web address or
mailto:/tel:goes to the OS. - The click reaches analytics with the next batch.
- ContentFlow stores the event for analytics.
Open it safely
- Keep an allow-list of your app's own scheme and the web hosts you own. Compare the scheme and host case-insensitively.
- Route app paths yourself. A value that starts with one
/(/in-app/offers) is a screen in your app. A value that starts with//is a web address on another host, not a path. - Route your own scheme yourself (
myapp://offers/42), rather than handing it back to the OS. - Open allowed web addresses with the system browser or your universal-link handling, and only when there is no
user@before the host. - Hand
mailto:andtel:to the OS if your app offers email or calls. - Ignore everything else, including an empty value. Keep a sensible default for the button, or hide it.
The samples below define the check once per platform as resolveDestination, and use it for blocks and campaigns alike.
Route a block CTA
Read the destination by tag, record the click, then route it. In Swift and Kotlin a url field isn't one of the SDK's typed field kinds yet, so it is read as a raw value.
// destinations.ts: steps 1 and 2, shared with the React and React Native tabs.
// 1. Your allow-list: your app's own scheme and the web hosts you own.
const APP_PREFIX = 'myapp://';
const WEB_HOSTS = ['www.example.com', 'offers.example.com'];
export type Destination = { kind: 'app'; path: string } | { kind: 'external'; url: string };
// 2. Turn a delivered destination into something safe to open, or null.
export function resolveDestination(raw: string): Destination | null {
const value = raw.trim();
if (/^\/(?!\/)/.test(value)) return { kind: 'app', path: value }; // /in-app/offers
if (value.toLowerCase().startsWith(APP_PREFIX)) {
return { kind: 'app', path: '/' + value.slice(APP_PREFIX.length) }; // myapp://offers/42
}
const web = /^https:\/\/([^/?#@:]+)(?::\d+)?(?:[/?#]|$)/i.exec(value);
if (web && WEB_HOSTS.includes(web[1].toLowerCase())) return { kind: 'external', url: value };
if (/^(mailto|tel):\S+$/i.test(value)) return { kind: 'external', url: value };
return null; // anything else: ignore
}
// 3. The person tapped the block's button: record it, then route.
const block = client.getBlock('home_banner');
if (block) {
ctaButton.onclick = () => {
client.trackCtaClick(block);
const to = resolveDestination(String(block.get('cta_url') ?? ''));
if (to?.kind === 'app') router.navigate(to.path);
else if (to) window.open(to.url, '_blank', 'noopener');
};
}import { useCF, useCFBlock } from '@contentflow/sdk/react';
import { useNavigate } from 'react-router-dom';
// 1-2. The allow-list and resolveDestination from the JavaScript tab.
import { resolveDestination } from './destinations';
function OfferButton() {
const cf = useCF();
const navigate = useNavigate();
const block = useCFBlock('home_banner'); // tracks the impression
if (!block || !block.ctaVisible) return null;
// 3. The person tapped the block's button: record it, then route.
const onClick = () => {
cf.trackCtaClick(block);
const to = resolveDestination(String(block.get('cta_url') ?? ''));
if (to?.kind === 'app') navigate(to.path);
else if (to) window.open(to.url, '_blank', 'noopener');
};
return <button onClick={onClick}>{block.ctaLabel}</button>;
}import { Linking, Pressable, Text } from 'react-native';
import { useCF, useCFBlock } from '@contentflow/sdk/react-native';
// 1-2. The allow-list and resolveDestination from the JavaScript tab (no DOM needed).
import { resolveDestination } from './destinations';
function OfferButton({ navigate }: { navigate: (path: string) => void }) {
const cf = useCF();
const block = useCFBlock('home_banner'); // tracks the impression
if (!block || !block.ctaVisible) return null;
// 3. The person tapped the block's button: record it, then route.
// (<CFBanner onPress> records trackTap for a tap on the whole card instead.)
const onPress = () => {
cf.trackCtaClick(block);
const to = resolveDestination(String(block.get('cta_url') ?? ''));
if (to?.kind === 'app') navigate(to.path);
else if (to) void Linking.openURL(to.url);
};
return <Pressable onPress={onPress}><Text>{block.ctaLabel}</Text></Pressable>;
}import UIKit
import ContentFlow
// 1. Your allow-list: your app's own scheme and the web hosts you own.
let appScheme = "myapp"
let webHosts: Set<String> = ["www.example.com", "offers.example.com"]
enum Destination { case app(path: String), external(URL) }
// 2. Turn a delivered destination into something safe to open, or nil.
func resolveDestination(_ raw: String) -> Destination? {
let value = raw.trimmingCharacters(in: .whitespacesAndNewlines)
if value.hasPrefix("/") && !value.hasPrefix("//") { return .app(path: value) }
guard let url = URL(string: value), let scheme = url.scheme?.lowercased() else { return nil }
switch scheme {
case appScheme:
return .app(path: "/" + (url.host ?? "") + url.path)
case "https":
guard let host = url.host?.lowercased(), webHosts.contains(host), url.user == nil else { return nil }
return .external(url)
case "mailto", "tel":
return .external(url)
default:
return nil
}
}
// A url field reaches this SDK as an unknown field type: read its raw string.
func ctaDestination(_ block: ContentBlock) -> String {
switch block.field("#cta_url")?.value {
case .unknown(_, let raw)?: return raw.stringValue ?? ""
case .text(let text)?: return text
default: return ""
}
}
// 3. The person tapped the block's button: record it, then route.
@MainActor
func ctaTapped(_ block: ContentBlock) async {
_ = try? await client.trackEngagement(.ctaClick(block))
switch resolveDestination(ctaDestination(block)) {
case .app(let path)?: router.open(path)
case .external(let url)?: _ = await UIApplication.shared.open(url)
case nil: break
}
}import android.content.Context
import android.content.Intent
import android.net.Uri
import click.contentflow.*
// 1. Your allow-list: your app's own scheme and the web hosts you own.
const val APP_SCHEME = "myapp"
val WEB_HOSTS = setOf("www.example.com", "offers.example.com")
sealed interface Destination {
data class App(val path: String) : Destination
data class External(val uri: Uri) : Destination
}
// 2. Turn a delivered destination into something safe to open, or null.
fun resolveDestination(raw: String): Destination? {
val value = raw.trim()
if (value.startsWith("/") && !value.startsWith("//")) return Destination.App(value)
val uri = Uri.parse(value)
return when (uri.scheme?.lowercase()) {
APP_SCHEME -> Destination.App("/" + (uri.host ?: "") + (uri.path ?: ""))
"https" -> if (uri.host?.lowercase() in WEB_HOSTS && uri.userInfo == null) Destination.External(uri) else null
"mailto", "tel" -> Destination.External(uri)
else -> null
}
}
// A url field reaches this SDK as an unknown field type: read it raw. Tags need the leading #.
fun ctaDestination(block: ContentBlock): String =
(block.value(FieldKeys.raw("#cta_url")) as? JsonValue.Str)?.value.orEmpty()
// 3. The person tapped the block's button (from a coroutine): record it, then route.
suspend fun onCtaTapped(context: Context, block: ContentBlock) {
client.track(CFEngagementEvent.of(block, CFEngagementEvent.CTA_CLICK))
when (val to = resolveDestination(ctaDestination(block))) {
is Destination.App -> router.open(to.path)
is Destination.External -> context.startActivity(Intent(Intent.ACTION_VIEW, to.uri))
null -> Unit
}
}import 'package:url_launcher/url_launcher.dart';
final cf = ContentFlow.instance;
// 1. Your allow-list: your app's own scheme and the web hosts you own.
const appScheme = 'myapp';
const webHosts = {'www.example.com', 'offers.example.com'};
class Destination {
const Destination.app(String this.appPath) : external = null;
const Destination.external(Uri this.external) : appPath = null;
final String? appPath;
final Uri? external;
}
// 2. Turn a delivered destination into something safe to open, or null.
Destination? resolveDestination(String raw) {
final value = raw.trim();
if (value.startsWith('/') && !value.startsWith('//')) return Destination.app(value);
final uri = Uri.tryParse(value);
if (uri == null) return null;
switch (uri.scheme.toLowerCase()) {
case appScheme:
return Destination.app('/${uri.host}${uri.path}');
case 'https':
return webHosts.contains(uri.host.toLowerCase()) && uri.userInfo.isEmpty
? Destination.external(uri)
: null;
case 'mailto':
case 'tel':
return Destination.external(uri);
default:
return null;
}
}
// 3. The person tapped the block's button: record it, then route.
// (block.cta?.action reads #cta_url or #cta_action too.)
Future<void> onCtaTapped(CFBlock block) async {
await cf.trackCtaClick(blockKey: block.key, instanceId: block.instanceId);
final to = resolveDestination('${block.get('cta_url') ?? ''}');
if (to?.appPath != null) {
router.go(to!.appPath!);
} else if (to?.external != null) {
await launchUrl(to!.external!);
}
}# 1-2. Routing happens in your app; there is nothing to call for it.
# 3. Record the CTA click for the block instance the person tapped.
curl -X POST https://api.contentflow.click/sdk/v1/events \
-H "X-CF-Key: $CF_SDK_KEY" -H "X-CF-Device: $CF_DEVICE" -H 'Content-Type: application/json' \
-d '{"events":[{"type":"cta_click","blockKey":"home_banner","instanceId":"'"$INSTANCE_ID"'"}]}'Route a campaign tap
An in-app campaign from getCampaigns(placement) carries its destination as actionUrl. Use the same check: a campaign accepts http:// and may hold a link saved before validation existed.
trackCampaign and the Flutter trackCampaignImpression, trackCampaignTap and trackCampaignConversion are new in SDK 2.4.0. Before 2.4.0 those two SDKs can read campaigns but not record campaign events. JavaScript and Swift record them in earlier versions too.import { resolveDestination } from './destinations'; // from the block sample
// 1. The highest-priority campaign for the placement, and its impression.
const [top] = await client.getCampaigns('home_hero');
if (top) {
client.trackCampaignImpression(top);
// 2. On tap: record the tap (sent at once), then route actionUrl through the same check.
heroButton.onclick = () => {
client.trackCampaignTap(top);
const to = resolveDestination(top.actionUrl);
if (to?.kind === 'app') router.navigate(to.path);
else if (to) window.open(to.url, '_blank', 'noopener');
};
}
// 3. Later, when the person completes what the campaign offered:
// client.trackCampaignConversion(top);import { useCF, useCFCampaigns } from '@contentflow/sdk/react';
import { useNavigate } from 'react-router-dom';
import { resolveDestination } from './destinations';
function Hero() {
const cf = useCF();
const navigate = useNavigate();
// 1. The highest-priority campaign; the hook tracks one impression per campaign.
const [top] = useCFCampaigns('home_hero');
if (!top) return null;
// 2. On tap: record the tap, then route actionUrl through the same check.
const onClick = () => {
cf.trackCampaignTap(top);
const to = resolveDestination(top.actionUrl);
if (to?.kind === 'app') navigate(to.path);
else if (to) window.open(to.url, '_blank', 'noopener');
};
// 3. Later, on the goal: cf.trackCampaignConversion(top)
return <button onClick={onClick}>{top.ctaLabel || top.title}</button>;
}import { Linking } from 'react-native';
import { CFCampaignBanner } from '@contentflow/sdk/react-native';
import { resolveDestination } from './destinations';
// 1. Renders the highest-priority campaign and tracks its impression.
// 2. onPress fires after the banner has recorded trackCampaignTap: just route.
// (With a custom `render`, the banner doesn't wrap it in a pressable: call cf.trackCampaignTap yourself.)
function Hero({ navigate }: { navigate: (path: string) => void }) {
return (
<CFCampaignBanner
placement="home_hero"
onPress={(c) => {
const to = resolveDestination(c.actionUrl);
if (to?.kind === 'app') navigate(to.path);
else if (to) void Linking.openURL(to.url);
}}
/>
);
}
// 3. Later, on the goal: useCF().trackCampaignConversion(campaign)// resolveDestination from the block sample.
// 1. The highest-priority campaign for the placement, and its impression.
let campaigns = try await client.getCampaigns(placement: "home_hero")
if let top = campaigns.first {
try await client.trackCampaign(top, type: .impression)
// 2. On tap: record the tap, then route actionURL through the same check.
try await client.trackCampaign(top, type: .tap)
switch resolveDestination(top.actionURL?.absoluteString ?? "") {
case .app(let path)?: router.open(path)
case .external(let url)?: _ = await UIApplication.shared.open(url)
case nil: break
}
// 3. Later, when the person completes what the campaign offered:
// try await client.trackCampaign(top, type: .conversion)
}// resolveDestination from the block sample. From a coroutine.
// 1. The highest-priority campaign for the placement, and its impression.
// trackCampaign: SDK 2.4.0 and later.
val top = client.getCampaigns("home_hero").firstOrNull()
if (top != null) {
client.trackCampaign(top, EngagementType.Impression)
// 2. On tap: record the tap, then route actionUrl through the same check.
client.trackCampaign(top, EngagementType.Tap)
when (val to = resolveDestination(top.actionUrl)) {
is Destination.App -> router.open(to.path)
is Destination.External -> context.startActivity(Intent(Intent.ACTION_VIEW, to.uri))
null -> Unit
}
// 3. Later, when the person completes what the campaign offered:
// client.trackCampaign(top, EngagementType.Conversion)
}// resolveDestination from the block sample.
// trackCampaign* calls: SDK 2.4.0 and later.
// 1. The highest-priority campaign for the placement, and its impression.
final campaigns = await cf.getCampaigns('home_hero');
if (campaigns.isNotEmpty) {
final top = campaigns.first;
await cf.trackCampaignImpression(top);
// 2. On tap: record the tap, then route the destination through the same check.
// CFCampaign has no actionUrl field: read ctaUrl from the content for the app's language.
await cf.trackCampaignTap(top);
final content = top.contentForLocale('ar') ?? top.content;
final to = resolveDestination('${content['ctaUrl'] ?? ''}');
if (to?.appPath != null) {
router.go(to!.appPath!);
} else if (to?.external != null) {
await launchUrl(to!.external!);
}
// 3. Later, when the person completes what the campaign offered:
// await cf.trackCampaignConversion(top);
}# 1. The campaigns for the placement, highest priority first. The destination is ctaUrl in each
# campaign's content (or in its metadata).
curl -X POST https://api.contentflow.click/sdk/v1/campaigns \
-H "X-CF-Key: $CF_SDK_KEY" -H "X-CF-Device: $CF_DEVICE" -H 'Content-Type: application/json' \
-d '{"placementId":"home_hero"}'
# 2. On tap: record it with the campaign and placement ids (routing happens in your app).
curl -X POST https://api.contentflow.click/sdk/v1/events \
-H "X-CF-Key: $CF_SDK_KEY" -H "X-CF-Device: $CF_DEVICE" -H 'Content-Type: application/json' \
-d '{"events":[{"type":"tap","campaignId":"'"$CAMPAIGN_ID"'","placementId":"home_hero"}]}'From a push
A push carries its destination as deepLink: a custom key in the APNs payload on iOS, and in the message data on Android (FCM). Campaigns and journeys set it only when the call-to-action is a web address, so expect an https:// link or nothing. Read it in your notification-open handler and pass it through the same resolveDestination check before you route it: a link in a push is just as much a marketer's value as a block field.
Reporting push opens, token handling and the remaining push limits are in Push notifications: opens and deep links.
Which tracking call
Each of these counts as one click in the dashboards, so record one call per gesture: a button tap inside a block is a CTA click, not a CTA click and a tap.
| What happened | JavaScript, React, React Native | Swift | Kotlin | Flutter |
|---|---|---|---|---|
| The person tapped the block's button | trackCtaClick(block) | trackEngagement(.ctaClick(block)) | track(CFEngagementEvent.of(block, CTA_CLICK)) | trackCtaClick(blockKey:, instanceId:) |
| The person tapped the whole block (a card with no separate button) | trackTap(block); <CFBanner> does it for you | trackEngagement(.tap(block)) | track(CFEngagementEvent.of(block, TAP)) | trackEngagement(type: CFEventType.tap, …) |
| The person closed the block | trackDismiss(block) | trackEngagement(.dismiss(block)) | track(CFEngagementEvent.of(block, DISMISS)) | trackDismiss(blockKey:, instanceId:) |
| The person completed the goal (applied, bought, signed up) | trackConversion(block) | trackEngagement(.conversion(block)) | track(CFEngagementEvent.of(block, CONVERSION)) | trackConversion(blockKey:, instanceId:) |
| The person tapped an in-app campaign | trackCampaignTap(campaign); <CFCampaignBanner> does it for you | trackCampaign(campaign, type: .tap) | trackCampaign(campaign, EngagementType.Tap) (2.4.0 and later) | trackCampaignTap(campaign) (2.4.0 and later) |
| A campaign led to the goal | trackCampaignConversion(campaign) | trackCampaign(campaign, type: .conversion) | trackCampaign(campaign, EngagementType.Conversion) (2.4.0 and later) | trackCampaignConversion(campaign) (2.4.0 and later) |
Track before you navigate: the event is queued (campaign taps are sent at once), so leaving the screen doesn't lose it. A conversion often happens on a later screen; keep the block or campaign object, or its ids, until then. Block clicks and conversions feed the instance's counters and its A/B results; see Blocks: tracking and Analytics: what to track.
When it doesn't work
| What you see | Why, and the fix |
|---|---|
| The dashboard won't save a destination | It failed the check for that surface: http:// on a block field, a missing scheme (example.com), a custom scheme without // (myapp:offers), //host, a space, or a blocked scheme. The message names the field; see What each surface accepts. |
| The destination arrives empty in the app | The field is typed url and the stored value no longer passes the check (typically one saved while the field was still text), so delivery sends an empty string. Re-enter it in Dynamic Blocks. |
| The push opens the app's home screen, not the offer | The campaign's destination is an app path or a myapp:// link, which a push leaves out. Use an https:// universal link or app link. |
Swift or Kotlin logs a warning about an unknown field type for #cta_url | Those SDKs don't model the url type yet and keep the raw value. Read it raw, as in the block sample. |
| Clicks look doubled for one block | The app records both trackTap and trackCtaClick for one gesture, for example a <CFBanner> (which records the tap) plus your own CTA call. Record one. |
| Clicks never show up | No analytics consent: the SDK drops the event and the link still opens. See Analytics: when it doesn't work. |
Next
- Blocks and fields: field types: declare a
urlfield for a destination. - Offline, queues and delivery: what happens to a click recorded with no network.