Developers
Developer documentation
One engineering integration up front. After that your team publishes content changes without a new app build and without a new app store submission.
This page is the technical reference for that integration: base URL, authentication, every request and response shape, error codes, and what we do and do not guarantee about rate limits. It describes exactly what a mobile or web app can build against today.
Base URL and authentication
https://api.contentflow.click/sdk/v1
Every route documented below is relative to this base URL; for example, the sync route in Quickstart is a GET to https://api.contentflow.click/sdk/v1/sync. It is the default in every SDK from 2.2.1. Every route takes an X-CF-Key header (the header X-API-Key is accepted as an alias for it).
Compatibility aliases. https://api.contentflow.click/api/v1/sdk and https://app.contentflow.click/sdk/v1 reach the same gateway with the same routes and answers, so existing apps that use them keep working. New integrations should use the base above.
| Header | Required | Purpose |
|---|---|---|
X-CF-Key | Yes | Your public client key. X-API-Key is accepted as an alias. |
X-CF-Device | Route-dependent | A device identifier you generate and keep stable per install. Required by /events and by DELETE /identify, which is keyed entirely on this header. |
Accept-Language | No | Locale hint for the response. |
If-None-Match | No | Send back the ETag a previous /sync response returned, to get a 304 when nothing has changed. |
The key is a public client key, shaped <workspaceId>_app for live traffic or <workspaceId>_test for test traffic. It is designed to be shipped inside an app binary or a web page: it is not a secret, and should not be handled like one. The suffix on the key alone decides live versus test; nothing in a request body can change that. See Test versus production below.
ws_xxxxxxxx_app, not a working credential. Use the real key from your workspace settings.Quickstart
Fetch the content currently published for a workspace:
curl "https://api.contentflow.click/sdk/v1/sync?locale=en" \
-H "X-CF-Key: ws_xxxxxxxx_app"
A successful response:
{
"success": true,
"data": {
"blocks": [
{
"key": "home.discovery",
"...": "the rest of the block's fields depend on its own type"
}
],
"version": "2026-08-01T12:00:00Z"
}
}
Record an impression once you have rendered a block:
curl -X POST "https://api.contentflow.click/sdk/v1/events" \
-H "X-CF-Key: ws_xxxxxxxx_app" \
-H "X-CF-Device: <device-id-you-generated>" \
-H "Content-Type: application/json" \
-d '{
"events": [
{ "type": "impression", "instanceId": "inst_123", "blockKey": "home.discovery" }
],
"context": { "sessionId": "sess_abc", "platform": "web" }
}'
Endpoints
Paths below are relative to the base URL, https://api.contentflow.click/sdk/v1.
Content delivery
The full set of published blocks for the workspace. Sends an ETag; send it back on If-None-Match to get a 304 when nothing has changed.
{"success":true,"data":{"blocks":[...],"version":"..."}}
A single block by key. Same envelope shape as /sync, scoped to one block.
200 {"success":true,"data":{...}}
404 {"success":false,"error":"unknown block"}
Localized strings for a namespace.
200 {"success":true,"data":{"strings":{...},"version":"..."}}
Which locales this workspace has configured.
{"success":true,"data":{"locales":[...],"sourceLocale":"...","version":"..."}}
Identity and consent
Link a device (and optionally a signed-in user) to targeting segments.
// request
{"deviceId":"...","consent":true,"traits":{},"userId":"..."}
// response
{"success":true,"data":{"deviceId":"...","segments":[...],"consent":...}}
Erase a device's data. Keyed entirely on the X-CF-Device header; there is no body.
200: full erase; only now clear the device id on the client
502: the erase could not be confirmed or was incomplete (code UPSTREAM_UNAVAILABLE); retry with the same device id
400: no X-CF-Device header was sent
Records a channel consent decision (for example push, SMS, WhatsApp, email or marketing) against the current device. The request and response body were not part of what we verified against the product this round, so we are not going to guess at the field names here; ask us for the current contract before you build against this route.
Events
Batch-send impression/tap/conversion-style events.
// request
{"events":[{"type":"impression","instanceId":"...","blockKey":"..."}],"context":{"sessionId":"...","platform":"web"}}
// response
{"success":true,"data":{"accepted":n,"dropped":n}}
// 400 DEVICE_ID_REQUIRED: no device identifier was present on the request
Push
Three routes exist for push delivery. As with /consent, their request and response bodies were not part of what this round verified against the product source, so they are listed here without a guessed schema.
Register a device's push token.
Manage push topic subscriptions.
Update push preferences for a device.
Error codes
Every error response uses the same envelope: {"success":false,"error":"...","code":"..."}. The error string is for people; branch on code, which is present wherever a caller can act on the cause. Rate-limit errors are the one exception: there error is an object carrying the code (see Rate limits).
| Status | Code | Meaning |
|---|---|---|
| 401 | MISSING_SDK_KEY | No X-CF-Key (or X-API-Key) header was sent. |
| 401 | INVALID_SDK_KEY | The key is not in a recognised shape, or is an unknown or revoked pk_ key. |
| 400 | DEVICE_ID_REQUIRED | A route that needs a device identifier, such as /events, got none. |
| 400 | DEVICE_REQUIRED | A request names a user but sends no X-CF-Device. |
| 403 | DEVICE_NOT_IDENTIFIED | The device is not signed in as a user. Call /identify first. |
| 403 | USER_MISMATCH | The device is signed in as a different user. |
| 429 | SDK_RATE_LIMIT_EXCEEDED | The workspace is over its request limit. Wait for Retry-After. |
| 502, 503, 504 | UPSTREAM_UNAVAILABLE | A transient failure behind or at the gateway. The body also has "retryable":true; keep showing cached content and retry after Retry-After seconds. |
DELETE /identify never answers 207: an incomplete erase is a 502, and you retry it.A note on double-wrapped responses
Earlier, the gateway's write routes could return a response envelope wrapped twice: {"success":true,"data":{"success":true,"data":{...}}}. That has been fixed; /sync is single-wrapped today, as shown in the Quickstart example above.
Our JavaScript SDK still parses defensively, because app builds shipped while the earlier behavior was live stay installed on real devices for months after a fix ships. We recommend the same on any client you write against this API: read the first object in the response that actually carries the field you are looking for, at whatever depth it appears, rather than assuming a fixed nesting.
Rate limits
Requests are limited per workspace, by default 1,000 per minute. Responses carry X-RateLimit-Limit and X-RateLimit-Remaining. Over the limit you get 429 with Retry-After and {"success":false,"error":{"message":"SDK rate limit exceeded","code":"SDK_RATE_LIMIT_EXCEEDED","details":{...}}}. Build your client to back off on failure rather than retry tightly.
SDK and CLI status
You can integrate against the REST API directly, or through the SDKs for JavaScript (web, React, React Native and Expo), Swift, Kotlin and Flutter. The current SDK release is 2.2.1.
- The SDKs are not published to a public package registry: not npm, pub.dev, Maven Central or CocoaPods. A command such as
npm install @contentflow/sdkwill not resolve, and nothing on this page promises that it will. - Each release is a set of versioned archives (for example
contentflow-sdk-2.2.1.tgz) with aSHA256SUMSfile, handed to invited integrators with their workspace. Verify the archives before installing them withshasum -a 256 -c SHA256SUMS, then install from the local file as each SDK's page on the developer hub shows. The hub's releases page lists the recorded checksum of every archive, so you can check a file you were sent against it. - There is no separate
@contentflow/react-nativepackage: React Native uses the JavaScript package. - The
@contentflow/clipackage is not published, so annpx @contentflow/clicommand will not resolve. - None of the above has a published general-availability date.
Offline and caching
If you integrate against this REST API directly, rather than through our JavaScript or React Native package, offline caching is yours to implement; the API itself does not cache anything on your behalf.
The JavaScript and React Native package persists the last successful /sync and /strings payloads (localStorage on web, async storage on React Native) with no expiry, and reuses them whenever a fetch fails, so the app keeps showing the last good content rather than blanking.
A response that comes back 200 without a blocks field is treated by the package as a failure, not as an empty catalogue, so an outage on our side is never mistaken for "nothing has been published yet."
Test versus production
The suffix on your key, _test or _app, is the entire mechanism. Test-tagged data is written to the same store as production data: it is stamped as test, filtered out of production metrics, and can be reset by an admin without touching live data. It is a tag on one database, not a separate database; plan your data handling accordingly.
Security and data
- Keys are public client keys, not secrets; see Base URL and authentication.
- A key only resolves content for its own workspace.
- All traffic is encrypted with TLS in transit.
- No end-user PII needs to leave the app to render content; targeting works on whatever traits you choose to pass, such as segment or locale.
Beyond this page
This page is the public reference: it is everything a team needs to decide what to build, and it is hosted here rather than behind a link to somewhere else. The developer hub at dev.contentflow.click goes further, with platform quickstarts, CLI recipes and the SDK previews. Nothing on this page depends on it; if the two ever disagree, this page is the one we hold ourselves to.
Support
Questions, edge cases, or something not behaving as documented? Email hello@contentflow.click or book a demo and we will walk through your integration.