Home / REST API reference
REST API · v1

ContentFlow REST API reference

The HTTP contract for the three surfaces ContentFlow exposes: the delivery API your app calls with an SDK key, the write API a server or CI job calls with a write key, and the read API that hands back what a device actually resolved to. Every endpoint below carries its method, its required headers, its request and response shapes, and its error codes, so a native iOS, Android, Flutter or server-side client can be written from what is on this page.

Eighteen delivery routes under /sdk/*, ten of which have a full request and response contract on this page, three read endpoints under /read/*, and three write endpoints on their own credential. Every credential class is in Authentication, and the routes this page enumerates, each with the credential that opens it, are in the route map.

i What "enumerated here" covers, and what it does notThe route map was checked against the live gateway, most recently on 2026-08-16. It covers the delivery, write and read surfaces, plus the management routes named there. It is not a claim that no other route exists anywhere on the platform. If you find a route that answers and is not on this page, it is missing from the page rather than forbidden to you, and the counts are the checkable part: eighteen under /sdk/*, three under /read/*.
! That invitation is only actionable on one of the three surfacesTelling a real route from a nonexistent one, from outside, works on /api/v1/read/* and nowhere else. Under that prefix a real route answers 401 and an invented path answers a 404 that names the path, so the two are distinguishable. Under /api/v1/sdk/* and on the portal surface they are not: an invented path and a documented one return byte-identical answers, TENANT_NOT_FOUND when you send a synthetic key and MISSING_SDK_KEY when you send none, because the credential is checked before the path is matched. So a report of "this route answers and is undocumented" is something only the read surface can actually supply. This is a limitation of the check, not a suggestion to go hunting: on the other two surfaces, tell us what you were trying to do and we will answer from the source rather than from a status code.
! The retired host still answers, and that is exactly the problemThe only supported base URL is https://app.contentflow.click/api/v1. The old https://api.contentflow.click/v1 host was never switched off. It still returns 200 with real-looking block data to any client holding a legacy key, in the old unenveloped format, and that content is frozen: nothing you publish in the dashboard will ever reach it. This is the most expensive misconfiguration available to an integrator precisely because nothing fails, nothing logs an error, and the app looks like it is working. See how to tell which host you are on.
i You do not have to identify anyone to fetch contentContent delivery is a read. GET /sdk/sync, GET /sdk/blocks/:key and GET /sdk/strings all answer 200 with your workspace's live content when the only header you send is X-CF-Key. No device id, no identify call, no traits, no profile, nothing stored. If your app is privacy-sensitive and you would rather not collect an identifier at all, read Anonymous, content-only mode first, before you write a line of client code.

Base URL

Every endpoint on this page is relative to a single base:

base url
https://app.contentflow.click/api/v1
HostStatusBehaviour
app.contentflow.click/api/v1SupportedThe delivery gateway. Use this.
api.contentflow.click/v1Retired, still runningNot decommissioned. Serves frozen content to legacy keys, with its own separate key store. See below.

All request and response bodies are JSON. Send Content-Type: application/json on every request that has a body.

Telling the two apart

The retired host is not gone, so pointing at it does not produce a clean failure. What it does instead depends on which key you send, and only one of the three outcomes looks like an error:

Request to the retired hostWhat comes back
The bare root, /v1404. This is why a root-only check is worthless: it 404s on the retired host too, so the test passes while your app is pointed at the wrong place.
A real path with a current-format key401 {"error": "invalid SDK key"}. The old host keeps its own legacy key store and does not recognise current keys.
A real path with a legacy key, the pk_live_… generation200 with real block data, in the old unenveloped format. No error of any kind. The content is stale and frozen.
! Do not test your base URL by checking for a 404Test the shape of the response body instead, because that differs on every path and every key. The supported host always wraps its payload in the standard envelope. The retired host returns a bare, unwrapped body.
which host am i on
// app.contentflow.click/api/v1  →  correct: enveloped
{ "success": true, "data": { "blocks": [  ], "version": "W/\"sync-…\"" } }

// api.contentflow.click/v1      →  RETIRED: bare, no success, no data, no version
{ "blocks": [  ] }

In code, the check is one line at the top of your response parser: if the body has no success member, you are talking to the retired host. Fail loudly there, in your own client, rather than shipping a build that quietly renders frozen content.

! The symptom to watch for in the fieldContent that looks stale or frozen, or a dashboard change that never appears no matter how many times you publish or how long you wait, is the signature of an app still pointed at the retired host. Check the base URL before you debug your cache, your key, your segments, or your publish workflow. Every one of those will look innocent, because every one of them is.

Authentication

There are four credential classes on the platform, and any one call takes exactly one of them. Every /sdk/* delivery call carries up to three headers, and only one of those is actually required. There is no OAuth flow, no token exchange, and no session to keep alive.

HeaderRequiredMeaning
X-CF-KeyYes, on /sdk/*Your publishable SDK key. It binds the request to exactly one workspace, and it is the only credential the delivery API needs.
X-Tenant-IdNo on /sdk/*. Yes on portal JWT callsThe workspace id. The key already names the workspace, so this is redundant on /sdk/*. Send it and it must agree with the key, or the call is rejected with 403 TENANT_KEY_MISMATCH. Omitting it on /sdk/* is fine and is one fewer thing to get wrong. On a portal call authenticated by JWT it is required.
X-CF-DeviceNoA caller-generated stable device identifier. Required only for the calls that are about a device: identify, register-push, events, track-event, consent. The three delivery reads work without it. See anonymous mode and persisting the device id.
X-CF-Write-KeyYes, on the write surfaceShaped wk_live_ followed by hex. Scoped to the write surface: POST /api/v1/cards/sync, POST /api/v1/cards/register and POST /api/v1/strings/sync. Issued to the admin and editor roles only. A server-side credential: it must never ship inside an app binary. See the route map.
X-CF-Read-KeyYes, on /read/*Shaped rk_live_ followed by 24 lowercase hex characters. Scoped to the three read routes, which are the only routes registered under that prefix. It is read-scoped: it cannot write anything, and a write-key-shaped value sent to a read route comes back 401 Invalid read key, which you can check yourself. The workspace is resolved from the key itself, so a read call sends no portal JWT and no X-Tenant-Id. Server-side and CI only.

The dashboard's management API is the fifth thing you may meet and it is not an SDK credential at all: it takes Authorization: Bearer <portal JWT> together with X-Tenant-Id, and role gates on top. Where that token comes from, how long it lasts, and why there is no service account for it are in where a portal JWT comes from. The response-shape differences are in Portal API differences.

Find the SDK key and the workspace id in Dashboard, Developers, "Your keys". The read key and the write key are rows in that same panel, labelled "Read key" and "Write key", and both are visible to the admin and editor roles only.

i Why the examples on this page still send all threeMost integrations are personalized, so the /sdk/* examples show the full set. That is a convention, not a requirement. If you are building a content-only client, drop X-Tenant-Id and X-CF-Device from every read and nothing breaks. See Anonymous, content-only mode.

X-API-Key and ?key=, the two aliases nobody documented

Both are named in live error bodies, both are supported today, and neither is deprecated. They are narrow, and the narrowness is the point.

AliasStatusExact scope
X-API-Key request headerSupported today. Not deprecated.An accepted alias for X-CF-Key on /sdk/*. Send either header, the effect is identical. On portal JWT routes it is not an authentication credential: the old fallback there was removed because it was an auth bypass, so an X-API-Key sent to a portal route authenticates nothing.
?key= query parameterSupported today. Not deprecated.Accepted on exactly one route, GET /sdk/stream, because an EventSource cannot set request headers. On every other /sdk/* route it is refused with 401 and the code SDK_KEY_IN_QUERY.
401 SDK_KEY_IN_QUERY
{
  "code": "SDK_KEY_IN_QUERY",
  "message": "A ?key= query parameter is only accepted on GET /sdk/stream (SSE). Send the SDK key as the X-CF-Key or X-API-Key header."
}
! A key in a URL does not stay in the URLQuery strings land in web-server access logs, in proxy and CDN logs, and in the Referer header of anything the page loads next. That is the whole reason ?key= is confined to GET /sdk/stream, where the browser API leaves you no header to use. Everywhere else, send the header. If you do use ?key= on the stream, treat that key as one you are willing to rotate.

When more than one key is present, the first one wins

The gateway looks for the SDK key in three places, in this fixed order, and stops at the first one that carries a value:

  1. The X-CF-Key request header.
  2. Otherwise the X-API-Key request header.
  3. Otherwise the ?key= query parameter, which is only accepted on GET /sdk/stream.

The precedence is positional, not quality-based. Nothing compares the candidates, and nothing warns you when they disagree. Send both headers naming different workspaces and X-CF-Key wins silently: the X-API-Key value is never looked at, and no part of the response mentions that a second key was present. Send a malformed X-CF-Key alongside a well-formed X-API-Key and the request fails on the malformed one with 401 INVALID_SDK_KEY. There is no fallback to the second header, because the second header was never a candidate once the first had a value.

The same rule runs downward: when either header carries a value, a stray ?key= in the URL is silently ignored rather than refused.

two keys, one request
// both headers, two different invented workspaces: X-CF-Key wins, X-API-Key is ignored
$ curl -i https://app.contentflow.click/api/v1/sdk/sync \
     -H "X-CF-Key: ws_alpha9_app" \
     -H "X-API-Key: ws_beta7_app"

HTTP/1.1 404 Not Found
{ "success": false, "error": { "message": "Tenant not found: ws_alpha9",
                     "code": "TENANT_NOT_FOUND" } }
// the answer names ws_alpha9. Nothing anywhere mentions ws_beta7.

// a malformed X-CF-Key does NOT fall back to a well-formed X-API-Key
$ curl -i https://app.contentflow.click/api/v1/sdk/sync \
     -H "X-CF-Key: nounderscore" \
     -H "X-API-Key: ws_beta7_app"

HTTP/1.1 401 Unauthorized
{ "success": false, "error": { "message": "SDK key is not in the expected <tenantId>_app or <tenantId>_test form",
                     "code": "INVALID_SDK_KEY" } }
! Send exactly one SDK key per requestA request carrying two keys is not an error, so a stale X-API-Key left in a shared HTTP client's default headers will never announce itself. It sits there doing nothing until the day someone removes X-CF-Key, at which point the old key silently takes over and your app starts reading a different workspace with a clean 200. Pick one header, set it in one place, and do not carry the other.

Environment comes from the key suffix

A key ending in _test resolves to the test / UAT environment. Anything else resolves to live. That is the only environment signal: a request body can never claim an environment, and no header can flip a live install into test.

KeyEnvironmentSees
ws_a1b2c3d4_appliveLive content only.
ws_a1b2c3d4_testtestLive content plus content staged for test.
! Older examples elsewhere show a key format that fails, but not with the status you would guessSome earlier docs and snippets on this site use keys of the form cf_live_… and cf_test_…. A key must be shaped <tenantId>_app or <tenantId>_test, and a legacy key does not fail the way you would expect. The server strips a known _app or _test suffix, and when neither is present it falls back to the text before the last underscore. So cf_live_xxx resolves to a tenant named cf_live, which does not exist, and the answer is 404 with {"code":"TENANT_NOT_FOUND","message":"Tenant not found: cf_live"}. It is not a 401 INVALID_SDK_KEY: that status is returned only when no workspace name can be parsed out of the key at all, which is a narrower condition than it sounds. See how a key resolves to a workspace for exactly where the line falls. Note also that cf_test_xxx does not end in _test, so even by suffix it would not select the test environment. Copy your real key from the dashboard rather than adapting an old example.
! Keep keys out of your repoThe SDK key is publishable but still workspace-scoped. Ship it through your build's secret store, not a committed constant, so you can rotate it without an app release.

How a key resolves to a workspace, and when it is a 401

The workspace id is parsed out of the key by the gateway before any lookup happens, in three steps:

  1. A trailing _app is stripped, provided there is at least one character in front of it.
  2. Otherwise a trailing _test is stripped, provided there is at least one character in front of it.
  3. Otherwise everything before the last underscore is taken, provided that underscore is not the very first character of the key.

Exactly one suffix is stripped, never two, and none of this touches the database. 401 INVALID_SDK_KEY is returned only when all three steps fail to produce a name, which is precisely the case where the key holds no underscore at any position after the first character: either it has no underscore anywhere, or its only underscore is the leading one. Any other key parses into some name, and the answer then turns on whether a workspace by that name exists, which is a 404 TENANT_NOT_FOUND when it does not.

401 INVALID_SDK_KEY
{
  "success": false,
  "error": {
    "message": "SDK key is not in the expected <tenantId>_app or <tenantId>_test form",
    "code": "INVALID_SDK_KEY"
  }
}

The discriminating cases, measured against the live gateway on 16 August 2026. Every key in this table is invented, so you can send any of them yourself without holding a key of your own:

Key sentWorkspace id parsed out of itAnswer
ws_a1b2c3d4_appws_a1b2c3d4Normal lookup, then whatever that workspace holds.
cf_live_xxxcf_live404 Tenant not found: cf_live
d5a_d5b_d5cd5a_d5b404 Tenant not found: d5a_d5b. The last underscore is the split point, not the first.
d5aaa_app_appd5aaa_app404 Tenant not found: d5aaa_app. One suffix is stripped, not both.
__app_404 Tenant not found: _. A single underscore is a parseable name.
_appNone401 INVALID_SDK_KEY
_testNone401 INVALID_SDK_KEY
_None401 INVALID_SDK_KEY
nounderscoreNone401 INVALID_SDK_KEY
! _app holds an underscore and is still a 401That row is the one that settles it. The presence of an underscore was never the rule: _app, _test and _ all contain one and all answer 401, because their only underscore is the leading one and stripping around it leaves nothing to look up. Conversely __app, which looks just as broken, parses cleanly into the name _ and answers 404. If you are branching on these two statuses in a client, branch on the status you actually received, not on a guess about the key's shape.
check it yourself, no key required
$ curl -i https://app.contentflow.click/api/v1/sdk/sync \
     -H "X-CF-Key: _app"

HTTP/1.1 401 Unauthorized
{ "success": false, "error": { "code": "INVALID_SDK_KEY",  } }

$ curl -i https://app.contentflow.click/api/v1/sdk/sync \
     -H "X-CF-Key: d5a_d5b_d5c"

HTTP/1.1 404 Not Found
{ "success": false, "error": { "message": "Tenant not found: d5a_d5b",
                     "code": "TENANT_NOT_FOUND" } }

Where a portal JWT comes from

The portal JWT is named all over this site as the credential for management routes, so here is where it is issued and what it costs to keep one alive. It is the credential the dashboard itself uses. It is not an SDK key, and it is not interchangeable with one.

PropertyValue
Issued byPOST /api/v1/auth/login, with a JSON body of {"email": "…", "password": "…"}.
Workspace id needed to log inNone. The response tells you which workspace the token is for, so you do not have to know it in advance.
Sent asAuthorization: Bearer <token> together with X-Tenant-Id. Both are required on portal routes.
Lifetime1 day. It is not configurable per workspace.
RenewalCall POST /api/v1/auth/login again. See the note on refresh below.
Roles it can carryExactly three: admin, editor, viewer.

The login response is flat, not enveloped

Every other route on this page wraps its payload. This one does not, and a parser that reaches for data will find nothing:

200 login response
{
  "message": "Login successful",
  "token":   "<JWT>",
  "user": {
    "userId":    "…",
    "tenantId":  "…",
    "roles":     ["…"],
    "email":     "…",
    "name":      "…",
    "workspace": "…"
  }
}

Take the token from token and the workspace id from user.tenantId, and send those two together on every portal call. If X-Tenant-Id disagrees with the token, the request fails with 401 INVALID_CREDENTIALS, which reads like a bad password and is not one.

Logging in, then using the token

curl
// step 1: log in and keep the answer
$ curl -s https://app.contentflow.click/api/v1/auth/login \
     -H "Content-Type: application/json" \
     -d '{"email": "you@example.com", "password": "<your password>"}' > login.json

$ TOKEN=$(jq -r '.token'          login.json)
$ TENANT=$(jq -r '.user.tenantId' login.json)

// step 2: send both on the portal route
$ curl -s https://app.contentflow.click/api/v1/blocks \
     -H "Authorization: Bearer $TOKEN" \
     -H "X-Tenant-Id: $TENANT"

// login.json holds a live token for a day. Delete it when you are done.

There is no refresh

! No refresh endpoint and no refresh token existThe only way to renew a portal JWT is to call POST /auth/login again with the email and password. There is nothing to exchange an expiring token for. Plan for a fresh login every 24 hours rather than for a refresh loop you will not find. POST /auth/logout exists but does not invalidate the token server-side, so treat logout as "discard it in the client" and nothing more. A token is invalidated early, within about 30 seconds, when the account's password, role, or workspace membership changes.

Two-step verification, when the account has it enabled

If the account uses two-step verification, POST /auth/login answers 200 with no token. The body carries requires2fa: true, an opaque challenge string, and the list of methods available on that account. A script that reads token without checking requires2fa will silently proceed with an empty credential.

Complete it by posting the challenge and the code to POST /api/v1/auth/2fa/verify:

2fa/verify request body
{
  "challenge": "<the opaque string from the login response>",
  "code":      "<the code from the authenticator>",
  "method":    "totp"
}

The token comes back from that call. The challenge is opaque: pass it back exactly as you received it, do not parse it, and do not construct one. It expires 5 minutes after it is issued, so a script that pauses for human input has to finish inside that window or start again from login.

Refusals a script has to handle

Status and codeWhen
401 INVALID_CREDENTIALSThe email and password did not match, or X-Tenant-Id disagrees with the token you sent.
403 EMAIL_NOT_VERIFIEDThe account has not confirmed its email address yet.
403The workspace is suspended.
403 FORBIDDEN_ROLEThe token is valid but the account's role is not permitted on that route. POST /segments, PUT /blocks/{key}/instances/{id}, GET /settings/write-key, GET /settings/read-key and POST /blocks all require admin or editor.
200 with no tokenNot a refusal. Two-step verification is enabled on the account, see above.
! Login is rate limited and repeated failures lock the accountA retry loop around a wrong password does not eventually succeed, it locks the person out. No thresholds or windows are published here, so build your client to stop on the first refusal and surface it, rather than to retry until something changes.

There is no service account for this surface

! No machine credential exists for portal JWT routesStated plainly because it changes how you build: there is no service account and no non-human credential for the portal surface. No machine token, no personal access token, no client-credentials grant, no long-lived token. A scripted client or a CI job has to log in as a real person's account, clear that person's two-step verification if it is enabled, and log in again every 24 hours, with that person's password held in your secret store.

If that is not acceptable for your pipeline, the parts of the workflow that do have a non-human credential are the SDK key on /sdk/*, the write key on the three cards and strings routes, and the read key on /read/*. Design the automated half of your workflow around those three where you can, and reserve the portal routes for work a person is actually present for.
i The "API keys" card in Settings is not this credentialThe values shown in the dashboard's Settings area under "API keys" do not issue a portal credential, and they are not accepted as authentication on portal JWT routes. If you are looking for something to put in Authorization, it is not there: the only issuer is POST /auth/login.

Anonymous, content-only mode

This is a first-class, supported way to use ContentFlow, and for a long time it was undocumented, which is why it is here near the top rather than buried in a guide. If all you want is to change copy and imagery without shipping an app release, you can have exactly that and collect nothing about anybody.

The three reads are reads. They resolve content for a workspace, not for a person:

CallWith only X-CF-Key
GET /sdk/sync200 with every live block that is not segment-restricted.
GET /sdk/blocks/:key200 with that block, resolved the same way.
GET /sdk/strings200 with the translation catalog (only strings whose status is ok, shown in the dashboard as "Approved"). This one never looks at a device at all.
the whole integration
$ curl "https://app.contentflow.click/api/v1/sdk/sync?locale=ar" \
     -H "X-CF-Key: ws_a1b2c3d4_app"

HTTP/1.1 200 OK
ETag: W/"sync-ws_a1b2c3d4-3f9c1ab2"

{ "success": true, "data": { "blocks": [  ], "version": "W/\"sync-…\"" } }

One header. No identify, no device id, no traits, no user id. ETag caching, locale negotiation, collections and the block payload shape all behave exactly as documented everywhere else on this page.

What you give up, and what you get

You give upYou get
Segment-targeted content. A device with no resolved segments receives the untargeted, global instances. An instance aimed at "high value customers" will not reach it, and that is not a bug you can debug your way out of.No profile. Nothing is created in Audience. There is no person record to export, retain, or delete.
Per-user and per-device analytics. Impressions, taps, CTA clicks and conversions all hang off a device, so a client that sends no device id has no engagement numbers in the dashboard.No device identifier. You never generate, persist, or transmit one, so there is nothing to declare as a persistent identifier.
Push, SMS, WhatsApp and email campaigns to these users, along with per-channel consent records, all of which are keyed to a person.No traits. Nothing you know about the user leaves the device, because you never send it.
A/B variant assignment, which is deterministic per device.Nothing written. On a live _app key these reads perform no write of any kind, so no record appears anywhere, not even a "last seen" timestamp.
i This is the mode to pick for a privacy-sensitive appIf your app is otherwise offline, or handles anything a privacy regime treats as sensitive, calling identify with a persistent device id and traits is a decision with real downstream consequences. A store privacy questionnaire will ask what you collect, and a persistent id plus user attributes is a different answer from "nothing". Pick anonymous mode first, and add identify later, deliberately, when you actually want targeting or analytics. It is much easier to add than to walk back.
! One thing to check if you are using a ContentFlow SDK rather than raw HTTPThe JavaScript client's start() is a convenience that calls identify() for you and generates a persisted device id. For content-only use, skip start() and call sync() and getStrings() yourself, or call the REST endpoints directly. Raw HTTP is the only path that guarantees no device id is ever generated, so if the guarantee is what you need, take it.

Upgrading later

Nothing about anonymous mode is a dead end. Adding identify later is additive: the same key keeps working, the same blocks keep arriving, and you begin receiving segment-targeted instances on top. You are not migrating, you are turning something on.


Response envelope

Every endpoint answers with the same two shapes. Parse the envelope once, in one place, and reuse it everywhere.

envelope
// success
{ "success": true,  "data": { /* endpoint payload */ } }

// failure
{ "success": false, "error": { /* description of what went wrong */ } }

There is exactly one level of wrapping on every endpoint. Read your payload from data. Treat unknown fields inside data as additive and ignore them, so a future field never breaks your client.

! Historical note: the write endpoints used to double-wrapThis was a bug on our side, not a design. The write endpoints (/sdk/identify, /sdk/register-push, /sdk/events, /sdk/track-event, /sdk/consent) wrapped the service payload a second time, so the real body arrived at data.data instead of data. The read endpoints were never affected. This has been corrected, and the single envelope documented above is what those endpoints return. Write your client against data. If you are maintaining an existing integration, or you cannot be certain which build a device is talking to, a defensive read of the form (body.data.data ?? body.data) is safe and correct against both the old and the corrected responses.
! The single envelope is a /sdk/* rule, not a base-URL ruleEverything above describes the delivery API. The portal API on the same base URL, the JWT-authenticated management surface such as GET /api/v1/blocks, still double-wraps: its real payload is at data.data. Two conventions, one host, and nothing in the URL tells you which one you are about to get. If your integration touches both, read Portal API differences before you share a response parser between them.

Caching and versioning

The read endpoints (/sdk/sync, /sdk/blocks/:key, /sdk/strings) are conditional-request aware. Honouring that is the difference between a polite client and one that re-downloads your whole catalog every launch.

  1. Every read returns an ETag response header holding a weak validator, in the W/"…" form.
  2. The same value is repeated as the version field inside the response body, so you can persist it alongside the cached payload without reading headers.
  3. Send it back as If-None-Match on the next read. If nothing changed you get 304 Not Modified with an empty body. Keep using your cached copy.

The three read endpoints emit three different validators. They share the weak-ETag form and nothing else. Cache each one against its own endpoint, and never send the validator you got from one read to a different one.

ReadValidator
/sdk/syncOne value covering the whole block payload for this device, including every card inlined inside a collection. Editing a card moves it even when no wrapper was touched.
/sdk/stringsA separate value covering the string catalog for the requested locale.
/sdk/blocks/:keyA per-block value, distinct per key and locale.
curl
$ curl -i https://app.contentflow.click/api/v1/sdk/sync \
     -H "X-CF-Key: ws_a1b2c3d4_app" \
     -H "X-Tenant-Id: ws_a1b2c3d4" \
     -H "X-CF-Device: dev_9f2c41" \
     -H 'If-None-Match: W/"sync-ws_a1b2c3d4-3f9c1ab2"'

HTTP/1.1 304 Not Modified
ETag: W/"sync-ws_a1b2c3d4-3f9c1ab2"
i Treat the validator as an opaque stringEcho it back byte for byte, including the W/ prefix and the quotes. Do not parse it, do not split it, do not compare its parts, and do not build one yourself. The internals differ per endpoint and are free to change without notice. The only contract is this: store what you were given, send it back on the next read of that same endpoint, and it will change whenever the content you would receive changes.

One thing to know about /sdk/strings: it previously returned a bare sha with no W/"" wrapper, which broke naive validator comparisons. That is being unified to the weak-ETag form documented here. If your client stores whatever it received last and echoes it back verbatim, the change is invisible to you. If your client string-matched on the bare sha, move to verbatim echo.


Locale negotiation

This has caused a real defect in a customer integration, so it is worth thirty seconds of your attention. Two mechanisms are read, two commonly-guessed ones are not.

MechanismRead?Notes
?locale=ar query parameterYesHighest precedence. Wins over the header.
Accept-Language request headerYesThe standard header. The first subtag wins: ar-SA,ar;q=0.9 resolves to ar.
?lang=ar query parameterNoIgnored. Not an alias for ?locale=.
X-CF-Locale request headerNoIgnored. Not a ContentFlow header.

An ignored mechanism does not fail the request. If you send only ?lang= or only X-CF-Locale, and no supported mechanism at all, the response carries an X-CF-Warning header naming exactly what was ignored, so the mistake is visible instead of silently serving you the base locale with a clean 200.

curl
$ curl -i "https://app.contentflow.click/api/v1/sdk/strings?lang=ar" \
     -H "X-CF-Key: ws_a1b2c3d4_app" \
     -H "X-Tenant-Id: ws_a1b2c3d4" \
     -H "X-CF-Device: dev_9f2c41"

HTTP/1.1 200 OK
X-CF-Warning: unsupported locale parameter "lang" ignored; use ?locale= or the Accept-Language header

When a field has no translation in the requested locale, it falls back to the tenant's source locale rather than returning blank. You never render an empty string because a translator has not finished.

i Log the warning headerSurface X-CF-Warning in your client's debug log. It is the cheapest possible early warning that your locale plumbing is wrong.

Error behaviour

Failures use the standard failure envelope with a real HTTP status. The status is not always the one you would guess, and that is deliberate.

StatusCodeWhen
400Malformed request: a required field is missing, or a field has the wrong type (for example consent sent as a string instead of a boolean). Not what a missing credential gives you on /sdk/*: a request with no key, with or without an X-Tenant-Id, answers 401 MISSING_SDK_KEY, and this page said 400 until 16 August 2026. Measured both ways.
401MISSING_SDK_KEYNo SDK key was presented at all: neither X-CF-Key nor its alias X-API-Key was sent.
401INVALID_SDK_KEYNo workspace name could be parsed out of the key, which happens when the key holds no underscore at any position after the first character: either none anywhere, or only a leading one. The message is SDK key is not in the expected <tenantId>_app or <tenantId>_test form. This is a malformed credential, not a wrong one: no lookup happened, so nothing is revealed by saying so. Note that _app does contain an underscore and still lands here, while a legacy cf_live_… key does not, it lands on TENANT_NOT_FOUND below. The full parse, with the discriminating cases, is in how a key resolves to a workspace.
401SDK_KEY_IN_QUERYA ?key= query parameter was sent to something other than GET /sdk/stream. See the alias table for why that one route is the exception.
401UNAUTHORIZEDOn the read surface only: X-CF-Read-Key was missing, or the key presented was not a valid read key.
403TENANT_KEY_MISMATCHThe key is well formed, but X-Tenant-Id names a different workspace than the key is bound to. You cannot read another workspace by swapping the header.
404TENANT_NOT_FOUNDThe key parsed into a tenant name that does not exist. The message names it, for example Tenant not found: cf_live. Two requests land here, and both are reproducible with an invented key: a legacy-format key, whose parse leaves a fragment that is not a workspace, and a well-formed key naming a workspace that does not exist. What a revoked key returns has not been confirmed, so do not assume it is this code.
404NOT_FOUNDThe path is not a route. Every path under /api/v1/read other than the three documented ones answers this way, with a Cannot GET … message. See the read surface before you conclude something is broken.
404Unknown block key on /sdk/blocks/:key.
i Why an unknown key is 404 and not 401The two look similar and are deliberately different. A malformed key never reaches a lookup, so it answers 401. A well-formed key that simply does not exist answers 404, because a 401 there would confirm that the workspace exists and only the credential is wrong. Key probing therefore cannot be used to enumerate tenants. When a fresh integration 404s everywhere, suspect the key before you suspect an empty workspace. Note that the reverse is not a useful test: a 404 tells you nothing about whether your base URL is right, because the retired host 404s at its root too.
404 unknown block
HTTP/1.1 404 Not Found

{
  "success": false,
  "error": "unknown block"
}

Endpoint

Identify a device or user

POST/sdk/identify, upsert a device or user and resolve its segments

It upserts the device behind X-CF-Device, records the consent decision, stores traits, optionally links the device to one of your user ids, and returns the segments the device now resolves to. Those segments decide which targeted blocks sync will hand you.

! Identify is optional, and it is the call that makes your integration a privacy questionIt is not required to fetch content. Its whole job is to attach a profile to a device: an identifier, traits, consent state, and segment membership. Call it when you want segment-targeted content, per-user analytics, or messaging. Do not call it merely because it is listed first. If you only need remotely editable copy, anonymous, content-only mode gives you that with no identifier and nothing stored.

Request headers

X-CF-Key required, X-CF-Device required here because this call is about a device, Content-Type: application/json required because there is a body. X-Tenant-Id is optional and redundant, exactly as in the auth table: send it and it must agree with the key, omit it and nothing changes.

Request body

FieldTypeNotes
consentbooleanOptional. Device-level analytics consent. Omitting it leaves the stored decision unchanged, it does not reset to false. Sending a non-boolean is rejected with 400.
traitsobjectOptional. Flat map of your own attributes used for targeting. Only stored once consent is granted. A non-empty object replaces the device's whole stored trait set, it does not merge into it, so send the full set every time. An empty object {} means nothing to report and changes nothing: the stored traits are left exactly as they are, the same as omitting the key. null and malformed values (an array, a string, a number) are also left-unchanged. There is therefore no way to clear a device's traits through this field, use DELETE /api/v1/sdk/identify to erase a device outright.
userIdstringOptional. Your stable user id. Without it the device stays anonymous presence rather than a known profile.
platformstringOptional. ios, android, or web.
deviceIdstringOptional. Overrides the X-CF-Device header for this call. Prefer the header.

Example

curl
$ curl -X POST https://app.contentflow.click/api/v1/sdk/identify \
     -H "Content-Type: application/json" \
     -H "X-CF-Key: ws_a1b2c3d4_app" \
     -H "X-Tenant-Id: ws_a1b2c3d4" \
     -H "X-CF-Device: dev_9f2c41" \
     -d '{
       "userId": "user_8812",
       "consent": true,
       "platform": "ios",
       "traits": { "region": "riyadh", "salary": 7200, "signupDaysAgo": 12 }
     }'
200 response
{
  "success": true,
  "data": {
    "deviceId": "dev_9f2c41",
    "segments": ["All users", "Riyadh region", "New users (< 30 days)"],
    "consent": true
  }
}
i Segment membership is resolved at identify timeRe-call identify when your user logs in, logs out, or when a trait you target on changes. Sync does not re-evaluate segments on its own.

Endpoint

Erase everything stored for one device

DELETE/sdk/identify, hard-delete the device and the person-level data behind it

The erasure route. It removes the device named by X-CF-Device and the records that hang off it, then reports part by part what it managed to erase. This is the call behind a "delete my data" control in your app. Its answer has to be read rather than glanced at: a 200 is not the only success-looking status it returns, and one field in the body decides what your client is allowed to do next.

Request

PartValue
CredentialThe workspace SDK key, in X-CF-Key or its alias X-API-Key. Required. No portal JWT is involved, and no role is checked.
X-CF-DeviceRequired. Names the device to erase. This is the only way to name it.
X-Tenant-IdOptional and redundant, as everywhere on /sdk/*. Send it and it must agree with the key, or the call is refused with 403 TENANT_KEY_MISMATCH.
Request bodyNone. There is no body to send.
Query parametersNone.
Path parameterNone. The path is exactly /sdk/identify, with no id on the end.
i There is no way to pass a user id to this routeThe device is named by the X-CF-Device header and by nothing else. The linked user id is read from the stored device record, so you do not supply it and you cannot override it. If you hold a user id and not a device id, resolve the devices first with GET /read/users/{userId}/devices on the read surface, then erase each device you get back.

Example

! This curl is a write, and it is not reversibleEverything else on this page that you can paste into a terminal is a read. This one deletes. Run it deliberately, against a device id you meant to name, and read the status table below before you act on the answer.
curl, this one deletes
$ curl -i -X DELETE https://app.contentflow.click/api/v1/sdk/identify \
     -H "X-CF-Key: <your key>" \
     -H "X-CF-Device: <the device id to erase>"

The success response

200 response
{
  "success": true,
  "data": {
    "deviceId":      "…",
    "status":        "deleted",
    "localWipeSafe": true,
    "parts": {
      "device":          { "store": "…", "applied": true, "deleted": 1 },
      "profile":         { "store": "…", "applied": true, "deleted": 1 },
      "kyc":             {  },
      "locationHistory": {  },
      "geofenceEvents":  {  },
      "enrichment":      {  },
      "events":          { "store": "…", "applied": true, "deleted": 42 }
    },
    "remaining": []
  },
  "metadata": { "timestamp": "…" }
}

Single wrap, exactly as on the rest of /sdk/*. Read the outcome from data.

MemberMeaning
deviceIdThe device you named, echoed back.
statusdeleted, partial, or failed. See the status table below.
localWipeSafeBoolean. The one field your client branches on. See the client rule below.
partsObject, one entry per class of data. Each entry carries store, naming the store that held it, applied, and deleted, plus error when the part was not applied.
remainingArray. The parts that were not erased. Empty on a full success.
i deleted: 0 is a success, not a failuredeleted is a number or null. A number is how many records went. Zero means the store held nothing to delete, which is a completed erasure and the normal answer on a repeat call. null means the store confirmed the delete without reporting a count. What tells you a part failed is applied: false together with an error, and its name appearing in remaining. Never treat a zero count as an incomplete erasure.

Status codes

StatusstatuslocalWipeSafeMeaning
200deletedtrueEvery part was erased. remaining is empty.
207partialfalseSome parts were erased and some were not. remaining names the rest, and data.pendingSince carries the ISO timestamp of the first request for this device, not of the latest retry, so it measures how long the erasure has actually been outstanding.
502failedfalseNothing was erased.
400No X-CF-Device header was sent. Body: {"success":false,"error":"the X-CF-Device header is required to identify which device to erase"}.
! Wipe locally only when localWipeSafe is trueThis is the client rule, and it is the whole reason the field exists. Proceed with your own local wipe, including discarding the device id, only when data.localWipeSafe is true. Otherwise keep the device id and retry later, because that id is the only key that can still address the records listed in data.remaining. Throw it away on a 207 and the outstanding half of the erasure becomes unaddressable from your side.
! This route answers in two different failure shapesGateway-level refusals, the 401, 403 and 404 you get for a bad key or a mismatched tenant, use the nested envelope with a machine-readable code: {"success":false,"error":{"message":"…","code":"…"}}. The 400, 207 and 502 answers from this route itself use a flat shape instead: {"success":false,"error":"<string>","data":{…}}, where error is a plain string and there is no code member at all. A client that reads error.code unconditionally will read undefined on exactly the answers that matter most here. Handle both shapes: check whether error is a string before you reach into it.

Idempotency

The route is fully idempotent, and deliberately does not use 404 to signal anything.

What you callWhat comes back
The same device a second time200, with every part applied: true and deleted: 0. Retrying is safe and costs you nothing.
A device that never existedThe same 200. This is not a 404 on purpose: a 404 would tell a client that its erasure had not happened, when in fact there is nothing anywhere to erase.
A device belonging to another workspaceUnreachable. The workspace is pinned to the key, so you can only ever address devices in your own workspace, and a device id from somewhere else simply reads as an unknown device.

What it erases

One entry in parts per class of data:

PartWhat goes
deviceThe device record itself, and with it the device's traits, its segment snapshot, its consent state and consent timestamps, its push token and platform, its pseudonymous id, its first-seen and last-seen timestamps, and its link to a user id.
profileThe person-level profile: custom attributes, email, phone, and materialized segment memberships.
kycKYC verification records.
locationHistoryLocation history batches.
geofenceEventsGeofence event records.
enrichmentEnrichment records.
eventsAnalytics events for that device.

What survives, and what that means for a compliance answer

If you are answering a data subject's erasure request, this is the part to read to the end. Everything below is true after a clean 200 with status: "deleted".

  • Sibling devices keep their own records. Erasing one device also deletes the shared person-level profile, so traits and segment membership contributed by that person's other devices go with it. The other devices themselves are not deleted: only the device you named is removed, and each sibling keeps its own traits, its own consent state, its own push token and its own link to a user id. To erase a person across their devices, list them with GET /read/users/{userId}/devices and call this route once per device.
  • Notification records are not swept by this route. They carry contact details, including recipient email, phone and device token. They are not deleted here; they age out on their own retention window of 30 days.
  • Notification preference records and journey enrolment records are not swept by this route either.
  • Marketer-level objects are untouched on purpose. Geofence definitions and the like are configuration rather than personal data, so an erasure does not disturb them.
  • An erasure record is kept on purpose, after the data is gone. It holds a one-way keyed pseudonym rather than the raw device id, so the fact that an erasure happened survives without the identifier surviving with it.
! The erasure is recorded, not enforcedThis is the one to be clear about, because it is what a compliance answer turns on. Nothing suppresses the identifier afterwards. A later identify or track-event carrying the same device id or the same user id will create a fresh profile, and the platform will treat it as a new device it has never seen. The erasure removed what was stored; it did not add the identifier to a blocklist. If your obligation requires that the subject stays erased, you have to stop sending that identifier from your own side, because the platform will not stop accepting it.

Operational notes

  • The deletes are hard deletes. There is no soft-delete flag, no undo, and no grace period. Once a part reports applied: true, that data is gone.
  • The route is opened by the same publishable SDK key your app already ships with, the one used for content delivery, so any code path that can reach the network with your key can reach this route. Gate the call behind your own verified-user flow, and pass it a device id you resolved yourself rather than one taken from arbitrary input.
  • A 207 is not a dead end. Keep the device id, retry, and use pendingSince to see how long the outstanding parts have been outstanding.

Consent and the two separate consent decisions are covered in Consent and PDPL.


Endpoint

Register a push token

POST/sdk/register-push, store an APNs or FCM token for this device

Files the OS push token against the device, and against the linked user profile when there is one. Call it every launch once the OS hands you a token: tokens rotate, and the last one you registered is the one campaigns will use.

Request body

FieldTypeNotes
tokenstringRequired. The raw OS token. Missing or blank is rejected with 400.
platformstringOptional. ios, android, or web.
providerstringOptional. apns or fcm. When omitted it is derived from platform: ios gives apns, android gives fcm.
userIdstringOptional. Only send it when you know it. Omitting it never unlinks a device that identify already linked.
deviceIdstringOptional. Overrides the X-CF-Device header for this call.

Example

curl
$ curl -X POST https://app.contentflow.click/api/v1/sdk/register-push \
     -H "Content-Type: application/json" \
     -H "X-CF-Key: ws_a1b2c3d4_app" \
     -H "X-Tenant-Id: ws_a1b2c3d4" \
     -H "X-CF-Device: dev_9f2c41" \
     -d '{
       "token": "e9f1c0a2b7d4…",
       "platform": "ios",
       "provider": "apns",
       "userId": "user_8812"
     }'
200 response
{
  "success": true,
  "data": {
    "deviceId": "dev_9f2c41",
    "userId": "user_8812",
    "registered": true,
    "provider": "apns"
  }
}
i Registering a token is not consent to messageHolding a token means you can technically deliver a push. Whether you are permitted to is the push flag in per-channel consent.

Endpoint

Sync every live block

GET/sdk/sync, every live block for the tenant behind the key

The main read. Returns every live block instance visible to this device, already filtered by the device's segments and resolved into the locale you asked for. One call per launch, plus a refresh when the stream tells you something changed.

i X-CF-Device is optional here, and so is identifySend neither and you get a 200 carrying every live block that is not segment-restricted. Send a device id that has never called identify and you get the same thing: an unknown device resolves to no segments, which is a valid answer, not an error. On a live _app key this call writes nothing at all, so an anonymous read leaves no trace. See anonymous, content-only mode.

Query parameters

ParameterTypeNotes
localestringOptional. See locale negotiation. Omit it and the Accept-Language header is used instead.

Conditional request

Send If-None-Match with the last version you stored. A 304 Not Modified with an empty body means your cache is current.

Example

curl
$ curl "https://app.contentflow.click/api/v1/sdk/sync?locale=ar" \
     -H "X-CF-Key: ws_a1b2c3d4_app" \
     -H "X-Tenant-Id: ws_a1b2c3d4" \
     -H "X-CF-Device: dev_9f2c41"
200 response
ETag: W/"sync-ws_a1b2c3d4-3f9c1ab2"

{
  "success": true,
  "data": {
    "blocks": [
      {
        "key": "discovery_card",
        "name": "Discovery card",
        "screen": "Home screen",
        "instanceId": "inst_7c31",
        "segment": "high_value",
        "version": 7,
        "values": {
          "#header_image": "https://cdn.example.com/hero.png",
          "#title_main": "أهلاً بك"
        },
        "fields": [
          { "tag": "#header_image", "type": "image" },
          { "tag": "#title_main",   "type": "text" }
        ]
      }
    ],
    "version": "W/\"sync-ws_a1b2c3d4-3f9c1ab2\""
  }
}

See block payload shape for how the values and fields members fit together, and why you must address fields by tag.


Endpoint

Resolve one block by key

GET/sdk/blocks/:key, one block, resolved for this device

Same resolution rules as sync, scoped to a single block type key. Useful when one screen needs one block and you do not want to hold the whole catalog in memory. It is conditional-request aware in exactly the same way.

Path and query

ParameterInNotes
keypathRequired. The block type key, for example home_hero.
localequeryOptional. Same rules as sync.

Example

curl
$ curl https://app.contentflow.click/api/v1/sdk/blocks/home_hero \
     -H "X-CF-Key: ws_a1b2c3d4_app" \
     -H "X-Tenant-Id: ws_a1b2c3d4" \
     -H "X-CF-Device: dev_9f2c41"

An unknown key is a 404 with the standard failure envelope. It is not an empty 200, so a typo in a block key fails loudly.

404 response
{ "success": false, "error": "unknown block" }
i Known key, nothing to showA block that exists but currently resolves to nothing for this device answers 200 with visible: false instead of a block payload. Render nothing and move on. Do not treat it as an error, and do not retry.

Endpoint

Fetch approved translations

GET/sdk/strings, approved translations for one locale, plus smartKeys

Returns the tenant's translation catalog for one locale as a flat key to string map. Only review-approved translations ship. Anything still in draft or machine-translated falls back to the tenant's source locale, so your UI never renders a half-translated screen.

i The most anonymous call on this pageThe catalog belongs to the workspace, not to a person, so this endpoint never reads a device at all. X-CF-Device is ignored, and identify is irrelevant to it. A key is the whole request. See anonymous, content-only mode.
i The wire value is ok, not approvedThe status stored and returned by the localization API is ok; the dashboard displays that status as "Approved" for readability. The only other values are review (needs review, including machine-translated drafts) and missing. There is no approved value anywhere in the API or the database.
! How fast does a published translation show upDelivery reads this catalog from a per-tenant cache with a 60 second TTL. An edit typically appears well under that, but the worst case is a full 60 seconds - there is no faster guaranteed number, so do not build a UI or support script that assumes it lands in a few seconds.

Query parameters

ParameterTypeNotes
localestringOptional. See locale negotiation.
namespacestringOptional. Restrict the catalog to a single namespace.

Example

curl
$ curl "https://app.contentflow.click/api/v1/sdk/strings?locale=ar" \
     -H "X-CF-Key: ws_a1b2c3d4_app" \
     -H "X-Tenant-Id: ws_a1b2c3d4" \
     -H "X-CF-Device: dev_9f2c41"
200 response
ETag: W/"strings-ws_a1b2c3d4-b71e04d9"

{
  "success": true,
  "data": {
    "locale": "ar",
    "strings": {
      "checkout.title": "إتمام الشراء",
      "checkout.cta":   "تأكيد"
    },
    "smartKeys": ["checkout.title"],
    "version": "W/\"strings-ws_a1b2c3d4-b71e04d9\""
  }
}
i What smartKeys is forsmartKeys lists the keys the workspace has enabled for observation (Sensors). If you do not implement Sensors, ignore the field entirely, it is additive and safe to drop. If you do, emit string_impression and string_interaction through /sdk/events for those keys only. See Sensors.

Endpoint

Send engagement events

POST/sdk/events, batched engagement events, consent-gated

Batch your engagement signals and post them together. This is the endpoint that makes the dashboard's numbers real. Batch on a timer or on backgrounding, not one request per tap.

Event types

TypeSend when
impressionA block became visible to the user.
tapThe user tapped the block itself.
cta_clickThe user activated the block's call to action.
dismissThe user closed or dismissed the block.
conversionThe user completed the outcome the block was asking for.

Request body

FieldTypeNotes
eventsarrayRequired. Each entry carries a type from the table above and the instanceId of the block it refers to.
contextobjectOptional, sent once per batch rather than per event. Carries session and device context such as sessionId and platform.

Example

curl
$ curl -X POST https://app.contentflow.click/api/v1/sdk/events \
     -H "Content-Type: application/json" \
     -H "X-CF-Key: ws_a1b2c3d4_app" \
     -H "X-Tenant-Id: ws_a1b2c3d4" \
     -H "X-CF-Device: dev_9f2c41" \
     -d '{
       "events": [
         { "type": "impression", "instanceId": "inst_7c31" },
         { "type": "cta_click",  "instanceId": "inst_7c31" }
       ],
       "context": { "sessionId": "sess_4d20", "platform": "ios" }
     }'
200 response
{
  "success": true,
  "data": { "accepted": 2, "dropped": 0 }
}
! A 200 does not mean recordedIf the device has not granted consent, the batch is dropped server side and you still get success: true with accepted: 0 and a dropped count. That is intentional, an unconsented client must not be able to tell the difference. Read accepted, do not assume it.

Endpoint

Send identity events

POST/sdk/track-event, sign_up / sign_in / custom, consent-gated

Where /sdk/events records what happened to a block, this records what happened to a person. It links the device to a user, upserts the profile behind that user, and is what makes the account show up in Audience.

Request body

FieldTypeNotes
usernamestringRequired. Your stable user identifier. Missing it is a 400.
eventTypestringsign_up, sign_in, or custom.
fullNamestringOptional profile field.
emailstringOptional profile field.
phonestringOptional profile field. Required on the profile before SMS or WhatsApp can reach the user.
deviceobjectOptional. deviceId, model, osType, appVersion.
locationobjectOptional. lat, lng, region, country.
customobjectOptional. Your own attributes, merged into the profile.

Example

curl
$ curl -X POST https://app.contentflow.click/api/v1/sdk/track-event \
     -H "Content-Type: application/json" \
     -H "X-CF-Key: ws_a1b2c3d4_app" \
     -H "X-Tenant-Id: ws_a1b2c3d4" \
     -H "X-CF-Device: dev_9f2c41" \
     -d '{
       "username": "user_8812",
       "eventType": "sign_up",
       "fullName": "Sara A.",
       "email": "sara@example.com",
       "device": { "model": "iPhone15,2", "osType": "ios", "appVersion": "3.4.1" }
     }'
200 response
{
  "success": true,
  "data": {
    "deviceId": "dev_9f2c41",
    "segments": ["All users", "Riyadh region"],
    "consent": true
  }
}
! Identify with consent firstThis endpoint is consent-gated the same way /sdk/events is. Call /sdk/identify with consent: true before it, or the profile write is dropped and the response tells you so rather than failing.


Endpoint

Live updates over SSE

GET/sdk/stream, Server-Sent Events, tells you when to re-sync

A long-lived text/event-stream connection that tells you when content changed. It is a signal, not a delivery channel: when an event arrives, call /sdk/sync again. Do not try to patch your local state from the event payload.

Example

curl
$ curl -N https://app.contentflow.click/api/v1/sdk/stream \
     -H "Accept: text/event-stream" \
     -H "X-CF-Key: ws_a1b2c3d4_app" \
     -H "X-Tenant-Id: ws_a1b2c3d4" \
     -H "X-CF-Device: dev_9f2c41"
stream
: connected

id: 41
event: block.published
data: {"key":"home_hero"}

: ping

Lines beginning with : are comments: one on connect, then a periodic keep-alive. Ignore them, but do use them to detect a dead connection.

Resuming

Track the id of the last frame you processed. On reconnect send it back as the standard Last-Event-ID header, or as ?lastEventId= if your HTTP client cannot set headers on an event source. Buffered events after that id are replayed, so a dropped connection does not mean missed changes.

Event names

Block-changing events are named block.updated, block.published, block.paused, and block.targeted. Treat any block.* event as a single instruction to re-sync, debounce a burst of them into one call, and ignore names you do not recognise so new event types cannot break your client.

i The stream is optionalA client that polls /sdk/sync with If-None-Match on app launch and on foreground is perfectly correct, and cheap, because unchanged content answers 304 with an empty body. Add the stream when you need updates to land without the user reopening the app.

Endpoint

The read surface: ask what a device actually resolved to

Delivery answers "what should this device see". The read surface answers the question you need in a test, a support ticket, or a CI gate: what did the platform actually decide about this identifier. It is a separate surface with its own credential, and it is live today.

GET/api/v1/read/users/{userId}/devices, every device known for one identifier
GET/api/v1/read/devices/{deviceId}, one device and the profile attached to it
GET/api/v1/read/users/{userId}/segments, the segment names one identifier resolves to

There are exactly three. No fourth route exists under this prefix, and nothing here is paginated, filtered, or searchable.

The credential

One header, X-CF-Read-Key. No portal JWT and no X-Tenant-Id: the workspace is resolved from the read key itself, so neither of those headers has anything to contribute on this surface.

PropertyValue
HeaderX-CF-Read-Key
Formatrk_live_ followed by 24 lowercase hex characters, for example rk_live_4f2ab91c77d0e35b8a61c204.
ScopeRead only. It cannot write anything, and a write key will not validate on this surface.
Where to get itDashboard, Developers, "Your keys", on the row labelled "Read key". Visible to the admin and editor roles only. Over HTTP: GET /api/v1/settings/read-key with a portal JWT and the admin or editor role.
Rate limitNot documented. No limit is published for this surface and none has been measured either way. Do not build against a specific limit, and do not assume a limit is protecting this surface.
What you sentWhat comes back
No X-CF-Read-Key header at all401 {"message":"Missing X-CF-Read-Key","code":"UNAUTHORIZED"}
A header holding a key this workspace does not recognise401 {"message":"Invalid read key","code":"UNAUTHORIZED"}
! This key never ships in an appThe read key is a server-side and CI credential. It reads back profile and segment data for arbitrary identifiers in your workspace, so putting it in a mobile binary, a single-page app bundle, or anything a user can unpack hands that reading ability to anyone who looks. Keep it in your backend's secret store or your CI secret store. The publishable credential for a client is X-CF-Key, and it is a different key with a different job.
! Rotation is instant, and there is no button for itPOST /api/v1/settings/read-key/rotate exists, takes a portal JWT with the admin or editor role, and takes effect immediately with no grace period: the moment it returns, the previous key answers 401. There is no rotation control in the dashboard today, so rotation is an HTTP call you make deliberately, not something a colleague can do by clicking. Plan the swap in your secret store before you call it, because there is no overlap window to catch up in.

Every other path under the prefix is a 404, and that is correct

Only the three paths above are routes. /api/v1/read, /api/v1/read/users/{id}, /api/v1/read/segments and everything else under the prefix are not routes: they answer 404 NOT_FOUND with a Cannot GET … message, the framework's own reply to an unmatched path.

i Probing the parent path is the standard way to conclude this surface is brokenWalk up from a deep path and you get a 404 on the parent while the deep path answers 401. That reads like a misrouted or half-deployed service, and it is neither. A 404 on /api/v1/read means "that is not a route"; a 401 on /api/v1/read/users/u_1/segments means "that is a route and you have not authenticated". Test the three documented paths, and read the status you get on those.

The envelope is the standard one

envelope
{
  "success": true,
  "data": { /* endpoint payload */ },
  "metadata": { "timestamp": "2026-08-14T09:12:44.108Z" }
}

Single wrap, exactly as on /sdk/*. Read your payload from data, and treat metadata as informational.

i Tokens are stripped from every read responsePush tokens and device tokens never appear on this surface, on any of the three routes, no matter what is stored. If you are looking for a token to debug a delivery problem, this is not where you will find it, and that is deliberate.

What goes in the {userId} slot

{userId} accepts whatever the profile is keyed on. That is normally your own stable user id, the one you sent to identify. A device id works in that slot too, because an anonymous consented device gets a profile keyed on its device id. So a support script that only has a device id is not stuck.


Endpoint

List every device behind one identifier

GET/api/v1/read/users/{userId}/devices, every device known for one identifier

Answers "how many devices has this person actually got, and is the one I am debugging among them". This is the call that catches a fragmented identity: a client regenerating its device id per launch shows up here as a pile of near-identical devices under one user.

Request

PartValue
HeaderX-CF-Read-Key. Required, and the only header this call needs.
userIdPath segment. Required. Your user id, or a device id. See what goes in the slot.

Response

Member of dataMeaning
userIdString. Echoed back exactly as you sent it, so a response is self-describing in a log.
devicesArray. One entry per device record held for that identifier, with push and device tokens stripped out. An identifier with no devices answers 200 and an empty array.

Example

curl
$ curl https://app.contentflow.click/api/v1/read/users/user_8812/devices \
     -H "X-CF-Read-Key: rk_live_4f2ab91c77d0e35b8a61c204"
200 OK
{
  "success": true,
  "data": {
    "userId": "user_8812",
    "devices": [ /* one device record each, tokens stripped */ ]
  },
  "metadata": { "timestamp": "2026-08-14T09:12:44.108Z" }
}

Errors

StatusWhen
401 UNAUTHORIZEDMissing X-CF-Read-Key when the header is absent, Invalid read key when it holds something that is not a valid read key for a workspace.
404You dropped the /devices suffix. /api/v1/read/users/{id} on its own is not a route.

Endpoint

Fetch one device and its profile

GET/api/v1/read/devices/{deviceId}, one device and the profile attached to it

The single-device view. Use it when you have a device id from a crash report, a support ticket, or your own logs, and you want to know whether the platform has ever heard of it and what it knows.

Request

PartValue
HeaderX-CF-Read-Key. Required, and the only header this call needs.
deviceIdPath segment. Required. The same value your client sends as X-CF-Device.

Response

Member of dataMeaning
deviceObject. The device record, with push and device tokens stripped out.
profileObject or null. The profile attached to the device: customAttributes, consent, and appVersionSnapshot.

Example

curl
$ curl https://app.contentflow.click/api/v1/read/devices/dev_9f2c41 \
     -H "X-CF-Read-Key: rk_live_4f2ab91c77d0e35b8a61c204"
200 OK
{
  "success": true,
  "data": {
    "device": { /* device record, tokens stripped */ },
    "profile": {
      "customAttributes": { "region": "riyadh" },
      "consent": true,
      "appVersionSnapshot": "3.4.1"
    }
  },
  "metadata": { "timestamp": "2026-08-14T09:12:44.108Z" }
}

Errors and the one state that looks like an error

StatusWhen
401 UNAUTHORIZEDMissing X-CF-Read-Key, or Invalid read key.
404Device not found. No device is stored under that id in this workspace.
200 with profile: nullNot an error. The device exists and has no profile yet. That is a valid state, and it is what an unconsented or never-identified device looks like.
! appVersionSnapshot is a snapshot on the profile, not per-device truthIt is a last-known value carried on the profile, so on an identifier with several devices it tells you what was last seen somewhere, not what this particular device is running. Do not gate a rollout, a support answer, or a bug reproduction on it as though it were authoritative for the device you are holding.

Endpoint

Read the segments an identifier resolves to

GET/api/v1/read/users/{userId}/segments, the segment names one identifier resolves to

The one call that turns "did my targeting work" from an argument into an assertion. It returns the segment names the platform currently resolves for that identifier.

Request

PartValue
HeaderX-CF-Read-Key. Required, and the only header this call needs.
userIdPath segment. Required. Your user id, or a device id.

Response

Member of dataMeaning
userIdString. Echoed back as you sent it.
segmentNamesArray of strings. Segment names, not ids: the same human names you see in the dashboard, and the same strings you put in an instance's seg field. Compare on the name.

Example

curl
$ curl https://app.contentflow.click/api/v1/read/users/user_8812/segments \
     -H "X-CF-Read-Key: rk_live_4f2ab91c77d0e35b8a61c204"
200 OK
{
  "success": true,
  "data": {
    "userId": "user_8812",
    "segmentNames": ["All users", "Riyadh region"]
  },
  "metadata": { "timestamp": "2026-08-14T09:12:44.108Z" }
}

Two answers that look the same and are not

What you getWhat it means
404No profile exists for that identifier. The platform has never heard of it. Check the identifier before you check your segment rules.
200 with "segmentNames": []A known profile that is in zero segments. The identifier resolved fine, the rules simply did not match it. Check your segment rules, not your identifier.
401 UNAUTHORIZEDMissing X-CF-Read-Key, or Invalid read key.
! Do not collapse the 404 and the empty array into one branchThey send you to opposite halves of your setup. Writing if (!segments?.length) around both makes a wrong identifier and a non-matching rule indistinguishable, which is exactly the confusion this route exists to end.

Verify that targeting actually worked

After you call identify, call GET /api/v1/read/users/{id}/segments and assert the segment name you expected is in segmentNames. That is the CI-checkable proof that targeting landed, and it is a real assertion rather than a screenshot of a block that happened to render.

  1. Call /sdk/identify with the device id, consent, and the traits your segment targets on.
  2. Call GET /api/v1/read/users/{id}/segments with your read key.
  3. Assert the expected name is present, for example "Riyadh region". Compare on the name string, and compare it exactly.
! A check run immediately after identify can legitimately be staleSegment membership refreshes at most once every 60 seconds, so a read taken a moment after identify may not reflect it yet. That is the only timing guarantee there is: no faster number is supported, and none is promised. Give the assertion room for that interval, or retry it until the window has passed, rather than treating the first answer as final.
i Names, both endsWhat this route returns and what an instance's seg field holds are both segment names, so your assertion compares like with like and needs no id lookup. The rules behind those names are in the targeting reference.

Guide

Block payload shape

Every block, whether it came from sync or blocks/:key, has the same shape. Content and schema arrive as two separate members, values and fields. Understanding that split is the single most useful thing on this page.

block
{
  "key":        "discovery_card",
  "name":       "Discovery card",
  "screen":     "Home screen",
  "instanceId": "inst_7c31",
  "segment":    "high_value",
  "version":    7,

  // the content, keyed by tag
  "values": {
    "#header_image": "https://cdn.example.com/hero.png",
    "#title_main":   "Hello"
  },

  // the declared schema, one entry per field
  "fields": [
    { "tag": "#header_image", "type": "image" },
    { "tag": "#title_main",   "type": "text" }
  ]
}

values and fields, and why they are separate

values is a map from tag to content. fields is the block type's declared schema, one entry per field, carrying that field's type. They are joined by the tag.

The reason for the split is that it lets you render by declared type instead of guessing from the tag name. A tag called #header_image is a naming convention, and a marketer is free to rename it. Its type of image is a contract. Look the tag up in fields, switch on the type you find, then pull the content out of values. Do not infer that a field is an image because its tag ends in _image, and do not assume every value is a string.

MemberMeaning
keyThe block type key, the same one you pass to /sdk/blocks/:key.
nameHuman-readable block name, for your logs and debug screens.
screenWhich screen the block type belongs to, as labelled in the dashboard. May be absent.
instanceIdThe identifier you send back on every engagement event for this block.
segmentWhich audience this instance was targeted at, for debugging why a device sees what it sees. May be absent on an untargeted, global instance.
versionA number. The per-instance content version. See the warning below.
valuesObject. The content, keyed by field tag. Every entry is a scalar, except a collection, whose entry is an array of whole delivered blocks.
fieldsArray. The declared schema, { tag, type } per field. Schema only, it carries no content. A collection field additionally carries allowedBlockKeys, and minItems / maxItems when the block type set them.
! Two different things are both called versionThe version inside a block is a number, such as 7. It is that instance's content revision, and it is useful for logging, support, and telling two renders of the same block apart. The version at the top level of a sync or strings response is a string, such as W/"sync-ws_a1b2c3d4-3f9c1ab2", and it is the ETag validator. They are unrelated: never send a block's numeric version in If-None-Match, and never treat the response-level string as a per-instance revision counter.
! Address fields by tag, never by array indexOrder is not part of the contract, in fields or anywhere else. A marketer reordering fields in the dashboard, or a new field being added, will change positions without any version bump you can react to. Look fields up by their tag, for example #title_main, and render nothing when the tag is absent from values.
! Delivery keys values by tag; the write side keys them by field idEverything above describes what arrives on /sdk/*, where values is keyed by tag. If you also write instance content over the portal API, be aware that stored values are keyed by the field's id, a server-generated UUID, and not by its tag. So a system that both writes and reads has to keep a mapping between the two. Details, including the tag fallback and where the id comes from, are in Portal API differences.

Field types

The type comes from the matching entry in fields. The value it describes lives in values under the same tag.

TypeValue
textSingle-line string.
textareaMulti-line string.
imageA plain URL. Delivered stripped of any CSS url() wrapper. An empty string means render no image.
numberNumeric value.
selectOne value out of a fixed set defined on the block type.
toggleBoolean.
collectionAn array of whole delivered blocks, inlined. See Collections. The only non-scalar value in the payload.
i Render defensivelyUnknown field types will appear over time as new block types ship. Skip a type you do not handle rather than failing the whole block, and your client keeps working through changes you were never told about.
! A repeater field type was documented here once and never shippedIt nested an array of { id, values } items under a tag with its own fields sub-schema. That shape does not exist on the wire and never will. Its replacement is collections, below, which is shipped: a list is a wrapper holding real block instances rather than nested values.

Guide

Collections: a field whose value is a list of blocks

A collection field holds an ordered list of references to other block instances. The block that owns the field is the wrapper, for example a carousel; each referenced instance is a card. Delivery resolves the references and inlines each eligible card in full under the collection's tag, so there is no second request and no card model to learn.

A card is a block. It carries its own instanceId, its own segment, its own numeric version, its own values and its own fields, which is exactly why this shape exists: every card gets targeting, scheduling, approval, A/B variants and analytics because those already belong to an instance.

a wrapper as delivered
{
  "key":        "reading_carousel",
  "name":       "Reading carousel",
  "screen":     "Home screen",
  "instanceId": "carousel-7df3",
  "segment":    "All users",
  "version":    4,

  "values": {
    "#heading": "Recommended for you",

    // a collection value is an array of WHOLE BLOCKS
    "#slides": [
      {
        "key":        "reading_card",
        "name":       "Reading card",
        "screen":     "Home screen",
        "instanceId": "card-a12f",
        "segment":    "All users",
        "version":    6,
        "values": { "#title": "First article",
                    "#image": "https://cdn.example.com/first.jpg" },
        "fields": [ { "tag": "#title", "type": "text" },
                    { "tag": "#image", "type": "image" } ]
      }
    ]
  },

  "fields": [
    { "tag": "#heading", "type": "text" },
    { "tag": "#slides",  "type": "collection",
      "allowedBlockKeys": ["reading_card"], "minItems": 0, "maxItems": 20 }
  ]
}

A card is parsed by the same code that parses a top-level block, in every ContentFlow SDK and, we would suggest, in yours. Recursing your existing block parser one level is the whole integration.

Detecting a collection

Look the tag up in fields and check for type: "collection". Do not infer it from the value being an array, unless you are handling a payload with no schema at all: an absent or partial fields array is the only case where "an array carrying block-shaped entries" is a reasonable fallback, and that is precisely what the ContentFlow SDKs do.

allowedBlockKeys, minItems and maxItems on the schema entry are authoring constraints the server validates writes against. They are not a filter you are expected to apply, and the SDKs deliberately ignore them when decoding. An unrecognised card key should still decode as an ordinary block.

Which cards arrive, and in what order

RuleBehaviour
OrderThe wrapper's authoring order. Survivors of filtering keep their relative order. Never derived from priority, which only decides winner selection among competing instances and has no meaning inside a collection.
TargetingAn intersection. The wrapper is evaluated first, and if it is ineligible nothing under it is resolved. Each card is then evaluated independently. A card can narrow the wrapper's audience, never widen it.
LifecycleOnly deliverable instances are inlined. Draft, in-review, paused, disabled and frozen cards are omitted, as is any card whose on / enabled toggle is false. A _test key also receives cards in test status, exactly as at the top level.
Empty wrapperA wrapper whose collections resolve to zero cards is omitted from blocks altogether. Fixed behaviour, not configurable, so no client renders list chrome around nothing.
Broken referenceSkipped, never fatal. A missing instance, a missing definition, or an instance whose key the field does not allow is dropped with a server-side diagnostic and the sync still succeeds.
NestingDepth is one. A card may not itself hold a collection, and one that somehow does is skipped rather than inlined. Cap your own parser at one level too.
! An inlined card is suppressed from the top level, but only when it was really deliveredA card that arrived inside a wrapper is removed from the top-level blocks array, so you never receive the same content twice. The suppression is computed from what was actually emitted, not from the reference graph, which means a card whose wrapper was filtered out, paused, or omitted for having no eligible cards still arrives at the top level. Content does not vanish because of a wrapper you never got. A card inlined by two delivered wrappers appears in both, with the same instanceId in each.

Caching

The /sdk/sync validator is a SHA-256 digest over the entire delivered block array, computed recursively after resolution, so it already covers every inlined card's instanceId, numeric version, localized values and field schema, and the collection order that survived filtering. Editing a card, reordering slides, or a card becoming eligible or ineligible therefore moves the validator even though no wrapper was edited. Nothing changes for your client: store the validator, echo it back on If-None-Match, treat it as opaque. See Caching and versioning.

Events

Track a card exactly as you track any block: send the card's own instanceId to /sdk/events. The metric owner is the card, never the wrapper, so a card reused in three carousels reports against itself in all three. Send the wrapper's instance id, the collection tag and the card's position as extra dimensions if you want a placement breakdown, but they are context, not ownership.

Authoring constraints, for the write side

These apply to the management API rather than to /sdk/*, but they explain what you will and will not see on the wire.

  • A collection stores an ordered array of instance id strings. Duplicates and self-references are rejected. allowedBlockKeys is required and is matched against immutable definition keys, never display names.
  • 50 references per collection is the platform hard limit, applied even when maxItems is omitted. A stricter maxItems wins.
  • minItems bounds what an author may save. It is not a promise about how many cards a device receives after filtering.
  • Deleting a referenced instance answers 409 with code: "instance_referenced" and a references array of { wrapperInstanceId, fieldTag }. Archiving a referenced block definition answers 409 with the same code and a references array of { wrapperBlockKey, fieldTag }. Pausing and disabling stay available, because delivery already treats a paused card as absent.
  • A definition change that would strand live references, such as dropping a key from allowedBlockKeys or re-keying a collection field, is rejected with 400 rather than orphaning them.
  • Payload size is enforced at publication, not at delivery: a wrapper that would exceed the sync payload limit, measured at its worst case across every stored reference and every locale, is rejected with 400 and code: "collection_payload_too_large". A runtime backstop truncates trailing cards if an oversized wrapper reaches delivery anyway, so a device is never handed an unbounded body.

The block-type side of this, including how to register a wrapper and a card, is on Blocks & fields.


Guide

Portal API differences

Everything else on this page is the delivery API, the /sdk/* surface your app calls with a publishable key. The portal API is the management surface the dashboard itself uses: JWT-authenticated, and where content is created and edited rather than read. Most integrators never touch it. If you are seeding content from a CMS, a build script, or a migration, you will.

They share a base URL and share almost nothing else. Three differences have each cost somebody a debugging session.

1. The portal API double-wraps its envelope

/sdk/* wraps once: your payload is at data. The portal API wraps twice: the gateway envelope carries the downstream service's envelope, so your payload is at data.data.

same host, two conventions
// GET /api/v1/sdk/sync            single wrap
{ "success": true, "data": { "blocks": [  ] } }

// GET /api/v1/blocks              DOUBLE wrap
{ "success": true, "data": { "success": true, "data": [  ] } }

Nothing in the URL warns you which one you are about to get. Do not share one response parser between the two surfaces without a deliberate unwrap step. A read of the form (body.data.data ?? body.data) is correct against both, and is what we would write ourselves.

2. Updating an instance is PUT. PATCH returns 404

There is no PATCH route for a block instance. PATCH /api/v1/blocks/:key/instances/:id is not a rejected update, it is a route that does not exist, so it answers 404, which reads exactly like a wrong instance id and sends you looking in the wrong place. Use PUT.

i The PUT is a partial update, despite the verbThe handler applies each of title, internalName, seg, status, val, priority, schedule, i18n and ab only when the request body actually carries that member, so a body of {"seg": "Riyadh region"} changes the targeting and leaves everything else exactly as it was. You do not have to read the instance back and resend it. Two things are not partial in the way you might expect: val and i18n are each replaced wholesale when present, so send the whole map for whichever of the two you are changing, and a body containing name is rejected with a 400 rather than ignored, because the instance field is title.
IntentMethod and path
Update an instancePUT /api/v1/blocks/:key/instances/:id. Partial: send only the members you are changing.
Change only its statusPATCH /api/v1/blocks/:key/instances/:id/status. The one PATCH that exists, and it is on the /status sub-path, not on the instance.
Publish, pause, disable, enablePOST to the matching sub-path, for example /instances/:id/publish.
! A 404 from a write is worth reading twiceOn this surface a 404 is at least as likely to mean "wrong method" as "wrong id". Check the verb before you check the id.

3. Values are written by field id and read back by tag

This asymmetry is real, it is not going to be papered over silently, and an integrator that both writes and reads has to hold a mapping.

DirectionKey
Write, portal API, an instance's stored valuesThe field's id, as declared in fields on the block definition. Per-locale i18n overrides are keyed the same way.
Read, /sdk/* deliveryThe field's tag, for example #title_main. Field ids never appear in a delivered payload.

An id is not derived from the tag, and it is not guessable when you did not choose it: register a block type without supplying an id for a field and the server generates a UUID for it. So fetch the block definition, build a tag → id map from its fields, and key your writes with that. Re-read the definition when a marketer may have added a field, because a new field brings a new id.

i Give your fields readable ids when you register the typeThe id is yours to choose, and choosing it is the cheapest way to make this asymmetry survivable: { "id": "title", "tag": "#title_main" } gives you a mapping you can read in a diff. Let the server assign UUIDs and every write in your codebase is keyed by something meaningless that you have to look up at runtime.
i Tag-keyed values are accepted as a fallback, and you should still not rely on itDelivery looks a value up by field id first and falls back to the tag when the id key is absent or empty. That is why a tag-keyed write often appears to work. The trap is a value written under both keys, which happens the moment the dashboard edits an instance your script created: the dashboard writes by id, the id copy wins, and your tag-keyed write becomes invisible without an error anywhere. Write by id.

The block definition shape, including where fields[].id appears, is on Blocks & fields.


Guide

Consent and PDPL

Consent is enforced on our servers, not in the client, and it defaults to false. You do not have to trust an SDK to behave, and you do not have to build the enforcement yourself.

  • Consent defaults to false. A device that has never granted consent is treated as unconsented, and nothing you send changes that except an explicit grant.
  • Content still syncs while consent is false. Your app is not broken for a user who declined analytics. Blocks, strings, and locales are all delivered normally.
  • Only analytics events are dropped, and they are dropped server side. A client that keeps sending events while unconsented cannot cause a recording.

Two different consents, on purpose, in two different places

ConsentQuestion it answersSet viaCovers
Analytics, device-levelMay we record your behaviour?The consent boolean on POST /sdk/identifyWhether behavioural events are recorded at all. Nothing else.
Messaging, per-channelMay we contact you on X?POST /sdk/consentpush, sms, whatsapp, email, locationTracking, marketing. Nothing else.

They are independent because they answer different questions. A user can be happy to be measured but refuse SMS, or accept push notifications while declining analytics. Collapsing the two into one switch would make one of those users impossible to represent honestly.

! The endpoint named /sdk/consent is the messaging oneThe naming is a genuine trap and it has caught a customer. The one endpoint with "consent" in its path handles only messaging channels. Analytics consent is a boolean field on a differently-named endpoint. If you build a single "Privacy" screen, it has to write to both places, and neither call reports anything about the other.
i The third option is to grant neitherAnonymous, content-only mode sends no device id and never calls identify, so there is no consent decision to record and nothing to keep a record of. Content still arrives. If your privacy posture is "collect nothing", that is the mode, not a consent flag set to false.
i What this buys youUnder PDPL and comparable regimes the burden is on you to show that unconsented behaviour was not recorded. Because the gate is on our side, that is a property of the platform rather than a claim about your client code, and it holds even for an old app version you cannot re-release.

Erasure is a separate obligation, and a separate route

Consent answers what you may collect. It says nothing about deleting what you already have. That is DELETE /sdk/identify, which erases one device and the person-level records behind it, and reports part by part what it managed to remove.

Three things about it belong in a compliance answer rather than in an integration guide, so read the full section before you write one:

  • It erases the device you name and the shared profile, but not that person's other devices. Erasing a person means listing their devices on the read surface and calling the route once per device.
  • Some records are not swept by it, including notification records carrying contact details, which age out on a 30 day retention window instead. The full list of what survives is in that section.
  • The erasure is recorded, not enforced. A later identify or track-event with the same identifier creates a fresh profile. Nothing suppresses the identifier afterwards, so staying erased is something your own client has to do by not sending it again.

Guide

The route map: every route, and the credential that opens it

The endpoints with a full section on this page cover what an app normally needs, but they are not the whole delivery surface: /sdk/* holds eighteen routes. A full integration usually also touches a route or two from the lists below, so here they all are in one place. Paths are relative to https://app.contentflow.click/api/v1.

Delivery, credential X-CF-Key

Eighteen routes. Ten have a full request and response contract on this page, linked below. The other eight are listed here with their method, path and credential, and nothing more, because their shapes are not documented on this page yet: listing them without inventing a contract is the honest version.

! Count method plus path, not pathsPOST /sdk/identify and DELETE /sdk/identify are two different endpoints on one path: one upserts a device, the other erases it. Any count that tallies distinct paths rather than method-plus-path pairs will undercount this surface no matter how carefully it is done. The page previously said nine, which was the count of the nine sections it happened to carry.
MethodPathCredentialDocumented here
POST/sdk/identifyX-CF-KeyFull contract.
DELETE/sdk/identifyX-CF-KeyFull contract. Same path as the POST, different endpoint.
POST/sdk/register-pushX-CF-KeyFull contract.
GET/sdk/syncX-CF-KeyFull contract.
GET/sdk/blocks/:keyX-CF-KeyFull contract.
GET/sdk/stringsX-CF-KeyFull contract.
POST/sdk/eventsX-CF-KeyFull contract.
POST/sdk/track-eventX-CF-KeyFull contract.
POST/sdk/consentX-CF-KeyFull contract.
GET/sdk/streamX-CF-Key, or ?key= on this route onlyFull contract.
GET/sdk/localesX-CF-KeyListed only. Request and response shapes are not documented on this page.
POST/sdk/campaignsX-CF-KeyListed only. Request and response shapes are not documented on this page.
GET/sdk/consent/config/:presetX-CF-KeyListed only. Request and response shapes are not documented on this page.
POST/sdk/consent/fullX-CF-KeyListed only. Request and response shapes are not documented on this page.
POST/sdk/push/topicsX-CF-KeyListed only. Request and response shapes are not documented on this page.
GET/sdk/push/topicsX-CF-KeyListed only. Request and response shapes are not documented on this page.
PATCH/sdk/push/preferencesX-CF-KeyListed only. Request and response shapes are not documented on this page.
GET/sdk/public-statsNone. See below.Listed only. Request and response shapes are not documented on this page.
i Every route above authenticates the same way, with one exceptionSeventeen of the eighteen take the SDK key exactly as the documented ones do, in X-CF-Key or its alias X-API-Key, and resolve their workspace from it. GET /sdk/public-stats is the exception: it takes no credential at all and is not workspace-scoped, so nothing about it is bound to your key or your workspace.
! DELETE /sdk/register-push is not available at the gatewayIt exists inside the delivery service, but it is not exposed on the public gateway, so calling DELETE /api/v1/sdk/register-push returns a framework 404, the same answer as any unmatched path. Do not build against it. To remove a device's push token today, the route that exists is DELETE /sdk/identify, which removes the whole device record along with it.

Write, credential X-CF-Write-Key

The write key opens exactly three routes and no others. All three authenticate on the key alone: no portal JWT, no X-Tenant-Id, because the workspace comes from the key.

MethodPathCredentialPurpose
POST/cards/registerX-CF-Write-KeyUpsert one block or card definition. By default it also seeds one blank starter draft instance for that definition.
POST/cards/syncX-CF-Write-KeyReconcile the workspace against a declarative manifest of many definitions. It can optionally carry instance content in the same call, and it archives every active definition the manifest does not name. See the warning below.
POST/strings/syncX-CF-Write-KeyPush translation strings into the workspace from a server or a build step.

Which cards route to use, and the one that archives

! This page had these two backwardsAn earlier version of this section described /cards/sync as pushing card content and /cards/register as registering the types that content is written against, which inverts the relationship. Both routes write block and card types, that is, definitions. Neither is a content-only endpoint. The real difference is shape and scope: register upserts one definition imperatively, sync reconciles many definitions declaratively. Neither is a prerequisite for the other, because a manifest entry creates its own type.
POST /cards/registerPOST /cards/sync
ShapeImperative. One definition per call.Declarative. A manifest of many definitions, describing the state the workspace should be in.
Writes definitionsYes, one.Yes, all of them.
Writes instance contentSeeds one blank starter draft instance per definition, by default.Optionally, carried in the same call.
Effect on definitions you did not nameNone. Everything else is left alone.Archives them. Every currently active definition the manifest does not name is archived.
Empty inputNot applicable.Refused, unless the body carries "prune": true, which then archives everything.
! /cards/sync archives whatever the manifest leaves out, so treat it as the whole workspaceThis is the behaviour to understand before you put the route in a CI job, because a partial manifest does not mean a partial update. It means "these are the only definitions that should be active", and everything else is archived. A pipeline that syncs one team's manifest on every push will archive the definitions owned by every other team, on every push, with a successful build. Either send a manifest that names every definition the workspace should keep, or use POST /cards/register, which touches only the one definition you named. An empty manifest is refused rather than silently archiving everything, and "prune": true is the explicit opt-in that makes it archive everything on purpose.
i POST /blocks and POST /cards/register are two doors to the same tableThey are not two names for one route. POST /blocks takes a portal JWT with the admin or editor role; POST /cards/register takes the write key. They differ in behaviour too: POST /blocks does not seed an instance and does not enforce the key pattern, while POST /cards/register requires the block key to match ^[a-z0-9_]+$ and does seed one. Pick by which credential your caller holds, and expect the seeded instance when you go through the write key.

Read, credential X-CF-Read-Key

MethodPathCredentialPurpose
GET/read/users/{userId}/devicesX-CF-Read-KeyEvery device known for one identifier.
GET/read/devices/{deviceId}X-CF-Read-KeyOne device and the profile attached to it.
GET/read/users/{userId}/segmentsX-CF-Read-KeyThe segment names one identifier resolves to.

Management, credential portal JWT

These take Authorization: Bearer <portal JWT> together with X-Tenant-Id, and every one of them is gated to the admin or editor role, with a 403 FORBIDDEN_ROLE otherwise. They double-wrap their envelope, so read from data.data. See Portal API differences for the envelope, and where a portal JWT comes from for how to obtain one, how long it lasts, and why there is no service account for this surface.

MethodPathCredentialPurpose
GET/settings/write-keyPortal JWT, admin or editorFetch the workspace's write key.
GET/settings/read-keyPortal JWT, admin or editorFetch the workspace's read key.
POST/settings/read-key/rotatePortal JWT, admin or editorReplace the read key. Immediate, no grace period, and no dashboard control exists for it. See the read surface.
POST/blocksPortal JWT, admin or editorRegister a block type.
PUT/blocks/:key/instances/:idPortal JWT, admin or editorUpdate a block instance. Partial update, including the targeting field. See below.
POST/segmentsPortal JWT, admin or editorCreate a segment.

Setting an instance's targeting

PUT /api/v1/blocks/:key/instances/:id takes a partial update, and the field that sets targeting is seg: a single segment name string, the same name the read surface returns.

targeting an instance
// aim this instance at one segment, by name
{ "seg": "Riyadh region" }

// make it untargeted again: either of these
{ "seg": null }
{ "seg": "" }

The full targeting rules, including how a name is matched and what happens when several instances compete, are in the targeting reference.

! Do not point an integrator at the retired host for any of thisEvery path above is relative to app.contentflow.click/api/v1. The retired api.contentflow.click host still answers, so a wrong base URL fails silently here exactly as it does on delivery.

Guide

Writing your own client

One required header, one envelope, plus a separate write surface on X-CF-Write-Key and a separate read surface on X-CF-Read-Key that an app never calls. A normal app touches a handful of the eighteen /sdk/* routes and nothing else; all eighteen, and every other route this page knows about, are in the route map. Before you write anything, decide which of two clients you are building, because the answer changes what you collect and what you have to declare.

Decide first: content-only, or personalized

Content-onlyPersonalized
You wantCopy and imagery you can change without an app release.That, plus segment targeting, engagement analytics, or messaging.
You sendX-CF-Key. That is the whole client.A key, a persisted device id, an identify call, and consent decisions.
You store about a userNothing.A device identifier, traits, consent state.
Start hereAnonymous, content-only modeThe order of operations below.

Content-only is not a degraded mode or a trial tier, and you are not locked into it: identify can be added later without changing anything else. Choose it if it covers what you need, and read the tradeoffs before you decide it does not.

Order of operations, personalized client

  1. Generate and persist a device id, once, on first launch. See below. A content-only client skips this step and every step that depends on it.
  2. Identify. Call /sdk/identify with the device id, the user's consent decision, and any traits you target on. This is what resolves the device's segments.
  3. Then sync. Call /sdk/sync and store both the payload and its version. Syncing before identify is legal and returns real content, but it returns the untargeted view, so if you meant to target, do this in order rather than debugging an unexpectedly plain screen later.
  4. Then send events. Post engagement to /sdk/events in batches, using the instanceId from the blocks you rendered.
  5. Optionally, subscribe. Open /sdk/stream and re-sync when a block.* event arrives. Polling with If-None-Match is a valid alternative.

Persisting the device id

This section applies only to a personalized client. If you are building content-only, there is no device id to persist, which is the point.

The device id is the identity everything else hangs off: segments, consent state, and the push token are all keyed to it. Getting this wrong is the most expensive mistake available on this page, because it silently fragments one user into many.

  • Generate a UUID on first launch. Do not derive it from a hardware identifier, an advertising id, or anything the OS may rotate or refuse you.
  • Store it where it survives app restarts and updates: Keychain on iOS, SharedPreferences or DataStore on Android, localStorage on web.
  • Send the same value as X-CF-Device on every call, forever, including calls made before the user logs in.
  • Never regenerate it per launch, per session, or on logout. A fresh id is a fresh anonymous device with no consent and no segments.
✓ A correct minimal clientContent-only: send X-CF-Key, sync with If-None-Match, read values by tag and switch on the type from fields. Personalized: add a persisted device id, identify on launch, and batched events. Everything else on this page is refinement.

Checklist before you ship

CheckWhy
Base URL is app.contentflow.click/api/v1, verified by the response being envelopedThe retired api. host still answers 200 with frozen content and never errors.
You send a device id only because you decided toThe reads do not need one. A persistent identifier you did not have to collect is one you have to declare.
Device id survives an app update, if you send one at allOtherwise every update resets consent and segments.
Analytics consent written to identify, messaging consent written to /sdk/consentThey are separate. Writing one does nothing to the other, and both answer 200.
Envelope read from data on /sdk/*, and from data.data on the portal APISame host, two conventions.
304 handled as "keep cache"The body is empty by design, not truncated.
Locale sent as ?locale= or Accept-Language?lang= and X-CF-Locale are ignored.
Content read from values by tag, type read from fieldsArray order is not a contract, and a tag name is not a type.
A collection value parsed by recursing your block parser one level, keyed on each card's instanceIdA card is a whole block. Position is never identity, and nesting stops at one level.
Block version treated as a number, not the ETagTwo different things share the name version.
accepted checked on event postsA consent-dropped batch still answers 200.
No X-CF-Read-Key or X-CF-Write-Key anywhere in the shipped binary or bundleBoth are server-side and CI credentials. The publishable one is X-CF-Key. See the route map.
Targeting asserted in CI against /read/users/{id}/segments, allowing for the 60 second refreshIt is the only mechanical proof that a segment landed, rather than a screenshot of a block that happened to render.
Back to docs Read the wiki