Security
Each ContentFlow credential can do only its own job, so a key leaked from an app can't edit content or read profiles. This page covers which key each caller uses, how the SDK finds the API, and the transport protections each SDK offers.
- Only a publishable SDK key (
<workspace-id>_cfpk<40 hex>, created under Settings → API keys) goes in an app; secret API keys (pk_…), write keys and read keys stay on your server or CI. - The key decides the workspace and environment; you never send a tenant id to override it.
- Every SDK defaults to
https://api.contentflow.click/sdk/v1; a configured base URL must behttps, except for local development hosts. - Certificate pinning is off by default and available in Swift, Kotlin and Flutter, never in JavaScript; it needs at least two pins.
- Request signing protects nothing: no ContentFlow server verifies a request signature.
Four kinds of key
Publishable SDK keys are created, rotated and revoked under Settings → API keys. The write key and read key live under Developers → Your keys, where the Environment toggle shows the test or live values; they are shown to admin and editor seats only. Secret pk_ API keys are managed separately, under Settings → API Keys.
Keys are stored hashed, so a live publishable key or a secret key cannot be shown again after it is created. The one exception is the workspace's test publishable key: it is stored retrievably so the portal and the API explorer can display it. Publishable test keys are not secrets; live and secret keys remain hash-only.
| Key | Header | Caller | Can call |
|---|---|---|---|
Publishable SDK key: <workspace-id>_cfpk<40 hex> (recommended) | X-CF-Key (or X-API-Key) | Your app. It is publishable. Stored only as a hash, so the full value is shown once when you create or rotate it. | Every /sdk/v1 route: read content, track events, record consent and push tokens for this installation |
Secret API key: pk_live_… / pk_test_… | X-CF-Key | Your server or CI only. Created, rotated and revoked under Settings → API Keys (admins and editors). | The same routes as the SDK key |
Write key: wk_live_… | X-CF-Write-Key | Your server or CI only | Block and string registration: POST /cards/register, POST /cards/sync, POST /api/v1/strings/sync, POST /api/v1/strings/disable |
Read key: rk_live_… | X-CF-Read-Key | Your server only | The read API: a user's devices and segments (push tokens never returned) |
Derived key: <workspace-id>_app / <workspace-id>_test (deprecated) | X-CF-Key (or X-API-Key) | Existing apps only | Same as the publishable key. Still accepted by default, but anyone who knows your workspace id can guess it. The _test key sends its data to the workspace's sandbox when one exists; the sandbox's own test publishable key is managed from inside the sandbox (Settings → API keys there). |
Switch to a _cfpk publishable key, then turn the derived keys off: an admin can disable them for the workspace (PUT /settings/sdk-key-policy, allowed only once a live publishable key exists), or operators can set SDK_DERIVED_KEYS=reject on the SDK gateway. A disabled derived key answers 401 INVALID_SDK_KEY.
The dashboard and the management API use a portal sign-in token with X-Tenant-Id; that never ships in an app either. See How it works: four callers for what each caller may never do.
Keep server keys out of apps
- Never put a write key, read key, secret
pk_key, portal credentials or signing secrets in an app or a browser. Apps use a publishable SDK key. - The key decides the workspace and environment. An
X-Tenant-Idheader is not needed and does not override the key. - Send the key in a header. Only the live-updates stream accepts it as a
?key=query parameter, because a browserEventSourcecannot send headers. - Use a test-environment key while developing and the live key only in production builds. See Test and live keys.
- The API explorer takes only the publishable test key. Write-key routes show the curl to run from your server instead of sending it.
Sandboxes replace the in-workspace test environment. Once that switch is on, a test key is served from the workspace's sandbox. A test key of a workspace without a sandbox can only read live content; its writes answer 409 SANDBOX_REQUIRED. See Testing: sandboxes.
Rotating keys
Rotate a write key or read key from Developers → Your keys if it leaks. Publishable keys and secret pk_ keys are created, rotated and revoked under Settings → API keys: Create key and Rotate show the full value once, so copy it then; after a rotation the previous value stops working; Revoke disables a key but keeps it listed as history. A revoked key answers 401 INVALID_SDK_KEY; to move an app off a derived key, create a publishable key, ship it, then disable the derived keys as above. From 2.3.0 every SDK treats a rejected key as permanent for that client and stops sending; ship the corrected key and start a new client. See A rejected key.
Abuse limits
The SDK gateway limits each client IP to 300 requests a minute with a burst of 100 (IPv6 clients are counted by /64), currently in monitor mode by default. It trusts X-Forwarded-For only from configured proxies. Per-user caps apply on top, such as consent changes and new push devices; they answer 429 USER_RATE_LIMITED with a Retry-After header.
Base URL
The SDK API is served at https://api.contentflow.click/sdk/v1, the default in every SDK from 2.2.1. app.contentflow.click is the portal; its /sdk/v1 and /api/v1/sdk paths, and https://api.contentflow.click/api/v1/sdk, reach the same SDK gateway and keep working for existing apps. A base that does not end in /sdk/v1 logs a one-time "legacy path" notice. Pass the base only, without an operation such as /sync.
Prefer configuring it outside your code, so a future host change needs no code change. Precedence: an explicit baseUrl in code, then platform config, then the default.
// 1a. Plain page: set the global before the SDK loads.
// <script>window.__CONTENTFLOW_BASE_URL__ = 'https://api.contentflow.click/sdk/v1';</script>
// 1b. Or pass your build's value (Vite shown; Next.js: process.env.NEXT_PUBLIC_CONTENTFLOW_BASE_URL,
// Expo: process.env.EXPO_PUBLIC_CONTENTFLOW_BASE_URL). Unset falls through to the default.
const client = createCFClient({ publicKey, baseUrl: import.meta.env.VITE_CONTENTFLOW_BASE_URL });import { CFProvider } from '@contentflow/sdk/react';
// 1. Pass your build's value (Vite shown; Next.js: process.env.NEXT_PUBLIC_CONTENTFLOW_BASE_URL).
// Unset falls through to window.__CONTENTFLOW_BASE_URL__, then to the default.
export function App({ children }: { children: React.ReactNode }) {
return (
<CFProvider config={{ publicKey, baseUrl: import.meta.env.VITE_CONTENTFLOW_BASE_URL }}>
{children}
</CFProvider>
);
}import { CFProvider } from '@contentflow/sdk/react-native';
// 1. Expo: pass the EXPO_PUBLIC_ value. Unset falls through to the default.
// Bare React Native: pass the value from your own build config, or omit baseUrl.
export function App({ children }: { children: React.ReactNode }) {
return (
<CFProvider config={{ publicKey, baseUrl: process.env.EXPO_PUBLIC_CONTENTFLOW_BASE_URL }}>
{children}
</CFProvider>
);
}<!-- 1. Info.plist -->
<key>ContentFlowBaseURL</key>
<string>https://api.contentflow.click/sdk/v1</string><!-- 1. Android: AndroidManifest.xml, inside <application>.
JVM: the system property -Dcontentflow.baseUrl=… or the environment variable CONTENTFLOW_BASE_URL. -->
<meta-data android:name="click.contentflow.BASE_URL"
android:value="https://api.contentflow.click/sdk/v1"/># 1. A compile-time define.
flutter run --dart-define=CONTENTFLOW_BASE_URL=https://api.contentflow.click/sdk/v1- https only. A configured value must be
https;httpis accepted only forlocalhost,127.0.0.1and10.0.2.2(the Android emulator). An invalid value is ignored with a logged error, and the default is used. An explicitbaseUrlin code is not newly validated. - Never remote. The base URL is never fetched from a server: a remote value could redirect your app's traffic and keys, and would bypass certificate pinning.
- Wrong host. A
baseUrlthat does not point at a ContentFlow API is reported as an error (CFInvalidBaseUrlErrorin JavaScript,InvalidBaseUrlin Kotlin); Swift rejects a 2xx body with nosuccessflag asunenvelopedResponserather than rendering it.
Certificate pinning
Pinning is off by default in every SDK. When enabled, the app only talks to a server whose public key (SPKI) matches one of your pins, on top of the normal certificate chain and hostname checks, which always still apply. A mismatched server never receives a request: the check runs during or right after the TLS handshake, before any request bytes are sent.
Pins are sha256/<base64> hashes of the server's public key, so they survive certificate renewal as long as the key is reused. At least two distinct pins are required (the current key plus a backup). Too few pins or a malformed pin fails closed: an error at configuration, never a silent fallback to an unpinned connection. Only the baseUrl host is pinned; other hosts, such as CDNs and image URLs, use normal TLS validation.
Not available in the JavaScript SDK: fetch cannot pin, so setting enableCertificatePinning throws at init. Pin in a native wrapper, or send traffic through a trusted server proxy.
Not available in React: it uses the JavaScript SDK, where fetch cannot pin, so setting enableCertificatePinning throws at init. Pin in a native wrapper, or send traffic through a trusted server proxy.
Not available in React Native: it uses the JavaScript SDK, so setting enableCertificatePinning throws at init, as on every JavaScript platform. Leave it off, or send traffic through a trusted server proxy.
// 1. Pins every SDK request, including the live-updates stream.
// Not pinned: block images in the generic block view (SwiftUI AsyncImage).
let client = ContentFlow.start(configuration: ContentFlowConfiguration(
apiKey: AppConfig.contentFlowKey, tenantId: AppConfig.contentFlowWorkspace,
certificatePins: ["sha256/<current>", "sha256/<backup>"]))// 1. Pins every SDK request through the core transport (Android, JVM, and a caller-supplied httpClient).
// Not pinned: anything your app loads itself, such as images via Coil.
ContentFlow.start(this, ContentFlowConfiguration(
apiKey = BuildConfig.CONTENTFLOW_KEY, tenantId = BuildConfig.CONTENTFLOW_WORKSPACE,
security = SecurityConfig(
enableCertificatePinning = true,
pinnedCertificates = listOf("sha256/<current>", "sha256/<backup>"))))// 1. Pins every SDK request, including live updates; proxies are bypassed and http:// is refused while on.
// Not pinned: anything your app loads itself, such as Image.network.
await ContentFlow.create(const ContentFlowConfig(
publicKey: CONTENTFLOW_SDK_KEY,
enableCertificatePinning: true,
pinnedCertificates: ['sha256/<current>', 'sha256/<backup>'],
));Compute the pin of the API host (use the host of your baseUrl):
openssl s_client -connect api.contentflow.click:443 -servername api.contentflow.click </dev/null 2>/dev/null \
| openssl x509 -pubkey -noout \
| openssl pkey -pubin -outform der \
| openssl dgst -sha256 -binary | base64
# -> use as "sha256/<output>"Current SPKI pins (certificates valid until 2026-12-11):
| Host | Pin |
|---|---|
api.contentflow.click (the default from 2.2.1) | sha256/uYvQE/yGyakA32XBHLaqAjnLJHS82yNLfoh4CRV6lX8= |
app.contentflow.click (the default in 2.2.0 and earlier) | sha256/A3O5BGFGDQZm4wNh1QpbA4QYeI0vT6D5tCSOy65IrhI= |
The two hosts have different keys. If you pin and relied on the old default host, add the api.contentflow.click pins, or set baseUrl explicitly, before upgrading to 2.2.1. Always ship a backup pin as well.
Rotation: server certificates are renewed with the same key (for example certbot renew --reuse-key). To change keys, first ship an app release that includes a backup pin for the new key, then rotate. Rotating to an unpinned key breaks every pinned app until it updates.
Request signing
No ContentFlow server verifies a request signature, and a signing secret shipped in an app can be extracted from it, so signing adds no protection. Requests are authenticated by the publishable key, which the server binds to exactly one workspace.
| SDK | Behaviour |
|---|---|
| JavaScript | Not available: setting enableRequestSigning throws at init. |
| Swift | CFRequestSigner is deprecated and never used. |
| Kotlin | SecurityConfig.enableRequestSigning and signingSecretKey are deprecated and have no effect; turning signing on reports one request_signing_unsupported error diagnostic per client. Remove both options. |
| Flutter | enableRequestSigning adds X-CF-Timestamp and X-CF-Signature headers, which nothing on the server checks. |
What the SDK stores on the device
Storage is not encrypted. The device id is a random UUID, never an advertising or hardware id.
- JavaScript: the device id, user id and cached content are in the platform's normal storage (
localStorage, AsyncStorage).enableSecureStoragehas no effect; supply appropriate host storage if you need more. Pending events are memory-only unless the device was already known to be offline, so they are not a durable audit log. - Swift: the device id, consent state and segment names in
UserDefaults; blocks and strings in the app's Caches directory.CFSecureStorageis deprecated and never used. None of the stored values is a credential. - Kotlin: the device id in the SDK's private SharedPreferences, the user id in memory, and consent, segment names and content cache as unencrypted files in the app's private storage.
enableSecureStorageis deprecated with no effect. None of them is a credential.
Webhook signatures
Webhooks are signed. The signing secret (whsec_…) is shown once, when you create the endpoint under Developers → Overview → Webhooks. Each delivery carries X-CF-Timestamp and X-CF-Signature: sha256= followed by the hex HMAC-SHA256 of <X-CF-Timestamp>.<raw body>, keyed with your secret. Verify it over the raw body, before parsing, and compare in constant time; use X-CF-Delivery to ignore duplicates. The verification code, events and retry rules are in REST guide: Webhooks.
Checklist
- A publishable
_cfpkkey in the app, with derived_app/_testkeys switched off once every app has moved; write key, read key and secretpk_keys only on servers or CI. - A test-environment key while developing, and the live key only in production builds.
- Base URL from platform config,
https, ending in/sdk/v1. - If you pin: at least two pins, including a backup, for the host in your
baseUrl. - Analytics and channel consent set from real user choices, never defaulted to granted. See Identity and consent.
logout()on sign-out, and never one client shared between different people's server-side requests.
When it doesn't work
| What you see | Why, and the fix |
|---|---|
401 MISSING_SDK_KEY or 401 INVALID_SDK_KEY | No key in X-CF-Key, or a malformed, unknown or revoked one. Also returned for a derived _app/_test key when derived keys are disabled. Create a publishable key under Settings → API keys, or check a secret pk_ key there. |
The SDK stops syncing and sending after a 401 TENANT_KEY_MISMATCH | A publishable key was sent with a conflicting X-Tenant-Id (other key types log and ignore that header). A 403 appears only on the legacy path. The SDKs treat both as a permanent key rejection: fix the key or remove the header, and start a new client. |
| A configured base URL is ignored with a logged error | It is not https and not a local development host. The SDK used the default. |
CFInvalidBaseUrlError (Kotlin InvalidBaseUrl) | baseUrl does not point at a ContentFlow API. Remove it, or set https://api.contentflow.click/sdk/v1. |
| The JavaScript client throws at init about pinning or signing | enableCertificatePinning or enableRequestSigning is set. Remove them; pin in a native wrapper or a server proxy instead. |
Every call fails with pins set (Swift invalidConfiguration) | Fewer than two pins, a malformed pin, or pins for another host than your baseUrl. |
Next
- REST guide: Keys: the headers and error codes for every key.
- Testing your integration: check the key, logging and events end to end before you ship.