ContentFlow SDK runtime contract

This document defines the customer runtime integration supported by the current SDK release. Each release manifest distinguishes local contract tests from deployed service and physical-device verification. Do not interpret package compilation as production delivery evidence.

Public ingress and identity

Canonical base: https://api.contentflow.click/sdk/v1. The compatibility bases https://api.contentflow.click/api/v1/sdk, and /sdk/v1 or /api/v1/sdk on the portal host app.contentflow.click, reach the same SDK API and keep working for existing apps; new integrations use the api host. Key kinds accepted in X-CF-Key: a publishable key (<workspace-id>_cfpk<40 hex>, created in Settings > API keys, stored hashed, safe to ship in an app and the recommended app key); the legacy workspace-derived keys (<workspace-id>_app live, <workspace-id>_test test), which anyone who knows the workspace id can build and which a workspace (sdkKeyPolicy.derivedKeys) or the platform (SDK_DERIVED_KEYS=reject) can switch off, after which they answer 401 INVALID_SDK_KEY; and secret pk_live_/pk_test_ server/CI keys, which must never ship in an app. app.contentflow.click is the portal. SDK URLs include one base and one operation.

Configuring the base URL

From SDK 2.2.1 every SDK defaults to https://api.contentflow.click/sdk/v1 (2.2.0 and earlier defaulted to the portal host, or to https://api.contentflow.click/api/v1 in Kotlin). Configure the base outside code where you can, so a future host or version change needs no code change. Precedence: an explicit baseUrl > platform config > the default.

Platform Configure with
Web (plain page) <script>window.__CONTENTFLOW_BASE_URL__ = 'https://api.contentflow.click/sdk/v1';</script> before the SDK loads
Vite baseUrl: import.meta.env.VITE_CONTENTFLOW_BASE_URL
Next.js baseUrl: process.env.NEXT_PUBLIC_CONTENTFLOW_BASE_URL
Expo / React Native baseUrl: process.env.EXPO_PUBLIC_CONTENTFLOW_BASE_URL
iOS (Swift) Info.plist key ContentFlowBaseURL (String)
Android (Kotlin) <meta-data android:name="click.contentflow.BASE_URL" android:value="https://api.contentflow.click/sdk/v1"/> inside <application>
JVM (Kotlin) system property -Dcontentflow.baseUrl=…, or the environment variable CONTENTFLOW_BASE_URL
Flutter --dart-define=CONTENTFLOW_BASE_URL=https://api.contentflow.click/sdk/v1

A configured value must be https; http is accepted only for localhost, 127.0.0.1 and 10.0.2.2. An invalid value is ignored with a logged error and the default is used; an explicit baseUrl is not newly validated. The base URL is never fetched remotely. A base that does not end in /sdk/v1 logs one notice per client: "ContentFlow: baseUrl uses a legacy path. Update it to https://api.contentflow.click/sdk/v1 (see the 2.2.1 release notes)."

Every request includes X-CF-Key (publishable SDK key) and X-CF-Device (stable installation identity). SDKs may also send X-Tenant-Id, but the workspace always comes from the key. With a publishable key a conflicting X-Tenant-Id is refused with 401 TENANT_KEY_MISMATCH; for the other key kinds it is logged and ignored. From 2.2.2 every SDK sends X-CF-SDK-Version: <platform>/<semver> (js, react-native, swift, kotlin or flutter; JS 2.2.1 and earlier sent the bare version) and X-CF-SDK-Capabilities (comma-separated; from 2.3.0 every SDK declares erasure-partial-v1, from 2.4.0 also frequency-v1 (informational: the server applies frequency rules to every client), and only a JS app that sets sponsored: true also declares sponsored-surface-v1). That header reports a client version; it does not negotiate an alternative backend contract. Environment comes from the key accepted by the SDK API. Copy the workspace publishable key from its approved configuration; do not manufacture keys by concatenating a workspace and suffix.

POST /identify sends {deviceId, userId?, traits?, consent?}. deviceId identifies an installation; userId identifies a signed-in account. Preserve the prior installation ID during migration, and use the same ID in headers and body. Reserved or malformed identities are not usable devices. Missing consent means leave the server's decision unchanged; explicit true/false is a recorded analytics choice. SDKs must report failure rather than manufacture successful local identity state. Last-good read caches must be scoped by endpoint, workspace/key, device/account and locale.

POST /consent uses {deviceId?, username?, channels: {push?, email?, sms?, whatsapp?, locationTracking?, marketing?}}. Device-scoped consent uses the stable device ID and omits username while signed out; the SDK must not invent a user identity from the device ID. channels is an object of booleans. Official SDKs include their stable device ID. Legacy clients may omit it; the SDK API must preserve omission instead of inserting an empty or invented ID. A supplied malformed ID still fails. Username refers to the application account/profile whose channel flags are updated; it is distinct from installation identity.

Analytics consent and messaging-channel consent are independent. Token registration is not consent. SDK write methods reject non-success HTTP responses and any supported failure envelope, including a failure hidden under HTTP 200. A lost response leaves persistence uncertain; do not show an unconfirmed choice as saved. Serialize consent changes so later choices cannot be overwritten by older requests.

Content and localization

Operation Contract
GET /sync {success:true,data:{blocks:[...],version?}}, optionally ETag; empty blocks are authoritative
GET /blocks/{key} One delivered block; preserve actual errors/404
GET /strings {success:true,data:{locale,strings:{key:value},smartKeys?:[...],withheldKeys?:[...],version?}}; optional namespace. withheldKeys lists keys whose translation is withheld, which fall back to source text
POST /campaigns Placement-based campaign evaluation; app renders only returned campaigns

Locale precedence is ?locale= followed by Accept-Language. Official SDKs use these canonical selectors. ?lang= and X-CF-Locale are not accepted selectors in this version; existing requests may receive an informational warning. No new alias support is claimed.

Keep validators separate for sync, strings, and individual blocks, including locale and strings namespace. Return an empty 304 only for the exact matching representation. SDKs store the actual ETag header and preserve its complete value, including quoting. Current sync/strings data.version mirrors that opaque validator, so copying that exact value in the same representation is valid; a single block's numeric instance version is a different concept. No ETag is synthesized when the origin provides none. Absence of a validator is not proof of unchanged content. A missing/malformed content field or success:false must not clear last-good content; a valid empty content field must clear it. A stale request from a previous locale/account must not replace the active view.

The selected app language must be passed explicitly when it differs from the OS language. The resolver uses case-insensitive exact locale matching, then parent language tags (for example ar-SA → ar), with source fallback when the requested translation is absent or withheld. Strings can contain workspace sourceText and blocks can contain base source fields, including English. An SDK app fallback applies only to a genuinely absent delivered key; it does not replace source text returned by the server. SDKs do not generate translations. Campaign delivery uses the public POST /campaigns route and accepts the actual {success:true,data:[campaigns...]} envelope, with locale-keyed content; /campaigns/evaluate is an internal service route.

Sync returns all eligible instances for a block key, ordered by descending numeric priority, descending version, then ascending instance ID. A single-block response identifies the first as its primary block and includes the instances array. There is no active slot field to enforce. The server selects A/B assignment and localized values; delivered block attribution is instanceId plus optional variantId (A or B), without an invented experiment ID. Schedules are enforced at delivery time: payday (the 25th through the 3rd) and ramadan (Hijri month 9, Umm al-Qura) are recurring windows judged on the local calendar day in schedule.tz (Asia/Riyadh by default), and custom is enforced against its start/end only when the schedule carries acknowledgedAt. A valid empty snapshot is an authoritative unpublish result.

A per-request locale/deviceId override on a single-block read (GET /blocks/{key}?locale=&device header) is scoped to that request only: it is never written back into the client's active locale, device identity, or any cache, and must not be de-duplicated against an ordinary request for the same key.

Locales

GET /locales lists every locale the workspace has configured, independent of whether any string in it is translated yet, so a client can build a language picker without hardcoding a list:

JSON
{"success":true,"data":{"locales":[{"code":"ar","name":"Arabic","native":"العربية","rtl":true,"isSource":false,"coverage":92}],"sourceLocale":"en","version":"W/\"locales-ws_a1b2c3d4-3f9c1ab2\""}}

Exactly one entry has isSource:true; sourceLocale is that entry's code, or null on the abnormal case where none is flagged. An empty locales array with sourceLocale:null is the honest "nothing configured" answer, not an error. This route has its own ETag/validator, distinct from the sync and strings validators, and is shared across the live and test environments since the configured-locale list is a workspace property, not an environment-scoped one. X-CF-Key alone is sufficient; no device id or prior identify is required.

Status Code When
502 LOCALE_CATALOG_UNAVAILABLE The catalogue could not be read and nothing usable is cached. Never a 200 with an empty list on failure — an empty locales array is reserved for the true "nothing configured" case.

SDKs must not synthesize en/ar (or any locale) in place of a failed read; a client-side cached catalog from a prior successful read may still be served through its own accessor.

GET /consent/config/{preset} returns the shape a consent screen for one regulated industry should offer — required versus optional channels, default messaging topics, and the legal framework it is built against. Purely informational: reading a preset never grants, revokes, or otherwise changes anyone's consent, only POST /consent does that.

Exactly seven valid ids, and no default: banking_ksa, insurance_ksa, healthcare_ksa, travel_ksa, retail_ksa, food_delivery_ksa, fintech_ksa.

JSON
{"success":true,"data":{"preset":"banking_ksa","industry":"banking","region":"SA","required":["channels.push"],"optional":["marketing.promotional"],"defaultTopics":["security_alerts"],"availableTopics":[{"id":"security_alerts","name":"Security Alerts","nameAr":"تنبيهات الأمان","required":true}],"legalFramework":"SAMA + PDPL"}}

required/optional are dotted paths into the consent state (channels.push, marketing.transactional, marketing.serviceUpdates, marketing.promotional, marketing.thirdPartyOffers, location), not booleans or channel names. availableTopics lists every messaging topic the preset knows about with whether it is required; defaultTopics is the subset pre-selected for a fresh consent screen. Like every /sdk/v1 route it requires X-CF-Key, but it needs no device id or prior identify, so it can back a consent screen shown before identify.

Status Code When
404 UNKNOWN_CONSENT_PRESET The {preset} segment is not one of the seven ids above. The body also carries availablePresets, the real list.
502 CONSENT_CONFIG_UNAVAILABLE The preset service could not be reached, or answered with something other than a clean success or a genuine 404. Not a 404: that status is reserved for "no such preset".

An SDK decoding a 200 body that does not match this shape (a missing array field, a topic with no id) must raise a malformed-response error rather than hand back a half-decoded object with undefined fields. A body identified by a legacy id field still decodes, with missing arrays reading as [].

Response and error envelopes

Current success: {success:true,data:...}; failure: {success:false,error:"reason"} or an error object with code/message. SDKs also recognize bounded legacy nested {success:true,data:{success:false,...}} and nested success data. HTTP status and application envelope are both checked. HTTP 200 does not override an application failure. Diagnostics expose useful status/message without logging keys or request bodies.

A response that carries neither shape at all — an HTML error page, an empty body, unrelated JSON from some other host, or a successful-looking 200 from a different service entirely — is a distinct condition from an application failure: it means the configured base URL does not look like the ContentFlow SDK API. SDKs must diagnose this as a named, typed condition (not a generic parse error) and surface it to the caller rather than silently returning empty/last-good content forever; a genuine 404 from the delivery API itself (an unknown block key, still carrying the envelope) is not this condition.

Error codes

The OpenAPI spec (dev-site/openapi/sdk-v1.json) is authoritative for every code and status. Branch on code, not on message text or body shape.

Events and Sensors

POST /events sends {events:[...],context:{sessionId,platform?,osVersion?,appVersion?,deviceModel?}} with the installation header. Analytics consent must be recorded before ingestion. The client may add diagnostic event identifiers; current backend deduplication is not guaranteed.

The server accepts valid ISO-8601 timestamps and integer epoch milliseconds for backward compatibility. Batches are limited to 100 events; larger batches fail before any enqueue with MAX_BATCH_SIZE. Empty, blank or control-character event names fail with INVALID_EVENT_TYPE; valid custom names remain extensible and case-preserved. sign_up and sign_in belong to /track-event; sending those identity events to /events fails with ENDPOINT_MISMATCH rather than falsely accepting an identity mutation.

Block events include type, instanceId or blockKey, timestamp and optional screen/position/custom metadata. Campaign events include campaignId, optional placement and variant attribution. String signals use the following top-level fields:

JSON
{"type":"string_impression","key":"welcome","locale":"ar","namespace":"home","path":"/home","source":"usage","ts":"2026-09-19T00:00:00Z"}

source is usage or viewport for an impression; string_interaction omits source. Server-provided smartKeys controls automatically tracked keys. String payload validation and analytics read-side attribution must be verified through the live SDK API; an SDK constructing these fields alone does not verify the full Sensors path.

Production acknowledgement: HTTP 202 {success:true,data:{accepted:N}} means acceptance into the asynchronous event pipeline, not confirmed durable storage. Consent suppression returns 202 with accepted: 0, a dropped count and a reason. An immediate queue refusal returns a retryable 503. Other supported service responses include aggregate {accepted,dropped,byCategory?}. No per-item acknowledgement identifies which members of a partially accepted batch are safe to replay.

Each successful ACK includes accepted, dropped, and additive dropReasons as a count map. Codes include device_not_identified (the server could not establish device state), consent_not_granted, invalid_string_signal, and sensor_not_enabled (a string signal whose key is not an enabled sensor). These codes do not prove durable profile absence or inactive Sensors. Counts must account for the batch; incomplete accounting is uncertainty. Consume partial ACKs and report dropped counts. Report transport/timeout/server uncertainty without automatic replay by default. An explicit at-least-once retry mode may duplicate counters and must say so. Never describe this contract as exactly-once or as a durable audit log. Offline storage is bounded and may be unavailable; report capacity/expiry/retry exhaustion and recheck consent before replay. Physical-device app termination can prevent a final flush.

Streaming

GET /stream accepts key headers or the stream-only ?key= compatibility path required by browser EventSource. It flushes an initial comment and heartbeats; comments are not content updates. Named invalidations include block.created, block.updated, block.published, block.paused, block.disabled, block.enabled, block.definition.disabled, and block.definition.enabled. block.targeted (no event ID) reaches only a device whose own content changed, such as a new in-app message for its user on a live key; SDKs must re-sync on it. Payloads contain no personalized content. Refresh sync and strings on connect, reconnect and invalidation. Event IDs support bounded process-local replay, not durable replay across replicas or restarts. ETag polling remains the fallback.

Push lifecycle

POST /register-push accepts the stable device ID, optional linked user ID, token, platform and provider. Platform aliases are ios/apns → APNs, android/fcm → FCM, web/web_push → Web Push, and huawei/hms → HMS (requires hmsConfig.appId). Unknown supplied platforms/providers fail; an explicit supported provider can override a recognized platform. Host applications own OS permission, native token acquisition/refresh and notification handlers. Provider credentials are configured server-side.

DELETE /register-push removes only this installation's token. Supply {deviceId,userId?}; a real device header may also identify the install. Optional userId must match the existing linked identity. No user-only bulk clearing is supported. Success (200) data contains deviceId,removed:true and confirmed per-store results; idempotent no-token success contains deviceId,removed:false. Malformed ID → 400, mismatch → 403, partial or unconfirmed removal → 502. Retry an unconfirmed idempotent removal; do not treat it as removed.

On push withdrawal, first persist {push:false} channel consent, then remove the token. Revocation and token deletion are separate operations. Server-side dispatch must honor a revocation already received without requiring the app to reopen. A server cannot infer a choice made only on an offline device. Provider delivery and background-device behavior remain separate acceptance tests.

An identify response can distinguish device persistence from account linkage with data.results.identity.{applied,status}. Status is identified, anonymous or unchanged. A consent-gated HTTP 200 with applied:false does not confirm the requested user. A PUSH_LINKAGE_INCOMPLETE HTTP 207 may carry identity.applied:true and pushRegistration.applied:false; the SDK retains the confirmed new identity, clears old personalized content and exposes the partial failure. It must not claim that identity rolled back. Retry the same identify idempotently.

Versioning and compatibility policy

SDK releases use semantic versions and an immutable versioned archive/registry artifact with changelog. Package major versions cover incompatible public SDK API changes; minor versions add compatible features/fixes with documented behavioral corrections; patch versions correct compatible defects. A manifest version is not a publication record. Dist-tags are assigned only by the authorized registry publisher; next is for explicitly evaluated candidates and latest only for approved stable releases.

The /sdk/v1 contract must preserve existing valid request shapes. Adding a mandatory field or removing a valid selector/response shape requires a new API major version or an announced deprecation and migration path while existing clients continue working during migration. Release notes must identify changed behavior, affected versions, migration instructions and any retirement date before enforcement. This release does not announce a retirement date for legacy username/channel consent or /api/v1/sdk.

No support for a platform version is inferred from broad peer dependency ranges. The build matrix names the tested toolchain/OS targets; older or newer untested targets are unverified. Advanced methods not included in that matrix do not become certified product coverage merely because their symbols exist.

sdk-release/scripts/check-release.mjs is the shared, dependency-free release-readiness guard: it refuses a dirty working tree (unless --allow-dirty), refuses a version that is not strictly greater by semver than every version already recorded for that package in sdk-release/versions.json, requires a matching ## <version> CHANGELOG.md heading, and refuses to move an existing release git tag onto different source. With --record, and only on a clean pass, it appends a {package, version, commit, tree, tarballSha256, bytes, files, recordedAt} entry to versions.json — an append-only ledger; a mistake ships as a new, higher version, never as an edit to a recorded entry. None of this — a local .tgz, a CI-uploaded provenance artifact, a git tag, or a versions.json entry — constitutes publication to the npm registry; only a successful npm publish against the target registry does. next is the default dist-tag for any evaluated candidate; latest moves only on an owner-approved stable release, never as a side effect of testing a publish.

Generated from the ContentFlow repository by sdk-release/scripts/render-docs.mjs. Downloads and checksums are on Releases and downloads. The SDKs are licensed under the ContentFlow SDK License.