Home / Guides / Testing your integration
Product guide

Testing your integration

Work through these steps with a debug build before you ship. Each one tells you something different, so do them in order: the key and its environment, something published, the key on its own, SDK logging, then one device end to end.

In short
  • Develop with your workspace's test key (the publishable <workspace-id>_cfpk… key from the test environment): it sees content published to Test as well as Live, and its traffic never touches live analytics or block counters.
  • Publish what you are testing to Test, then check it with curl https://api.contentflow.click/sdk/v1/sync -H "X-CF-Key: …" before you open the app.
  • Turn on SDK logging, grant analytics consent in the test build, track one event and confirm it was accepted.
  • An event batch answers 202 even when events are dropped, so always read accepted and dropReasons.
Environments

Test and live keys

In Developers → Your keys, the Environment toggle switches between the workspace's test and live keys. The key to ship is the publishable key <workspace-id>_cfpk<40 hex> from Settings → API keys (create, rotate, revoke). <workspace-id>_app and _test are older workspace-derived keys, still accepted by default; an admin can disable them, after which they answer 401 INVALID_SDK_KEY. Secret keys (pk_live_ / pk_test_) are for servers only. The key decides the environment; there is no client-side flag that turns a live key into a test key.

Sandbox workspace. If your workspace has a linked sandbox (Settings → Sandbox, beta), its test keys send their data to the sandbox, not to the live workspace: the derived <workspace-id>_test key, test publishable keys (cfpk_test), and test secret keys (pk_test_) all land there, and a test publishable key is created in the sandbox itself. The sandbox still shows your live blocks and strings unless it overrides them. Without a sandbox, test keys behave as before. Live keys (_app, live publishable and pk_live_) are unchanged and never write to the sandbox. A sandbox workspace can only create and list test keys; asking for a live key there answers 403 SANDBOX_LIVE_KEY_FORBIDDEN. In Test mode, the Developers page shows the sandbox's test key. The sandbox's test key is managed from inside the sandbox: switch to the sandbox workspace, then create, rotate or revoke it under Settings → API keys there (the live workspace's key list never shows it). If ContentFlow briefly cannot confirm where a test key's traffic goes (for example during an outage of its workspace service), writes with that key (identify, events, track-event, consent, register-push, location and the other write calls) answer 503 SANDBOX_ROUTE_UNAVAILABLE with a Retry-After header instead of being stored in the live workspace, and the SDK retries them; reads (sync, blocks, strings) and the in-app campaign fetch (POST /campaigns, a read) keep working, answered from the workspace the key belongs to. Live keys are never affected.

  • A test key sees content published to Test as well as Live; a live key sees Live only. So you can check a draft on a device before customers see it.
  • Test traffic is stamped env: test, excluded from live analytics, and never updates the block impression and tap counters. Test mode in the dashboard shows it, with a checklist, the test devices and a button to delete test data.
  • A device belongs to the environment it was first identified in. Calling /identify, /consent, /register-push or logout for that device with the other environment's key returns 409 DEVICE_ENVIRONMENT_MISMATCH. Use a separate install, or a fresh device id, for test builds.
  • The Swift and Kotlin SDKs report client.environment from the key text: Test for _test and pk_test_ keys, Live for _app and pk_live_. A publishable _cfpk key reports Unknown from SDK 2.4.0 (.unknown in Swift), because its environment is stored on the server and not in the key; earlier versions wrongly reported it as live.
  • Put the live key only in production builds.
Environments

Sandboxes replace the in-workspace test environment

ContentFlow is moving to one test model: each live workspace gets a linked sandbox, and test keys are served from it. When your workspace's test content has moved and the platform switches the in-workspace test environment off, this is how things work:

  • Test mode is the sandbox. Turning on Test mode in the dashboard switches you to the workspace's sandbox. If the workspace has none yet, the dashboard asks you to create one under Settings → Workspace → Sandbox (admins). There is no browser-only Test mode any more.
  • No Publish to Test in a live workspace. To try a change on test devices, make it in the sandbox. Test devices see your live content plus the sandbox's changes. In the sandbox, content published to Test counts as published for its devices. Asking a live workspace to publish to Test answers 409 TEST_ENV_RETIRED.
  • Test keys of a workspace with a sandbox work exactly as described above: their devices, events and consent live in the sandbox, never in the live workspace.
  • Test keys of a workspace without a sandbox can still read the live workspace's content (/sync, blocks, strings), read-only. Every write (/identify, /events, /consent, push registration and so on) answers 409 SANDBOX_REQUIRED. Create a sandbox and the same key starts writing there, with no app change.
  • Live keys are unchanged.

Test content published before the switch (instances and campaigns published to Test) is copied into the sandbox, where it keeps working for test devices. Old test devices, profiles and events are deleted after a grace period; test devices recreate themselves in the sandbox the next time they identify.

Step 1

Publish something to Test

In the dashboard, open a block instance and publish it to Test. Until something is published for the key's environment, GET /sync correctly answers blocks: []. That is a valid answer, not an error.

Step 2

Check the key without an app

# your test publishable key from Settings → API keys
export CF_SDK_KEY="$YOUR_TEST_PUBLISHABLE_KEY"
curl -s https://api.contentflow.click/sdk/v1/sync -H "X-CF-Key: $CF_SDK_KEY"
# {"success":true,"data":{"blocks":[{"key":"home_banner","instanceId":"…","values":{"#title":"…"},"fields":[…],…}]}}
You getIt means
401 MISSING_SDK_KEY ("Missing API key")The X-CF-Key header was not sent.
401 INVALID_SDK_KEYThe value has the wrong shape for a ContentFlow key, names an unknown or suspended workspace, or is an older workspace-derived key (_app / _test) that an admin has disabled.
200 with blocks: []Nothing is published for this environment, the instance targets a segment this device is not in, or the workspace id in the key is mistyped.
A reply without successThe base URL is not the ContentFlow API. Use https://api.contentflow.click/sdk/v1.
200 with your blockContent delivery works; move on to the SDK.
Step 3

Turn on SDK logging

Switch logging on in debug builds only.

import { createCFClient } from '@contentflow/sdk/web';

// 1. Debug builds only: verbose logging.
const client = createCFClient({ publicKey: CONTENTFLOW_SDK_KEY, debug: true });
import { CFProvider } from '@contentflow/sdk/react';

// 1. Debug builds only: verbose logging.
export function App({ children }: { children: React.ReactNode }) {
  return (
    <CFProvider config={{ publicKey: CONTENTFLOW_SDK_KEY, debug: true }}>
      {children}
    </CFProvider>
  );
}
import { CFProvider } from '@contentflow/sdk/react-native';

// 1. Debug builds only: verbose logging.
export function App({ children }: { children: React.ReactNode }) {
  return (
    <CFProvider config={{ publicKey: CONTENTFLOW_SDK_KEY, debug: true }}>
      {children}
    </CFProvider>
  );
}
// 1. Debug builds only: verbose diagnostics.
let client = ContentFlow.start(configuration: ContentFlowConfiguration(
    apiKey: AppConfig.contentFlowKey, tenantId: AppConfig.contentFlowWorkspace, diagnostics: .verbose))
// 1. Debug builds only: set the level through the configuration (start() applies it).
ContentFlow.start(this, ContentFlowConfiguration(
    apiKey = BuildConfig.CONTENTFLOW_KEY, tenantId = BuildConfig.CONTENTFLOW_WORKSPACE, diagnostics = DiagnosticsLevel.Verbose))
// 1. Debug builds only: logs every request and response,
//    and every event dropped for missing consent.
await ContentFlow.create(const ContentFlowConfig(publicKey: String.fromEnvironment('CONTENTFLOW_KEY'), debug: true));
Step 4

The end-to-end check

One device, in order: content arrives, the device is identified with analytics consent, and an event is accepted. Grant consent in code only in a test build; in the app it comes from the person's choice.

import { createCFClient } from '@contentflow/sdk/web';

const client = createCFClient({ publicKey: CONTENTFLOW_SDK_KEY, debug: true });
client.subscribeErrors((e) => console.error('ContentFlow error', e));
client.subscribeEventDrops((d) => console.warn('events dropped', d.source, d.dropReasons));

// 1. Content: start() identifies the device and syncs.
await client.start();
console.log('live updates:', client.getLiveStatus().mode);   // 'stream', 'poll' or 'off'
const banner = client.getBlock('home_banner');
console.log('block:', banner?.instanceId ?? 'not delivered to this key/device');

// 2. Analytics consent (test build only).
await client.setConsent(true);

// 3. One event; a refused event is reported by subscribeEventDrops.
if (banner) client.trackImpression(banner);
await client.flush();
import { useEffect } from 'react';
import { useCF, useCFBlock, useLiveStatus } from '@contentflow/sdk/react';

function SmokeTest() {
  const cf = useCF();
  const live = useLiveStatus();
  // 1. Content: <CFProvider> calls start(), which identifies the device and syncs.
  //    useCFBlock re-renders when content arrives and fires one impression when it is shown.
  const banner = useCFBlock('home_banner');

  useEffect(() => {
    const offErrors = cf.subscribeErrors((e) => console.error('ContentFlow error', e));
    const offDrops = cf.subscribeEventDrops((d) => console.warn('events dropped', d.source, d.dropReasons));
    // 2. Analytics consent (test build only).
    void cf.setConsent(true);
    return () => { offErrors(); offDrops(); };
  }, [cf]);

  // 3. One event (the impression above); a refused event is reported by subscribeEventDrops.
  useEffect(() => { if (banner) void cf.flush(); }, [cf, banner?.instanceId]);

  console.log('live updates:', live.mode);   // 'stream', 'poll' or 'off'
  console.log('block:', banner?.instanceId ?? 'not delivered to this key/device');
  return null;
}

// Render <SmokeTest /> inside <CFProvider config={{ publicKey: CONTENTFLOW_SDK_KEY, debug: true }}>.
import { useEffect } from 'react';
import { useCF, useCFBlock, useLiveStatus } from '@contentflow/sdk/react-native';

function SmokeTest() {
  const cf = useCF();
  const live = useLiveStatus();
  // 1. Content: <CFProvider> calls start(), which identifies the device and syncs.
  //    useCFBlock re-renders when content arrives and fires one impression when it is shown.
  const banner = useCFBlock('home_banner');

  useEffect(() => {
    const offErrors = cf.subscribeErrors((e) => console.error('ContentFlow error', e));
    const offDrops = cf.subscribeEventDrops((d) => console.warn('events dropped', d.source, d.dropReasons));
    // 2. Analytics consent (test build only).
    void cf.setConsent(true);
    return () => { offErrors(); offDrops(); };
  }, [cf]);

  // 3. One event (the impression above); a refused event is reported by subscribeEventDrops.
  useEffect(() => { if (banner) void cf.flush(); }, [cf, banner?.instanceId]);

  console.log('live updates:', live.mode);   // 'stream', 'poll' or 'off'
  console.log('block:', banner?.instanceId ?? 'not delivered to this key/device');
  return null;
}

// Render <SmokeTest /> inside <CFProvider config={{ publicKey: CONTENTFLOW_SDK_KEY, debug: true }} eventSource={EventSource}>
// (EventSource from react-native-sse, so getLiveStatus can report 'stream').
#if DEBUG
ContentFlowDiagnostics.setHandler { d in print("[CF][\(d.category)] \(d.message)") }
let client = ContentFlow.start(configuration: ContentFlowConfiguration(
    apiKey: AppConfig.contentFlowKey, tenantId: AppConfig.contentFlowWorkspace, diagnostics: .verbose))

Task {
    // 1. Content.
    let result = try await client.sync()
    print("blocks:", result.blocks.map(\.key))

    // 2. Analytics consent (test build only): fresh installs are analytics-denied.
    try await client.setAnalyticsConsent(.granted)

    // 3. One event, then read the server's receipt.
    if let block = result.blocks.first { _ = try await client.trackEngagement(.impression(block)) }
    if case .acknowledged(let receipt) = await client.flushEvents() {
        print("accepted:", receipt.accepted, "dropped:", receipt.dropped, receipt.dropReasons)
    }
}
#endif
lifecycleScope.launch {
    val client = ContentFlow.client
    Log.d("CF", "env=${client.environment}")                          // Test for a _test key; Unknown for a _cfpk key

    // 1. Content.
    val sync = client.sync()
    Log.d("CF", "blocks=${sync.blocks.map { it.key }}")
    Log.d("CF", "block=${client.fetchBlock("home_banner")}")          // null = not published to this key/env

    // 2. Analytics consent (test build only).
    client.setAnalyticsConsent(ConsentStatus.Granted)

    // 3. One event: Accepted, Queued, DroppedNoConsent or Rejected(reason).
    Log.d("CF", "event=${client.trackEngagement(type = "impression", blockKey = "home_banner")}")
}
await ContentFlow.create(const ContentFlowConfig(publicKey: String.fromEnvironment('CONTENTFLOW_KEY'), debug: true));
final cf = ContentFlow.instance;

// 1. Content.
final blocks = await cf.sync();
debugPrint('blocks: ${blocks.map((b) => b.key).toList()}');

// 2. Analytics consent (test build only).
await cf.setConsent(true);

// 3. One event; watch the POST /events response in the debug log.
await cf.trackImpression(blockKey: 'home_banner');
await cf.flush();
export CF_DEVICE="$(uuidgen)"   # one stable id per test installation
B=https://api.contentflow.click/sdk/v1

# 1. Content.
curl -s $B/sync -H "X-CF-Key: $CF_SDK_KEY"

# 2. Identify with analytics consent (test only; in an app this is the person's choice).
curl -X POST $B/identify -H "X-CF-Key: $CF_SDK_KEY" -H "X-CF-Device: $CF_DEVICE" \
  -H 'Content-Type: application/json' -d "{\"deviceId\":\"$CF_DEVICE\",\"consent\":true}"

# 3. One event: expect accepted 1 and empty dropReasons.
curl -X POST $B/events -H "X-CF-Key: $CF_SDK_KEY" -H "X-CF-Device: $CF_DEVICE" \
  -H 'Content-Type: application/json' \
  -d '{"events":[{"type":"impression","blockKey":"home_banner","screen":"home"}],"context":{"sessionId":"s1","platform":"web"}}'
# 202 {"success":true,"data":{"accepted":1,"dropped":0,"dropReasons":{}}}

Live updates: leave the app open and publish a change to the same block; listeners and the React hooks re-render without a reload. Over REST, curl -N "$B/stream" -H "X-CF-Key: $CF_SDK_KEY" prints : connected, and a named event such as block.updated arrives when you publish.

Block counters in the dashboard do not move for a test key. Use the event acknowledgement, not the counters, to confirm delivery.

Step 5

Reading dropReasons

A refused event batch still answers 202 with success: true. The body says what happened: {"accepted":1,"dropped":0,"dropReasons":{}}.

Drop reasonMeansFix
consent_not_grantedNo analytics consent for the device. In JavaScript, source: 'local' means the SDK dropped it before sending.Set analytics consent from your consent UI. See Events without consent.
device_not_identifiedIdentify was not called, or failed, for this device id, or its 24 h gateway entry expired.The SDKs identify on start; check the error log for the identify failure. Over REST, call POST /identify first.
invalid_string_signal, sensor_not_enabledA string-sensor signal failed validation, or named a key that is not an enabled sensor.See Sensors.

Each SDK surfaces the same information: JavaScript through subscribeEventDrops (source is 'local' or 'server'), Swift through the receipt from flushEvents(), Kotlin through the TrackingDisposition each tracking call returns, and Flutter in the debug log. Every outcome, including whole-batch 400 refusals, is in Analytics: accepted, dropped, rejected.

Dashboard

Watch it in Test mode

Developers → Test mode shows what your test build does, as it happens. It reads only test-environment traffic (devices and events from the test key), so nothing a live app sends appears here.

  • Wiring checklist. Each row turns green when the real call arrives from a test build: SDK connected, Device identified, Consent granted, Impression tracked, Tap tracked, CTA click tracked, Dismiss tracked, Conversion tracked, Sign-up / sign-in event and Push token registered. A row marked unknown means the page couldn't load the data, not that nothing arrived.
  • Live event inspector. Your test events, newest first, refreshed every 4 seconds.
  • Your test key. The test SDK key to copy, and Content for this key: how many blocks the gateway would hand a build that uses it. No blocks published means step 1 is still to do.
  • No test build handy? A curl sample that simulates a test device from your terminal; the checklist and inspector react to it the same way.
  • Test devices. The 50 most recently seen devices on the test key. Each shows its device id, then the user id (or anonymous), its platform, consented or no consent, push ok when it has a push token, when it was last seen, and its segments. Find your own device by the user id you identified it with.
  • Reset test data. Deletes every test device, test event and test profile in the workspace, so the checklist starts again. Live data is not touched. Only admins can reset.
  • Why didn't it show? A block that doesn't appear may be held back by its frequency rule. Enter a user id or device id here to see each block instance with a rule and whether it can show for that person; Reset popup frequency clears a test user's or test device's counts so a "once ever" popup can be tested again. See Popup frequency: why didn't it show?

The same data is on the management API (portal sign-in token and X-Tenant-Id): GET /api/v1/blocks/devices/test for the devices, GET /api/v1/analytics/test-summary and GET /api/v1/analytics/test-events for the checklist and inspector, and DELETE /api/v1/test-env (admin) for the reset.

No code

Testing in the API explorer

The API explorer sends real requests with your test key. Press Authorize and paste the publishable test key; use a made-up device id such as explorer-test-1, never a real customer's. A live key and DELETE /identify both ask you to confirm before sending, and write-key routes only show the curl to run from your server.

Its test flow runs one device end to end in seven calls, in order: identify with consent: true, sync (then again with the ETag for a 304), strings, events (expect accepted equal to events sent and empty dropReasons), channel consent, push registration, and erasure to clean up. Worth running as negative checks:

  • Events before identify, with a fresh device id: 202 with dropReasons.device_not_identified.
  • Identify with consent: false, then events: consent_not_granted.
  • Any call without a key: 401 MISSING_SDK_KEY.
Keys

A rejected key

From 2.3.0 every SDK treats a rejected key (any 401, including TENANT_KEY_MISMATCH: a publishable key with a conflicting X-Tenant-Id; only the legacy path answers it with 403) as permanent for that key: it stops syncing and live updates, drops what is queued, reports one invalid-key error, and sends nothing more until you create a new client with a corrected key or restart the app. The error type per SDK and the retry rules are in the SDK guide: A rejected key.

Troubleshooting

When it doesn't work

What you seeWhy, and the fix
Every event is dropped with source: 'local' and consent_not_grantedAnalytics consent was never granted. Call setConsent(true) (Swift and Kotlin: setAnalyticsConsent) from your consent UI.
Server drops with device_not_identifiedThe device was not identified (identify failed or was never sent). Check the error log for the identify failure.
CFInvalidBaseUrlError (Kotlin InvalidBaseUrl)baseUrl does not point at a ContentFlow API. Remove it, or set https://api.contentflow.click/sdk/v1.
A one-time "legacy path" noticeYour baseUrl does not end in /sdk/v1. Change it to https://api.contentflow.click/sdk/v1.
Channel consent refused with DEVICE_NOT_IDENTIFIED or USER_MISMATCH; or 409 DEVICE_ENVIRONMENT_MISMATCHIdentify as that user first (setUserId / identify(userId:)). A 409 means the device id was first used with the other environment's key: use a fresh device id for test builds.
The SDK stops syncing, live updates stop and events stop arrivingThe server rejected the key (401, including TENANT_KEY_MISMATCH). Fix the key and start a new client. See A rejected key.

Next

  • Security: which key goes where before you ship, base URL rules, and certificate pinning.
  • Identity and consent: replace the test-only consent grant with the person's real choices.