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
https://api.contentflow.click/sdk/v1All 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.
| Key | Header | Where it goes | Opens |
|---|---|---|---|
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-Key | Your 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 key | X-CF-Write-Key | Your server or CI only | Block and string registration |
| Read key | X-CF-Read-Key | Your server only | The read API |
- The key decides the workspace and environment. An
X-Tenant-Idheader is not needed and never overrides the key. With a publishable key, a header naming a different workspace is refused with401TENANT_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/v1needs a key, includingGET /consent/config/{preset}. A missing key returns401withcodeMISSING_SDK_KEY("Missing API key"). A key in any other shape, an unknown or revokedpk_key, or a key whose workspace is unknown or suspended, returns401withcodeINVALID_SDK_KEY. If the SDK gateway cannot validate keys at that moment it answers503(nocode); treat it as transient and retry. - Only the live-updates stream accepts the key as a
?key=query parameter, because a browserEventSourcecannot 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 apk_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.
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.
{ "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.
{ "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:
{ "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 onPOST /register-push, and at most 10 consent changes per hour onPOST /consentandPOST /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},/stringsand/localeseach return their ownETag. Send it back asIf-None-Match, and an unchanged response answers304with no body. Keep showing your cached content. - Locale: send
?locale=arorAccept-Language. The server picks the best configured locale and may fall back to the workspace's source language when a translation is missing.GET /localeslists 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.
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.
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.
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.
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"}'Consent
Analytics consent travels on POST /identify; channel consent is set with POST /consent or POST /consent/full (contract). What each one governs, presets and erasure are explained in Identity and consent.
To set consent for a signed-in user, identify the device as that user first, and send the same X-CF-Device. A request naming a user is refused unless the device is linked to that user:
| Status | code | Meaning |
|---|---|---|
| 400 | DEVICE_REQUIRED | A user is named but no device was sent. |
| 403 | DEVICE_NOT_IDENTIFIED | The device is not signed in as a user. Call identify first. |
| 403 | USER_MISMATCH | The device is signed in as a different user. |
curl -X POST $B/consent -H "X-CF-Key: $CF_SDK_KEY" -H "X-CF-Device: $CF_DEVICE" \
-H 'Content-Type: application/json' \
-d "{\"deviceId\":\"$CF_DEVICE\",\"channels\":{\"push\":true}}"Channel flags must be nested under channels; flags at the top level of the body are refused with 400 CHANNELS_REQUIRED.
Device-only consent (no user named) needs just the device header. GET /consent/config/{preset} returns a ready-made consent screen for a regulated industry, such as banking_ksa. It needs the SDK key like every other route, but no device header and no identify call, so you can show the screen before the person has agreed to anything.
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.
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/registerand…/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'registerStringscalls 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.
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.
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:
localization-import-template.json: the{"strings": [...]}body. Send it tostrings/syncas it is, or import it.localization-import-template.csv: for the dashboard import and spreadsheet editing.
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.
| Field | Rules |
|---|---|
key | Required. At most 200 characters. A key that is already in the catalog is updated; a new one is created. |
values | Text 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. |
sourceText | Optional. The source text, if you'd rather send it on its own than as values' source-language entry. When both are sent, sourceText wins. |
namespace | Optional, at most 100 characters. A new key without one goes to app. |
description | Optional, 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).
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 keyThe 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:
| Event | Sent when | data adds |
|---|---|---|
campaign.status_changed | A campaign moves from one status to another, including every approval step | from, to, optional reason |
campaign.submitted_for_review | A campaign is submitted for approval | status |
campaign.approved | A pending campaign is approved | status, optional note |
campaign.rejected | A pending campaign is rejected | status, note |
journey.status_changed | A journey is activated, paused or returned to draft | journeyId, 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:
{ "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.
| Header | Meaning |
|---|---|
X-CF-Event | The event name, the same as type |
X-CF-Webhook-Id | Which of your endpoints this is |
X-CF-Delivery | Stable across retries of one delivery. Use it to ignore duplicates. |
X-CF-Attempt | 1 for the first try, then 2, 3, 4 |
X-CF-Timestamp | Unix seconds when this attempt was signed |
X-CF-Signature | sha256= 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:
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:
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
typewebhook.test, an id startingevt_test_, anddata{ webhookId, message }. It carriesX-CF-Event: webhook.test,X-CF-Webhook-Id,X-CF-Delivery,X-CF-TimestampandX-CF-Signature, signed exactly like a real delivery, so it tests your signature check. It has noX-CF-Attemptheader and notenantIdin the body; like every payload it carriesworkspaceKind. - Your endpoint answering anything other than
2xxstill givessuccess: true, withdelivered: false, itsstatus, andcode: "WEBHOOK_BAD_STATUS". - When nothing could be delivered, the answer is
success: falsewithcode: "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.
| Route | Purpose | |
|---|---|---|
| Identity & lifecycle | ||
DELETE /identify | Erase this device's data (GDPR / PDPL) | Contract |
POST /identify | Identify a device (and optionally its user) | Contract |
POST /logout | Sign the device out | Contract |
POST /profile | Update the signed-in user's domain profile | Contract |
| Content | ||
GET /blocks/{key} | Get one block | Contract |
GET /content/{id} | Open an uploaded media file | Contract |
GET /sync | Get all blocks for this device | Contract |
| Localization | ||
GET /locales | List the workspace's locales | Contract |
GET /strings | Get translated strings | Contract |
| Campaigns | ||
POST /campaigns | Get campaigns for a placement | Contract |
| Events | ||
POST /events | Send a batch of analytics events | Contract |
POST /track-event | Record a sign-up or sign-in | Contract |
| Consent | ||
GET /consent/config/{preset} | Get a consent preset | Contract |
POST /consent | Set channel consent | Contract |
POST /consent/full | Set channels, marketing, topics and preferences at once | Contract |
| Push | ||
DELETE /register-push | Remove this device's push token | Contract |
GET /push/topics | Get subscribed push topics | Contract |
PATCH /push/preferences | Update push preferences | Contract |
POST /push/topics | Subscribe or unsubscribe push topics | Contract |
POST /register-push | Register the device's push token | Contract |
| Location & geo | ||
GET /geofences | Get the geofences this device should monitor (needs X-CF-Device; query lat, lng, max; supports ETag / 304) | Contract |
GET /geo/ip | Coarse location from the caller's IP | Contract |
POST /location | Upload device location fixes and geofence events | Contract |
| Live updates | ||
GET /stream | Live change notifications (Server-Sent Events) | Contract |
| Server-side (write key) | ||
POST /cards/register | Register one block type | Contract |
POST /cards/sync | Declare your block types (cards as code) | Contract |