SDK API · v1

REST guide

The ContentFlow SDKs are thin clients over one HTTP API. Use this guide when you call it directly, from a platform without an SDK or from your own backend. Each endpoint's request and response contract lives in the API explorer, where you can also try it with a test key. This page covers what applies to every call.

Base URL

Code
https://api.contentflow.click/sdk/v1

All SDK routes below are relative to this base. From SDK 2.2.1 it is every SDK's default, and each SDK can take it from app configuration instead of code (see the SDK guide).

Existing apps may use https://app.contentflow.click/sdk/v1 or …/api/v1/sdk. Both still reach the same SDK gateway and keep working, but new integrations should use the base above.

Keys

The write key and read key are in the dashboard under Developers → Your keys; the publishable app key is under Settings → API keys. The Environment toggle switches between test and live. Secret pk_ API keys are under Settings → API Keys.

KeyHeaderWhere it goesOpens
Publishable key: <workspace-id>_cfpk<40 hex> (recommended)X-CF-Key (or X-API-Key)Your app. It is safe to ship. Create, rotate and revoke it under Settings → API keys; only a hash is stored, so the full key is shown once.Every /sdk/v1 route
Derived SDK key (legacy): <workspace-id>_app (live) or <workspace-id>_test (test)X-CF-Key (or X-API-Key)Existing apps only. Anyone who knows the workspace id can build it, so move apps to a publishable key. A workspace can switch it off (sdkKeyPolicy.derivedKeys), and the platform can refuse it everywhere (SDK_DERIVED_KEYS=reject); either way it answers 401 INVALID_SDK_KEY.Every /sdk/v1 route
API key: pk_live_… / pk_test_…X-CF-KeyYour server or CI only. A secret, created, rotated and revoked under Settings → API Keys. Never ship it in an app: apps use the publishable key above.Same as the SDK key
Write keyX-CF-Write-KeyYour server or CI onlyBlock and string registration
Read keyX-CF-Read-KeyYour server onlyThe read API
  • The key decides the workspace and environment. An X-Tenant-Id header is not needed and never overrides the key. With a publishable key, a header naming a different workspace is refused with 401 TENANT_KEY_MISMATCH; with the other key kinds it is logged and ignored.
  • A test-environment key receives live content plus content published to the test environment, so you can check a draft on a device before it goes live.
  • Every route under /sdk/v1 needs a key, including GET /consent/config/{preset}. A missing key returns 401 with code MISSING_SDK_KEY ("Missing API key"). A key in any other shape, an unknown or revoked pk_ key, or a key whose workspace is unknown or suspended, returns 401 with code INVALID_SDK_KEY. If the SDK gateway cannot validate keys at that moment it answers 503 (no code); treat it as transient and retry.
  • Only the live-updates stream accepts the key as a ?key= query parameter, because a browser EventSource cannot send headers. Every other route needs the header.
  • Never put a write key, read key or pk_ key in an app. The write key and read key are stored hashed: the full value is shown once, when it is created or rotated, and masked afterwards. If you lost it, rotate it. Rotate it from Developers → Your keys if it leaks too, and a pk_ key from Settings → API Keys (Rotate, or Revoke to disable it).

Which caller uses which key, base URL rules and certificate pinning: Security.

Device header

X-CF-Device carries a stable installation id that you generate once (a UUID) and store. Routes that act for a device (identify, consent, push, events, location) need it. Content reads do not.

Content-only mode

Fetching content is a read. GET /sync, GET /blocks/{key} and GET /strings answer with the key alone: no device id, no identify call, nothing stored about the person. Add identify only when you need targeting or analytics.

Terminal
curl https://api.contentflow.click/sdk/v1/sync \
  -H "X-CF-Key: $CF_SDK_KEY"

Responses and errors

Successful responses are JSON with {"success": true, "data": …}. Check both the HTTP status and success: an empty blocks array is a valid answer, and an error must not be read as empty content.

JSON
{ "success": false, "error": "Missing API key", "code": "MISSING_SDK_KEY" }

Errors carry success: false and a human-readable error string. Where a caller can act on it, a machine-readable code is added (for example MISSING_SDK_KEY or DEVICE_NOT_IDENTIFIED). Branch on code, not on the message text. Codes you may meet on every route include MISSING_SDK_KEY, INVALID_SDK_KEY (a malformed or revoked key, or an unknown or suspended workspace) and TENANT_KEY_MISMATCH (a publishable key with a conflicting X-Tenant-Id). The per-workspace rate-limit error has a different body shape; see below. Each endpoint's possible errors are listed in the API explorer.

A failure behind the SDK gateway, or of the SDK gateway itself, answers 502, 503 or 504 with code UPSTREAM_UNAVAILABLE, retryable: true and a Retry-After header. It is transient: keep showing cached content and retry after Retry-After seconds, with backoff.

JSON
{ "success": false, "error": "Service unavailable", "code": "UPSTREAM_UNAVAILABLE", "retryable": true }

Rate limits

Requests are limited per workspace, by default 1,000 per minute. Responses carry X-RateLimit-Limit and X-RateLimit-Remaining. Over the limit you get 429 with Retry-After: 60 and this body:

JSON
{ "success": false,
  "error": { "message": "SDK rate limit exceeded", "code": "SDK_RATE_LIMIT_EXCEEDED",
             "details": { "retryAfter": 60, "limit": 1000, "period": "minute" } } }

Back off for Retry-After seconds before retrying. Two further limits answer 429 in the standard flat envelope (success, error string, code) with a Retry-After computed in whole seconds, at least 1:

  • USER_RATE_LIMITED: per user, at most 3 new push devices per 24 hours on POST /register-push, and at most 10 consent changes per hour on POST /consent and POST /consent/full.
  • IP_RATE_LIMITED: 300 requests per minute per network address. It is monitor-only by default: it is counted and logged but does not refuse requests today.

The three limits do not share a body shape, so branch on code (from error.code for SDK_RATE_LIMIT_EXCEEDED, from the top-level code for the others), not on the shape.

The JavaScript SDK's own limiter. createCFClient and CFProvider take rateLimitPerSecond (default 10, 0 turns it off), and client.getRateLimiterStatus() returns { availableTokens, waitingCount }, or undefined when it is off. In the current release no request is routed through this limiter, so it neither delays nor drops calls, and waitingCount stays at 0. The server limit above is the one that applies; handle 429 as described. The Swift, Kotlin and Flutter SDKs have no client-side limiter; they honour Retry-After when they retry.

Caching and locale

  • ETag: /sync, /blocks/{key}, /strings and /locales each return their own ETag. Send it back as If-None-Match, and an unchanged response answers 304 with no body. Keep showing your cached content.
  • Locale: send ?locale=ar or Accept-Language. The server picks the best configured locale and may fall back to the workspace's source language when a translation is missing. GET /locales lists what the workspace offers.

Identify

POST /identify links the device to a signed-in user and records analytics consent and traits, which drive targeting. Traits, segments and the user link are only written while analytics consent is granted. Call it on app start and after sign-in; POST /logout unlinks the user again. Contract.

Code
export CF_SDK_KEY="$YOUR_TEST_KEY"   # a test-environment key, Settings → API keys
export CF_DEVICE="$(uuidgen)"               # generate once per installation and keep it
B=https://api.contentflow.click/sdk/v1

# consent is the user's recorded analytics 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\",\"userId\":\"test_user_1\",\"consent\":true,\"traits\":{\"plan\":\"gold\"}}"

An identify that applied the identity but could not re-link the device's push token answers 207 with success: false and code: "PUSH_LINKAGE_INCOMPLETE": keep the new identity and retry identify.

Sync content

GET /sync returns every block this device should see. It is authoritative: a block missing from it is no longer published. GET /blocks/{key} reads one block. Values are addressed by tag (#title); a collection field holds whole card blocks. Contract. Blocks and fields →

Strings

GET /strings returns approved translations for a namespace and locale, as {locale, strings, smartKeys, withheldKeys, version}. Contract.

Terminal
curl "https://api.contentflow.click/sdk/v1/strings?locale=ar" -H "X-CF-Key: $CF_SDK_KEY"

Live updates

GET /stream is a server-sent event stream. Each event (block.published, block.updated, …) is a signal to call /sync again. On reconnect, send the last event id as Last-Event-ID. Comment lines keep the connection alive. Contract.

Events

POST /events takes a batch of up to 100 events for one device. An event is counted only if the device was identified with consent: true first. Contract.

Terminal
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":{}}}

Always read accepted and dropReasons: a refused batch still answers 202 with success: true. device_not_identified means identify was not called, or failed, for this device id. consent_not_granted means the device was identified without consent: true. Sign-up and sign-in go to POST /track-event, not /events.

Campaigns

POST /campaigns returns the campaigns the server evaluates as eligible for a placement, highest priority first. Contract.

Terminal
curl -X POST $B/campaigns -H "X-CF-Key: $CF_SDK_KEY" -H "X-CF-Device: $CF_DEVICE" \
  -H 'Accept-Language: ar' -H 'Content-Type: application/json' \
  -d '{"placementId":"home_hero"}'

Erasure and PDPL

DELETE /identify erases everything the device id addresses: the device record, its linked user profile, KYC, location history, geofence events, enrichment and analytics events. Erasure also clears popup frequency counters and once-ever or stop state. Clear the device id on the client only after a 200; any other result means some data may remain, so retry with the same device id. A 207 (some parts erased, remaining lists the rest) is sent only to a client that declares erasure-partial-v1 in X-CF-SDK-Capabilities; any other client gets 502 for the same situation. While an erasure is pending, POST /identify for that device answers 409 ERASURE_PENDING: finish the erasure first. The device id is read only from the X-CF-Device header. Contract. Partial erasure and PDPL notes: Identity and consent.

Terminal
curl -X DELETE $B/identify -H "X-CF-Key: $CF_SDK_KEY" -H "X-CF-Device: $CF_DEVICE"

Test your integration

Run the calls above in order with a test-environment key, after publishing a block to Test: sync returns your block, identify with consent: true, and one event comes back with accepted equal to 1. The full check, reading dropReasons and common failures are in Testing your integration.

Server-side: write key

Register block definitions and source strings from your CI or backend. These calls use X-CF-Write-Key and must never run in an app.

  • POST https://api.contentflow.click/sdk/v1/cards/register and …/cards/sync: register or sync block definitions (contract). The same routes are also served at /api/v1/cards/….
  • POST https://api.contentflow.click/api/v1/strings/sync: register translation keys and source text. The SDKs' registerStrings calls this.
  • POST https://api.contentflow.click/api/v1/strings/disable: stop tracking translation keys (see below).

A request without a write key answers 401 with no code field, for example {"success":false,"error":"missing X-CF-Write-Key header"}. POST /cards/sync declares the complete set of block types: every active block type its manifest does not name is archived. For a single type use /cards/register. See Blocks and fields.

Terminal
curl -X POST https://api.contentflow.click/api/v1/strings/sync \
  -H "X-CF-Write-Key: $CF_WRITE_KEY" -H 'Content-Type: application/json' \
  -d '{"strings":[{"key":"home.welcome","sourceText":"Welcome back","namespace":"home","values":{"ar":"أهلاً بعودتك"}}]}'

Each row needs key, and source text for a new key (sourceText, or the source locale's entry in values); namespace, description and per-locale values are optional (see Strings file format). A rejected payload answers a 4xx with code: "STRINGS_SYNC_FAILED". If the localization service itself fails, the answer can still be 200 with success: false and the same code, so a CI step checks both the HTTP status and success. strings/sync never hides, shows or deletes a key; keys you stop sending stay in the catalog.

Stop tracking keys

POST /api/v1/strings/disable turns tracking off for the keys you name: their sensors go off, so no events are sent or accepted for them. The keys are still served to apps with their text, unchanged. Turning tracking back on is done in the dashboard, under Sensors → Enable sensors (which may need approval); the write key cannot enable tracking.

Terminal
curl -X POST https://api.contentflow.click/api/v1/strings/disable \
  -H "X-CF-Write-Key: $CF_WRITE_KEY" -H 'Content-Type: application/json' \
  -d '{"keys":["home.welcome","home.promo","home.old"]}'
# 200 {"success":true,"data":{"disabled":["home.welcome"],"alreadyDisabled":["home.promo"],"unknown":["home.old"]}}

keys takes 1 to 500 distinct keys, each a non-empty string of at most 200 characters (a key listed twice counts once); anything else answers 400 with code: "VALIDATION_ERROR". A missing or invalid write key answers 401. As with strings/sync, a failure inside the localization service can answer 200 with success: false (code: "STRINGS_DISABLE_FAILED"), so check both. In the answer, disabled lists keys whose tracking was on and is now off, alreadyDisabled keys that exist but were not being tracked, and unknown keys that are not in the workspace's catalog (nothing is changed for those). Calling it again is safe: keys already off come back in alreadyDisabled.

Strings file format

The same JSON file works for POST /api/v1/strings/sync and for Localization → Import strings in the dashboard. The dashboard also takes CSV. Start from a template:

Both templates are for a workspace with English (en) as the source language and Arabic (ar). In the dashboard, Localization → Import template builds the same files with a column for each of your workspace's languages.

FieldRules
keyRequired. At most 200 characters. A key that is already in the catalog is updated; a new one is created.
valuesText by locale code, one entry per language, for example {"en": "…", "ar": "…"}. These are the CSV's language columns. The entry for the workspace's source language is the source text, which a new key needs (otherwise it is rejected with missing_source_text); empty on an existing key keeps the stored text. Locales the workspace doesn't have are skipped and reported; an empty value is ignored and never erases a stored translation.
sourceTextOptional. The source text, if you'd rather send it on its own than as values' source-language entry. When both are sent, sourceText wins.
namespaceOptional, at most 100 characters. A new key without one goes to app.
descriptionOptional, at most 500 characters. Context for translators.

In CSV, the header row names the columns: key, namespace, description, a column named after the source locale (or sourceText) and one column per translation locale, such as key,namespace,description,en,ar. Fields may be separated by commas, or by semicolons or tabs as a spreadsheet app saves them under some regional settings. Use UTF-8 (a byte-order mark is fine and helps spreadsheet apps show Arabic) and quote any field that contains a comma, a double quote (written "") or a line break.

A row identical to what is stored counts as unchanged and is not written, so sending the same file twice is safe. When a row changes the source text of an existing key, that key's other translations are marked for review. strings/sync takes up to 2,000 rows per request. A dashboard import takes up to 20,000 rows and 5 MB, shows a preview, and is written only after it is approved: by a workspace admin, or by a user with approval rights other than the one who imported it.

Server-side: read key

Your backend can ask what ContentFlow knows about a user or device, with X-CF-Read-Key:

  • GET /api/v1/read/users/{userId}/devices: the devices behind one user id.
  • GET /api/v1/read/devices/{deviceId}: one device and its profile.
  • GET /api/v1/read/users/{userId}/segments: the segments that user id resolves to. It also accepts the device id of an anonymous, consented device.

Base: https://api.contentflow.click (https://app.contentflow.click serves the same routes for existing integrations).

Terminal
curl https://api.contentflow.click/api/v1/read/users/cust_1042/segments -H "X-CF-Read-Key: $CF_READ_KEY"
# 200 {"success":true,"data":{"userId":"cust_1042","segmentNames":["vip"]}}
# 404 no profile for that id (different from an empty list); 401 missing or invalid read key

The other two answer {userId, devices} and {device, profile} (profile is null when the device has none; 404 when the device does not exist). Push tokens are never returned.

Webhooks

A workspace can send signed HTTP callbacks to your server. Add an endpoint under Developers → Overview → Webhooks and pick its events; the signing secret (whsec_…) is shown once, when you create it.

These are the only events you can subscribe to today, and each one is delivered:

EventSent whendata adds
campaign.status_changedA campaign moves from one status to another, including every approval stepfrom, to, optional reason
campaign.submitted_for_reviewA campaign is submitted for approvalstatus
campaign.approvedA pending campaign is approvedstatus, optional note
campaign.rejectedA pending campaign is rejectedstatus, note
journey.status_changedA journey is activated, paused or returned to draftjourneyId, name, from, to

Campaign events also carry campaignId, name and type, and every event carries actor: the userId of the person who made the change, and name when known. A change no signed-in person made, such as the scheduler starting a scheduled campaign, can carry "actor": null or a service identity instead of a person, so do not assume one. There are no events yet for block publishing, report delivery or approvals outside campaigns; do not build a receiver around them.

Each delivery is a JSON POST:

JSON
{ "id": "evt_…", "type": "campaign.approved", "createdAt": "2026-09-30T09:00:00.000Z",
  "tenantId": "<workspaceId>",
  "workspaceKind": "live",
  "data": { "campaignId": "…", "name": "…", "type": "banner", "status": "approved",
            "actor": { "userId": "…" } } }

Every payload carries workspaceKind: "live" for a live workspace and "sandbox" for its linked sandbox. A sandbox delivers only to the webhook endpoints created in that sandbox, never to its live workspace's endpoints, so you can point a sandbox at a staging receiver and tell its events apart. The field was added later; ignore fields you do not know.

HeaderMeaning
X-CF-EventThe event name, the same as type
X-CF-Webhook-IdWhich of your endpoints this is
X-CF-DeliveryStable across retries of one delivery. Use it to ignore duplicates.
X-CF-Attempt1 for the first try, then 2, 3, 4
X-CF-TimestampUnix seconds when this attempt was signed
X-CF-Signaturesha256= followed by the hex HMAC-SHA256 of <X-CF-Timestamp>.<raw body>, keyed with your signing secret

Verify the signature over the raw request body, before parsing it, and compare in constant time:

Code
const expected = 'sha256=' + crypto.createHmac('sha256', secret)
  .update(`${req.headers['x-cf-timestamp']}.${rawBody}`).digest('hex')
const got = String(req.headers['x-cf-signature'] || '')
const ok = got.length === expected.length
  && crypto.timingSafeEqual(Buffer.from(got), Buffer.from(expected))

Answer with any 2xx within 5 seconds. A timeout, a network error, 408, 429 or 5xx is retried up to 4 attempts in all, after about 5 seconds, 30 seconds and 2 minutes. Any other 4xx is final. Delivery is at least once but not durable: a retry can be lost if our service restarts mid-backoff, so do not treat a webhook as your only record of a change. The dashboard shows each endpoint's last delivery result, and Test sends a signed ping.

Send a test delivery

Test in the dashboard calls the management API route below, which you can also call yourself with a portal sign-in token (admin or editor role) while you build your receiver. It sends one real, signed POST to the endpoint's stored URL, once, with no retries, and waits up to 5 seconds:

Terminal
curl -X POST https://api.contentflow.click/api/v1/settings/webhooks/$WEBHOOK_ID/test \
  -H "Authorization: Bearer $CF_PORTAL_TOKEN" -H "X-Tenant-Id: $CF_WORKSPACE_ID"
# {"success":true,"data":{"delivered":true,"status":200,"url":"…","sentAt":"…","latencyMs":143}}
  • The ping has type webhook.test, an id starting evt_test_, and data { webhookId, message }. It carries X-CF-Event: webhook.test, X-CF-Webhook-Id, X-CF-Delivery, X-CF-Timestamp and X-CF-Signature, signed exactly like a real delivery, so it tests your signature check. It has no X-CF-Attempt header and no tenantId in the body; like every payload it carries workspaceKind.
  • Your endpoint answering anything other than 2xx still gives success: true, with delivered: false, its status, and code: "WEBHOOK_BAD_STATUS".
  • When nothing could be delivered, the answer is success: false with code: "SETTINGS_WEBHOOK_TEST_FAILED", and the message says why: the URL resolves to a private or reserved address, no response within 5 seconds, the TLS handshake failed, or the endpoint could not be reached. Redirects are not followed, so a URL that redirects fails too.
  • The same URL rules apply as for real deliveries, so a test that passes means event delivery can reach that URL.

All routes

26 routes under /sdk/v1, grouped as in the API explorer. Each link opens its full contract.

RoutePurpose
Identity & lifecycle
DELETE /identifyErase this device's data (GDPR / PDPL)Contract
POST /identifyIdentify a device (and optionally its user)Contract
POST /logoutSign the device outContract
POST /profileUpdate the signed-in user's domain profileContract
Content
GET /blocks/{key}Get one blockContract
GET /content/{id}Open an uploaded media fileContract
GET /syncGet all blocks for this deviceContract
Localization
GET /localesList the workspace's localesContract
GET /stringsGet translated stringsContract
Campaigns
POST /campaignsGet campaigns for a placementContract
Events
POST /eventsSend a batch of analytics eventsContract
POST /track-eventRecord a sign-up or sign-inContract
Consent
GET /consent/config/{preset}Get a consent presetContract
POST /consentSet channel consentContract
POST /consent/fullSet channels, marketing, topics and preferences at onceContract
Push
DELETE /register-pushRemove this device's push tokenContract
GET /push/topicsGet subscribed push topicsContract
PATCH /push/preferencesUpdate push preferencesContract
POST /push/topicsSubscribe or unsubscribe push topicsContract
POST /register-pushRegister the device's push tokenContract
Location & geo
GET /geofencesGet the geofences this device should monitor (needs X-CF-Device; query lat, lng, max; supports ETag / 304)Contract
GET /geo/ipCoarse location from the caller's IPContract
POST /locationUpload device location fixes and geofence eventsContract
Live updates
GET /streamLive change notifications (Server-Sent Events)Contract
Server-side (write key)
POST /cards/registerRegister one block typeContract
POST /cards/syncDeclare your block types (cards as code)Contract