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://app.contentflow.click/api/v1/sdk
Every route documented below is relative to this base URL; for example, the sync route in Quickstart is a GET to https://app.contentflow.click/api/v1/sdk/sync. Every route takes an X-CF-Key header (the header X-API-Key is accepted as an alias for it).
| 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://app.contentflow.click/api/v1/sdk/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://app.contentflow.click/api/v1/sdk/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://app.contentflow.click/api/v1/sdk.
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":"..."}}
404 SDK_LOCALE_NOT_CONFIGURED: the locale is not set up for this workspace
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
207: partial erase; body names which parts succeeded
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 shape: {"success":false,"error":"..."} or a machine-readable code, as shown in each row below.
| Status | Code | Meaning |
|---|---|---|
| 401 | MISSING_SDK_KEY | No X-CF-Key (or X-API-Key) header was sent. |
| 401 | INVALID_SDK_KEY | The key does not resolve to any workspace. |
| 403 | TENANT_KEY_MISMATCH | The key resolved, but not to the tenant the request is scoped to. |
| 400 | MISSING_TENANT_ID | No tenant could be resolved for the request. |
| 404 | TENANT_NOT_FOUND | The resolved tenant does not exist. |
| 400 | DEVICE_ID_REQUIRED | A route that needs a device identifier, such as /events, got none. |
| 404 | SDK_LOCALE_NOT_CONFIGURED | The requested locale is not set up for this workspace. |
| gateway failure | SDK_SYNC_FAILED | /sync could not complete on the gateway's side. |
| gateway failure | SDK_EVENTS_FAILED | /events could not complete on the gateway's side. |
| gateway failure | SDK_IDENTIFY_FAILED | /identify could not complete on the gateway's side. |
| gateway failure | SDK_CONSENT_FAILED | /consent could not complete on the gateway's side. |
DELETE /identify returns 207 when an erase only partially completes.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
There is no application-level rate limit on these routes today, and we do not publish a quota. That describes current behavior, not a promise about the future. Build your client to back off on failure rather than retry tightly regardless.
SDK and CLI status
Production integration today uses the REST API. Native SDKs for React Native, iOS, Android and Web are in preview and are not generally available. The @contentflow/cli package is not published yet.
- The JavaScript and React Native package is written and has a working on-device cache (see Offline and caching below), but it is not published to npm.
- The Swift and Kotlin packages are not usable today. Do not plan an iOS or Android build around them.
- The
@contentflow/clipackage is not published to npm, so anpx @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.