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.
/sdk/*, three under /read/*./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.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.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.Anonymous mode
Fetch content with no device id, no identify, and no stored profile.
Authentication
One required header, two optional ones, and the key suffix that picks live or test.
Writing a client
Order of operations, device-id persistence, cache handling.
Base URL
Every endpoint on this page is relative to a single base:
https://app.contentflow.click/api/v1| Host | Status | Behaviour |
|---|---|---|
app.contentflow.click/api/v1 | Supported | The delivery gateway. Use this. |
api.contentflow.click/v1 | Retired, still running | Not 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 host | What comes back |
|---|---|
The bare root, /v1 | 404. 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 key | 401 {"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_… generation | 200 with real block data, in the old unenveloped format. No error of any kind. The content is stale and frozen. |
// 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.
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.
| Header | Required | Meaning |
|---|---|---|
X-CF-Key | Yes, 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-Id | No on /sdk/*. Yes on portal JWT calls | The 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-Device | No | A 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-Key | Yes, on the write surface | Shaped 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-Key | Yes, 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.
/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.
| Alias | Status | Exact scope |
|---|---|---|
X-API-Key request header | Supported 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 parameter | Supported 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. |
{
"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."
}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:
- The
X-CF-Keyrequest header. - Otherwise the
X-API-Keyrequest header. - Otherwise the
?key=query parameter, which is only accepted onGET /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.
// 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" } }
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.
| Key | Environment | Sees |
|---|---|---|
ws_a1b2c3d4_app | live | Live content only. |
ws_a1b2c3d4_test | test | Live content plus content staged for test. |
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.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:
- A trailing
_appis stripped, provided there is at least one character in front of it. - Otherwise a trailing
_testis stripped, provided there is at least one character in front of it. - 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.
{
"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 sent | Workspace id parsed out of it | Answer |
|---|---|---|
ws_a1b2c3d4_app | ws_a1b2c3d4 | Normal lookup, then whatever that workspace holds. |
cf_live_xxx | cf_live | 404 Tenant not found: cf_live |
d5a_d5b_d5c | d5a_d5b | 404 Tenant not found: d5a_d5b. The last underscore is the split point, not the first. |
d5aaa_app_app | d5aaa_app | 404 Tenant not found: d5aaa_app. One suffix is stripped, not both. |
__app | _ | 404 Tenant not found: _. A single underscore is a parseable name. |
_app | None | 401 INVALID_SDK_KEY |
_test | None | 401 INVALID_SDK_KEY |
_ | None | 401 INVALID_SDK_KEY |
nounderscore | None | 401 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.$ 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.
| Property | Value |
|---|---|
| Issued by | POST /api/v1/auth/login, with a JSON body of {"email": "…", "password": "…"}. |
| Workspace id needed to log in | None. The response tells you which workspace the token is for, so you do not have to know it in advance. |
| Sent as | Authorization: Bearer <token> together with X-Tenant-Id. Both are required on portal routes. |
| Lifetime | 1 day. It is not configurable per workspace. |
| Renewal | Call POST /api/v1/auth/login again. See the note on refresh below. |
| Roles it can carry | Exactly 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:
{
"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
// 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
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:
{
"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 code | When |
|---|---|
401 INVALID_CREDENTIALS | The email and password did not match, or X-Tenant-Id disagrees with the token you sent. |
403 EMAIL_NOT_VERIFIED | The account has not confirmed its email address yet. |
403 | The workspace is suspended. |
403 FORBIDDEN_ROLE | The 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 token | Not a refusal. Two-step verification is enabled on the account, see above. |
There is no service account for this surface
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.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:
| Call | With only X-CF-Key |
|---|---|
GET /sdk/sync | 200 with every live block that is not segment-restricted. |
GET /sdk/blocks/:key | 200 with that block, resolved the same way. |
GET /sdk/strings | 200 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. |
$ 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 up | You 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. |
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.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.
// 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.
/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./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.
- Every read returns an
ETagresponse header holding a weak validator, in theW/"…"form. - The same value is repeated as the
versionfield inside the response body, so you can persist it alongside the cached payload without reading headers. - Send it back as
If-None-Matchon the next read. If nothing changed you get304 Not Modifiedwith 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.
| Read | Validator |
|---|---|
/sdk/sync | One 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/strings | A separate value covering the string catalog for the requested locale. |
/sdk/blocks/:key | A per-block value, distinct per key and locale. |
$ 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"
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.
| Mechanism | Read? | Notes |
|---|---|---|
?locale=ar query parameter | Yes | Highest precedence. Wins over the header. |
Accept-Language request header | Yes | The standard header. The first subtag wins: ar-SA,ar;q=0.9 resolves to ar. |
?lang=ar query parameter | No | Ignored. Not an alias for ?locale=. |
X-CF-Locale request header | No | Ignored. 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 -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.
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.
| Status | Code | When |
|---|---|---|
400 | Malformed 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. | |
401 | MISSING_SDK_KEY | No SDK key was presented at all: neither X-CF-Key nor its alias X-API-Key was sent. |
401 | INVALID_SDK_KEY | No 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. |
401 | SDK_KEY_IN_QUERY | A ?key= query parameter was sent to something other than GET /sdk/stream. See the alias table for why that one route is the exception. |
401 | UNAUTHORIZED | On the read surface only: X-CF-Read-Key was missing, or the key presented was not a valid read key. |
403 | TENANT_KEY_MISMATCH | The 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. |
404 | TENANT_NOT_FOUND | The 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. |
404 | NOT_FOUND | The 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. |
404 | Unknown block key on /sdk/blocks/:key. |
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.HTTP/1.1 404 Not Found
{
"success": false,
"error": "unknown block"
}Identify a device or user
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.
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
| Field | Type | Notes |
|---|---|---|
consent | boolean | Optional. 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. |
traits | object | Optional. 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. |
userId | string | Optional. Your stable user id. Without it the device stays anonymous presence rather than a known profile. |
platform | string | Optional. ios, android, or web. |
deviceId | string | Optional. Overrides the X-CF-Device header for this call. Prefer the header. |
Example
$ 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 } }'
{
"success": true,
"data": {
"deviceId": "dev_9f2c41",
"segments": ["All users", "Riyadh region", "New users (< 30 days)"],
"consent": true
}
}Erase everything stored for one device
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
| Part | Value |
|---|---|
| Credential | The 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-Device | Required. Names the device to erase. This is the only way to name it. |
X-Tenant-Id | Optional 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 body | None. There is no body to send. |
| Query parameters | None. |
| Path parameter | None. The path is exactly /sdk/identify, with no id on the end. |
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
$ 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
{
"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.
| Member | Meaning |
|---|---|
deviceId | The device you named, echoed back. |
status | deleted, partial, or failed. See the status table below. |
localWipeSafe | Boolean. The one field your client branches on. See the client rule below. |
parts | Object, 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. |
remaining | Array. The parts that were not erased. Empty on a full success. |
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
| Status | status | localWipeSafe | Meaning |
|---|---|---|---|
200 | deleted | true | Every part was erased. remaining is empty. |
207 | partial | false | Some 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. |
502 | failed | false | Nothing was erased. |
400 | No X-CF-Device header was sent. Body: {"success":false,"error":"the X-CF-Device header is required to identify which device to erase"}. |
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.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 call | What comes back |
|---|---|
| The same device a second time | 200, with every part applied: true and deleted: 0. Retrying is safe and costs you nothing. |
| A device that never existed | The 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 workspace | Unreachable. 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:
| Part | What goes |
|---|---|
device | The 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. |
profile | The person-level profile: custom attributes, email, phone, and materialized segment memberships. |
kyc | KYC verification records. |
locationHistory | Location history batches. |
geofenceEvents | Geofence event records. |
enrichment | Enrichment records. |
events | Analytics 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}/devicesand 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.
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
207is not a dead end. Keep the device id, retry, and usependingSinceto see how long the outstanding parts have been outstanding.
Consent and the two separate consent decisions are covered in Consent and PDPL.
Register a push token
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
| Field | Type | Notes |
|---|---|---|
token | string | Required. The raw OS token. Missing or blank is rejected with 400. |
platform | string | Optional. ios, android, or web. |
provider | string | Optional. apns or fcm. When omitted it is derived from platform: ios gives apns, android gives fcm. |
userId | string | Optional. Only send it when you know it. Omitting it never unlinks a device that identify already linked. |
deviceId | string | Optional. Overrides the X-CF-Device header for this call. |
Example
$ 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" }'
{
"success": true,
"data": {
"deviceId": "dev_9f2c41",
"userId": "user_8812",
"registered": true,
"provider": "apns"
}
}push flag in per-channel consent.Sync every live block
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.
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
| Parameter | Type | Notes |
|---|---|---|
locale | string | Optional. 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 "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"
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.
Resolve one block by key
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
| Parameter | In | Notes |
|---|---|---|
key | path | Required. The block type key, for example home_hero. |
locale | query | Optional. Same rules as sync. |
Example
$ 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.
{ "success": false, "error": "unknown block" }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.Fetch approved translations
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.
X-CF-Device is ignored, and identify is irrelevant to it. A key is the whole request. See anonymous, content-only mode.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.Query parameters
| Parameter | Type | Notes |
|---|---|---|
locale | string | Optional. See locale negotiation. |
namespace | string | Optional. Restrict the catalog to a single namespace. |
Example
$ 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"
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\""
}
}smartKeys 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.Send engagement events
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
| Type | Send when |
|---|---|
impression | A block became visible to the user. |
tap | The user tapped the block itself. |
cta_click | The user activated the block's call to action. |
dismiss | The user closed or dismissed the block. |
conversion | The user completed the outcome the block was asking for. |
Request body
| Field | Type | Notes |
|---|---|---|
events | array | Required. Each entry carries a type from the table above and the instanceId of the block it refers to. |
context | object | Optional, sent once per batch rather than per event. Carries session and device context such as sessionId and platform. |
Example
$ 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" } }'
{
"success": true,
"data": { "accepted": 2, "dropped": 0 }
}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.Send identity events
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
| Field | Type | Notes |
|---|---|---|
username | string | Required. Your stable user identifier. Missing it is a 400. |
eventType | string | sign_up, sign_in, or custom. |
fullName | string | Optional profile field. |
email | string | Optional profile field. |
phone | string | Optional profile field. Required on the profile before SMS or WhatsApp can reach the user. |
device | object | Optional. deviceId, model, osType, appVersion. |
location | object | Optional. lat, lng, region, country. |
custom | object | Optional. Your own attributes, merged into the profile. |
Example
$ 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" } }'
{
"success": true,
"data": {
"deviceId": "dev_9f2c41",
"segments": ["All users", "Riyadh region"],
"consent": true
}
}/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.Set per-channel consent
Records what the user actually agreed to, channel by channel. This is the endpoint behind your notification-preferences screen. It is deliberately independent of the device-level consent flag on identify, see consent and PDPL for why.
/sdk/consent manages messaging channels only: push, sms, whatsapp, email, locationTracking, marketing. It answers "may we contact you on X". Analytics consent is a different thing in a different place: the consent boolean on POST /sdk/identify. It answers "may we record your behaviour". Posting {"channels": {...}} here does not grant, revoke, or affect analytics consent in any way, and posting consent: false to identify does not turn off a single messaging channel. A customer lost real time to that assumption. Wiring your analytics toggle to this endpoint will silently record nothing, with a 200 every time.Request body
| Field | Type | Notes |
|---|---|---|
username | string | Required. Channel consent is stored on the user profile, not the anonymous device. |
channels | object | Required. Booleans keyed by channel. At least one recognised channel must be present, otherwise 400. Send only the channels the user actually changed. |
| Channel | Grants permission to |
|---|---|
push | Send OS push notifications. |
sms | Send SMS. |
whatsapp | Send WhatsApp messages. |
email | Send email. |
locationTracking | Use location signals. |
marketing | Send marketing content as opposed to transactional messages. |
Example
$ curl -X POST https://app.contentflow.click/api/v1/sdk/consent \ -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", "channels": { "push": true, "sms": false, "whatsapp": false, "email": true, "locationTracking": false, "marketing": true } }'
{
"success": true,
"data": {
"deviceId": "dev_9f2c41",
"username": "user_8812",
"channels": { "push": true, "sms": false, "email": true, "marketing": true }
}
}Live updates over SSE
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 -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"
: 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.
/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.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.
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.
| Property | Value |
|---|---|
| Header | X-CF-Read-Key |
| Format | rk_live_ followed by 24 lowercase hex characters, for example rk_live_4f2ab91c77d0e35b8a61c204. |
| Scope | Read only. It cannot write anything, and a write key will not validate on this surface. |
| Where to get it | Dashboard, 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 limit | Not 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 sent | What comes back |
|---|---|
No X-CF-Read-Key header at all | 401 {"message":"Missing X-CF-Read-Key","code":"UNAUTHORIZED"} |
| A header holding a key this workspace does not recognise | 401 {"message":"Invalid read key","code":"UNAUTHORIZED"} |
X-CF-Key, and it is a different key with a different job.POST /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.
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
{
"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.
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.
List every device behind 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
| Part | Value |
|---|---|
| Header | X-CF-Read-Key. Required, and the only header this call needs. |
userId | Path segment. Required. Your user id, or a device id. See what goes in the slot. |
Response
Member of data | Meaning |
|---|---|
userId | String. Echoed back exactly as you sent it, so a response is self-describing in a log. |
devices | Array. 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 https://app.contentflow.click/api/v1/read/users/user_8812/devices \ -H "X-CF-Read-Key: rk_live_4f2ab91c77d0e35b8a61c204"
{
"success": true,
"data": {
"userId": "user_8812",
"devices": [ /* one device record each, tokens stripped */ ]
},
"metadata": { "timestamp": "2026-08-14T09:12:44.108Z" }
}Errors
| Status | When |
|---|---|
401 UNAUTHORIZED | Missing 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. |
404 | You dropped the /devices suffix. /api/v1/read/users/{id} on its own is not a route. |
Fetch one device and its profile
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
| Part | Value |
|---|---|
| Header | X-CF-Read-Key. Required, and the only header this call needs. |
deviceId | Path segment. Required. The same value your client sends as X-CF-Device. |
Response
Member of data | Meaning |
|---|---|
device | Object. The device record, with push and device tokens stripped out. |
profile | Object or null. The profile attached to the device: customAttributes, consent, and appVersionSnapshot. |
Example
$ curl https://app.contentflow.click/api/v1/read/devices/dev_9f2c41 \ -H "X-CF-Read-Key: rk_live_4f2ab91c77d0e35b8a61c204"
{
"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
| Status | When |
|---|---|
401 UNAUTHORIZED | Missing X-CF-Read-Key, or Invalid read key. |
404 | Device not found. No device is stored under that id in this workspace. |
200 with profile: null | Not 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.Read the segments an 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
| Part | Value |
|---|---|
| Header | X-CF-Read-Key. Required, and the only header this call needs. |
userId | Path segment. Required. Your user id, or a device id. |
Response
Member of data | Meaning |
|---|---|
userId | String. Echoed back as you sent it. |
segmentNames | Array 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 https://app.contentflow.click/api/v1/read/users/user_8812/segments \ -H "X-CF-Read-Key: rk_live_4f2ab91c77d0e35b8a61c204"
{
"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 get | What it means |
|---|---|
404 | No 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 UNAUTHORIZED | Missing X-CF-Read-Key, or Invalid read key. |
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.
- Call /sdk/identify with the device id, consent, and the traits your segment targets on.
- Call
GET /api/v1/read/users/{id}/segmentswith your read key. - Assert the expected name is present, for example
"Riyadh region". Compare on the name string, and compare it exactly.
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.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.
{
"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.
| Member | Meaning |
|---|---|
key | The block type key, the same one you pass to /sdk/blocks/:key. |
name | Human-readable block name, for your logs and debug screens. |
screen | Which screen the block type belongs to, as labelled in the dashboard. May be absent. |
instanceId | The identifier you send back on every engagement event for this block. |
segment | Which audience this instance was targeted at, for debugging why a device sees what it sees. May be absent on an untargeted, global instance. |
version | A number. The per-instance content version. See the warning below. |
values | Object. The content, keyed by field tag. Every entry is a scalar, except a collection, whose entry is an array of whole delivered blocks. |
fields | Array. 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. |
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.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./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.
| Type | Value |
|---|---|
text | Single-line string. |
textarea | Multi-line string. |
image | A plain URL. Delivered stripped of any CSS url() wrapper. An empty string means render no image. |
number | Numeric value. |
select | One value out of a fixed set defined on the block type. |
toggle | Boolean. |
collection | An array of whole delivered blocks, inlined. See Collections. The only non-scalar value in the payload. |
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.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.
{
"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
| Rule | Behaviour |
|---|---|
| Order | The 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. |
| Targeting | An 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. |
| Lifecycle | Only 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 wrapper | A 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 reference | Skipped, 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. |
| Nesting | Depth 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. |
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.
allowedBlockKeysis required and is matched against immutable definition keys, never display names. - 50 references per collection is the platform hard limit, applied even when
maxItemsis omitted. A strictermaxItemswins. minItemsbounds what an author may save. It is not a promise about how many cards a device receives after filtering.- Deleting a referenced instance answers
409withcode: "instance_referenced"and areferencesarray of{ wrapperInstanceId, fieldTag }. Archiving a referenced block definition answers409with the same code and areferencesarray 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
allowedBlockKeysor re-keying a collection field, is rejected with400rather 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
400andcode: "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.
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.
// 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.
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.| Intent | Method and path |
|---|---|
| Update an instance | PUT /api/v1/blocks/:key/instances/:id. Partial: send only the members you are changing. |
| Change only its status | PATCH /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, enable | POST to the matching sub-path, for example /instances/:id/publish. |
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.
| Direction | Key |
|---|---|
| Write, portal API, an instance's stored values | The field's id, as declared in fields on the block definition. Per-locale i18n overrides are keyed the same way. |
Read, /sdk/* delivery | The 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.
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.The block definition shape, including where fields[].id appears, is on Blocks & fields.
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
| Consent | Question it answers | Set via | Covers |
|---|---|---|---|
| Analytics, device-level | May we record your behaviour? | The consent boolean on POST /sdk/identify | Whether behavioural events are recorded at all. Nothing else. |
| Messaging, per-channel | May we contact you on X? | POST /sdk/consent | push, 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.
/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.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.
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.
POST /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.| Method | Path | Credential | Documented here |
|---|---|---|---|
POST | /sdk/identify | X-CF-Key | Full contract. |
DELETE | /sdk/identify | X-CF-Key | Full contract. Same path as the POST, different endpoint. |
POST | /sdk/register-push | X-CF-Key | Full contract. |
GET | /sdk/sync | X-CF-Key | Full contract. |
GET | /sdk/blocks/:key | X-CF-Key | Full contract. |
GET | /sdk/strings | X-CF-Key | Full contract. |
POST | /sdk/events | X-CF-Key | Full contract. |
POST | /sdk/track-event | X-CF-Key | Full contract. |
POST | /sdk/consent | X-CF-Key | Full contract. |
GET | /sdk/stream | X-CF-Key, or ?key= on this route only | Full contract. |
GET | /sdk/locales | X-CF-Key | Listed only. Request and response shapes are not documented on this page. |
POST | /sdk/campaigns | X-CF-Key | Listed only. Request and response shapes are not documented on this page. |
GET | /sdk/consent/config/:preset | X-CF-Key | Listed only. Request and response shapes are not documented on this page. |
POST | /sdk/consent/full | X-CF-Key | Listed only. Request and response shapes are not documented on this page. |
POST | /sdk/push/topics | X-CF-Key | Listed only. Request and response shapes are not documented on this page. |
GET | /sdk/push/topics | X-CF-Key | Listed only. Request and response shapes are not documented on this page. |
PATCH | /sdk/push/preferences | X-CF-Key | Listed only. Request and response shapes are not documented on this page. |
GET | /sdk/public-stats | None. See below. | Listed only. Request and response shapes are not documented on this page. |
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.
| Method | Path | Credential | Purpose |
|---|---|---|---|
POST | /cards/register | X-CF-Write-Key | Upsert one block or card definition. By default it also seeds one blank starter draft instance for that definition. |
POST | /cards/sync | X-CF-Write-Key | Reconcile 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/sync | X-CF-Write-Key | Push translation strings into the workspace from a server or a build step. |
Which cards route to use, and the one that archives
/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/register | POST /cards/sync | |
|---|---|---|
| Shape | Imperative. One definition per call. | Declarative. A manifest of many definitions, describing the state the workspace should be in. |
| Writes definitions | Yes, one. | Yes, all of them. |
| Writes instance content | Seeds one blank starter draft instance per definition, by default. | Optionally, carried in the same call. |
| Effect on definitions you did not name | None. Everything else is left alone. | Archives them. Every currently active definition the manifest does not name is archived. |
| Empty input | Not 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.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
| Method | Path | Credential | Purpose |
|---|---|---|---|
GET | /read/users/{userId}/devices | X-CF-Read-Key | Every device known for one identifier. |
GET | /read/devices/{deviceId} | X-CF-Read-Key | One device and the profile attached to it. |
GET | /read/users/{userId}/segments | X-CF-Read-Key | The 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.
| Method | Path | Credential | Purpose |
|---|---|---|---|
GET | /settings/write-key | Portal JWT, admin or editor | Fetch the workspace's write key. |
GET | /settings/read-key | Portal JWT, admin or editor | Fetch the workspace's read key. |
POST | /settings/read-key/rotate | Portal JWT, admin or editor | Replace the read key. Immediate, no grace period, and no dashboard control exists for it. See the read surface. |
POST | /blocks | Portal JWT, admin or editor | Register a block type. |
PUT | /blocks/:key/instances/:id | Portal JWT, admin or editor | Update a block instance. Partial update, including the targeting field. See below. |
POST | /segments | Portal JWT, admin or editor | Create 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.
// 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.
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.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-only | Personalized | |
|---|---|---|
| You want | Copy and imagery you can change without an app release. | That, plus segment targeting, engagement analytics, or messaging. |
| You send | X-CF-Key. That is the whole client. | A key, a persisted device id, an identify call, and consent decisions. |
| You store about a user | Nothing. | A device identifier, traits, consent state. |
| Start here | Anonymous, content-only mode | The 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
- 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.
- 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.
- 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. - Then send events. Post engagement to /sdk/events in batches, using the
instanceIdfrom the blocks you rendered. - Optionally, subscribe. Open /sdk/stream and re-sync when a
block.*event arrives. Polling withIf-None-Matchis 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,
localStorageon web. - Send the same value as
X-CF-Deviceon 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.
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
| Check | Why |
|---|---|
Base URL is app.contentflow.click/api/v1, verified by the response being enveloped | The retired api. host still answers 200 with frozen content and never errors. |
| You send a device id only because you decided to | The 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 all | Otherwise every update resets consent and segments. |
Analytics consent written to identify, messaging consent written to /sdk/consent | They 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 API | Same 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 fields | Array 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 instanceId | A card is a whole block. Position is never identity, and nesting stops at one level. |
Block version treated as a number, not the ETag | Two different things share the name version. |
accepted checked on event posts | A consent-dropped batch still answers 200. |
No X-CF-Read-Key or X-CF-Write-Key anywhere in the shipped binary or bundle | Both 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 refresh | It is the only mechanical proof that a segment landed, rather than a screenshot of a block that happened to render. |