ContentFlow

Developers

Developer documentation

REST API v1 · updated 14 August 2026 · overview

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.

App store posture. Publishing content through ContentFlow does not require a new build or a new store submission. Any change to your app's own code still goes through Apple and Google review, and remotely delivered content has to stay inside the purpose your app already declares and the store policies that apply to it.

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).

HeaderRequiredPurpose
X-CF-KeyYesYour public client key. X-API-Key is accepted as an alias.
X-CF-DeviceRoute-dependentA device identifier you generate and keep stable per install. Required by /events and by DELETE /identify, which is keyed entirely on this header.
Accept-LanguageNoLocale hint for the response.
If-None-MatchNoSend 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.

Every key on this page is a placeholder, 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

GET/sync?locale=en

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":"..."}}
GET/blocks/{key}?locale=en

A single block by key. Same envelope shape as /sync, scoped to one block.

200 {"success":true,"data":{...}}
404 {"success":false,"error":"unknown block"}
GET/strings?locale=en&namespace=...

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
GET/locales

Which locales this workspace has configured.

{"success":true,"data":{"locales":[...],"sourceLocale":"...","version":"..."}}

Identity and consent

POST/identify

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":...}}
DELETE/identify

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
POST/consent

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

POST/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.

POST/register-push

Register a device's push token.

POST/push/topics

Manage push topic subscriptions.

PATCH/push/preferences

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.

StatusCodeMeaning
401MISSING_SDK_KEYNo X-CF-Key (or X-API-Key) header was sent.
401INVALID_SDK_KEYThe key does not resolve to any workspace.
403TENANT_KEY_MISMATCHThe key resolved, but not to the tenant the request is scoped to.
400MISSING_TENANT_IDNo tenant could be resolved for the request.
404TENANT_NOT_FOUNDThe resolved tenant does not exist.
400DEVICE_ID_REQUIREDA route that needs a device identifier, such as /events, got none.
404SDK_LOCALE_NOT_CONFIGUREDThe requested locale is not set up for this workspace.
gateway failureSDK_SYNC_FAILED/sync could not complete on the gateway's side.
gateway failureSDK_EVENTS_FAILED/events could not complete on the gateway's side.
gateway failureSDK_IDENTIFY_FAILED/identify could not complete on the gateway's side.
gateway failureSDK_CONSENT_FAILED/consent could not complete on the gateway's side.
207 Multi-Status. A 207 response means a partial write: part of the request succeeded and part did not. The body names which parts succeeded, so read it rather than treating 207 as a plain success or a plain failure. 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.

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.

With no network the app renders the fallback content compiled into your build. No ContentFlow change can reach a device that is offline, and a device that has never reached us has nothing of ours cached.

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

Data residency. Today ContentFlow runs as a single deployment on infrastructure located in Riyadh, Saudi Arabia, so production content and end-user data are processed in the Kingdom; that is a property of how we are currently deployed rather than a control the product enforces, encrypted backups and some third-party processors are handled outside the Kingdom, and an in-Kingdom-only arrangement is something we agree in writing on an Enterprise plan rather than something this page can promise.

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.