Home / Wiki
Knowledge Base

ContentFlow Wiki

The shared brain for building on ContentFlow, concepts, architecture, and the words we use. If the docs tell you how, the wiki tells you why.


Concepts

Blocks vs. instances

A block type is a UI shape defined in code (a hero, a card, a carousel). A block instance is that shape filled with content, a specific hero on your home screen. Types are defined by developers and registered over the REST API, the CLI that will version them from git is not published yet; instances are managed by anyone in the dashboard. Field types and tag addressing are covered in Blocks & fields.

A list is not a special case of either. A collection field holds an ordered list of references to other instances, so a carousel is an ordinary instance whose slides are ordinary instances. Every card therefore has its own targeting, schedule, approval state, A/B variants and analytics, and delivery inlines the eligible ones in full under the field's tag.

Channels

A channel is where a block is delivered: push, whatsapp, sms, popup, or in-app. Popup and in-app render as block-style image cards; SMS and WhatsApp run through configured Twilio channels.

Segments & targeting

A segment is an audience. Blocks and campaigns target a segment, a locale, or a single user. The special "All users" segment requires contacts to have phone + consent before SMS/WhatsApp can reach them. For the whole chain, sending a trait through to proving the targeting landed, see Traits & targeting.


Architecture

ContentFlow runs as a small set of services behind an API gateway. Your app only ever talks to the gateway, directly over the REST API or through an SDK that wraps it. There is no other entry point.

data flow
Your app
   │  REST API (X-CF-Key, the only required header on /sdk/*)
   ▼
API Gateway  /api/v1
   ├──► auth-service     workspaces, members, keys
   ├──► content-service  blocks, instances, sync
   ├──► strings-service  extract, translate, deliver
   └──► campaign-service segments, dispatch
             │
             ▼
        MongoDB  (per-tenant data)
i Multi-tenantEvery request is scoped to the tenant behind your key. Data never crosses workspace boundaries.

Content lifecycle

  1. Define, a developer adds a block type in code and registers it with POST /blocks. The cf push CLI that will do this from git is not published yet.
  2. Fill, someone creates an instance in the dashboard (or seeds it via /cards/sync).
  3. Target, pick a segment, locale, and channel.
  4. Publish, the block goes live; content.published fires.
  5. Sync, apps pull it on their next GET /sdk/sync, no redeploy.
  6. Roll back, revert instantly if needed; content.rolledback fires.

Guide

Traits & targeting

A trait is a fact your app tells us about a device or a person. A segment is a named audience defined by rules over those facts. An instance is targeted at one segment by name. This section walks the whole chain in the order you build it: send a trait → write a rule over it → create the segment → point an instance at it → prove it landed.

Read the warn notes even if you skim the rest. Four of them cover failures that return a success status and then reach nobody: an invalid trait key, a trait sent without consent, a typo in seg, and a renamed segment.

1. Send a trait

Traits are sent on POST /api/v1/sdk/identify, authenticated with your SDK key in X-CF-Key. On /sdk/* the key is the only required credential, and this call also needs X-CF-Device because it is about a device. The X-Tenant-Id in the example below is optional and redundant, since the key already names the workspace; send it and it must agree with the key, or the call is refused with 403 TENANT_KEY_MISMATCH. The traits body field is optional, and its keys are flat: nesting goes inside a value, never in a key.

curl
$ 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": "u_8812",
       "consent": true,
       "traits": { "region": "riyadh", "salary": 7200, "signupDaysAgo": 12 }
     }'

No declaration step exists. You do not register a trait before sending it, the first identify call that carries it is enough.

Rule for a trait keyDetail
Allowed charactersMust match ^[a-zA-Z0-9_]{1,64}$. Letters, digits and underscore only, 1 to 64 characters.
Dots and $ are rejecteduser.plan is not a valid trait key. Nest inside the value instead, never in the key.
Case sensitiveAge and age are two different traits.
CountMaximum 50 traits per identify call.
Value sizeEach value must serialize to 4096 bytes or fewer of JSON. Nested objects and arrays are allowed as values.
! An invalid trait key is dropped silently, and the call still returns 200A key that breaks the rules above is discarded on the way in. There is no error, no warning field, and nothing in the response names the key that was dropped. If a trait never shows up in targeting, an invalid key is the first thing to check.
! A trait sent without consent is discardedConsent is a hard gate. Nothing trait related is stored or forwarded unless consent is granted, so send consent: true on identify. Without it the call succeeds and the traits go nowhere, which reads exactly like a targeting bug later.

What the traits field does when you send a real set, leave it out, or send something odd:

You sendWhat happens
A non-empty objectReplaces the whole stored trait set. It does not merge, so send every trait you still want on every call. A trait you stop sending is gone.
traits omittedLeave the stored traits unchanged.
"traits": {}Leave the stored traits unchanged. An empty object means nothing to report, not clear what you have.
null, or a malformed value such as an array, a string or a numberLeave the stored traits unchanged. A malformed value is a no-op, not an error.
! There is no way to clear a device's traits from identifyEvery shape above either replaces the set with the one you sent or leaves it alone. An empty object used to clear it and no longer does, because the clients that send {} are almost always apps with nothing to report on that call rather than apps asking to erase a profile. To erase a device's data outright, including its traits, call DELETE /api/v1/sdk/identify.
i There is no userId gateAn anonymous device that granted consent still gets a stored profile, keyed on its device id. A userId is not required for a device to be targeted.

2. Build a rule from the trait

Inside a segment rule a trait is addressed as customAttributes.<traitKey>. A trait called plan is matched with the field customAttributes.plan.

A rule is either a leaf or a group. Groups nest.

rule shapes
// leaf
{ "field": "customAttributes.plan", "operator": "equals", "value": "gold" }

// group, logicalOperator is "AND" or "OR"
{
  "logicalOperator": "OR",
  "conditions": [
    { "field": "customAttributes.plan", "operator": "equals", "value": "gold" },
    { "field": "customAttributes.salary", "operator": "greater_than", "value": 3000 }
  ]
}
OperatorWhat it means for a trait
equalsStrict comparison against the stored value. The string "30" does not equal the number 30, so send numeric traits as numbers.
not_equalsTrue when that same strict comparison fails.
greater_thanNumeric. Both sides coerce with Number(). A value that is not numeric evaluates to false rather than erroring.
less_thanNumeric, same coercion and the same quiet false on a non-numeric value.
greater_than_or_equalNumeric, inclusive at the boundary.
less_than_or_equalNumeric, inclusive at the boundary.
betweenNeeds a two element [min, max] array in value, and is inclusive at both ends.
containsCase sensitive. On a string value it tests substring; on an array value it tests membership.
not_containsThe inverse of contains, and case sensitive in the same way.
inNeeds an array in value. True when the trait is one of its entries.
not_inNeeds an array in value. True when the trait is none of its entries.
existsTrue when the trait key is present on the profile, whatever its value.
not_existsTrue when the trait key is absent.
regexPattern match against the trait value.
not_regexTrue when the pattern does not match.
starts_withCase sensitive prefix test.
ends_withCase sensitive suffix test.
! The date operators do not work on traitsdate_after, date_before and days_ago are not implemented for trait fields. On a customAttributes.* field they evaluate to false, so a rule built on them saves cleanly and then matches nobody. They work on the built in behavioural fields, not on traits. Model a date as a number you compute in your app, the way signupDaysAgo does.

Fields that are not traits carry their own prefix: demographic.*, behavioral.*, device.*, consent.*, location.*, metadata.* and enrichment.*. Note that POST /sdk/identify does not populate demographic.*: identify sets traits, name, email, phone and the device id only.

3. Create the segment

Over HTTP a segment is created with POST /api/v1/segments, using a portal JWT plus X-Tenant-Id, and an admin or editor role. name is 1 to 100 characters and rules needs at least one entry.

curl
$ curl -X POST https://app.contentflow.click/api/v1/segments \
     -H "Content-Type: application/json" \
     -H "Authorization: Bearer $CF_PORTAL_JWT" \
     -H "X-Tenant-Id: ws_a1b2c3d4" \
     -d '{
       "name": "Riyadh gold",
       "logicalOperator": "AND",
       "rules": [
         { "field": "customAttributes.region", "operator": "equals", "value": "riyadh" },
         { "field": "customAttributes.plan",   "operator": "equals", "value": "gold" }
       ]
     }'
! An SDK key can never create a segmentThis route is not reachable with X-CF-Key. Creating a segment takes a portal JWT plus X-Tenant-Id and an admin or editor role, which means it is a dashboard or backend operation, not something your mobile app can do at runtime.

In the dashboard the same thing lives under Audience, Segments.

Five built in trait predicates

Independently of any segment you create, delivery evaluates five fixed predicates against the traits on the device. They are compiled in and are not workspace configurable. You can put these names straight into an instance's seg with no segment created.

Segment nameTrait it readsCondition
All usersnoneAlways true.
Salary > 3,000 SARsalaryNumeric value greater than 3000.
New users (< 30 days)signupDaysAgoNumeric value less than 30.
Riyadh regionregionEquals "riyadh", and this one comparison is case insensitive.
Lapsed userslastActiveDaysAgoNumeric value 30 or greater.

4. Target an instance at the segment

The instance field is seg. It holds one segment name as a string. It is not an id, and it is not an array, so an instance targets at most one segment. Set it with a partial update, which means a body carrying only seg changes only the targeting.

curl
$ curl -X PUT https://app.contentflow.click/api/v1/blocks/home_hero/instances/inst_4412 \
     -H "Content-Type: application/json" \
     -H "Authorization: Bearer $CF_PORTAL_JWT" \
     -H "X-Tenant-Id: ws_a1b2c3d4" \
     -d '{ "seg": "Riyadh region" }'
Value of segEffect
A segment nameThe instance is targeted at that one segment.
null or ""The instance becomes untargeted.
seg omitted from the bodyTargeting is left unchanged.

Requires a portal JWT plus X-Tenant-Id and an admin or editor role. Use title, not name: a body containing name is rejected with a 400. In the dashboard this is the block editor's target dropdown, and new instances default to All users.

! A typo in seg reaches nobody, and nothing tells youseg is never validated against an existing segment. Misspell it and you get a live, published instance targeted at an audience that does not exist. No error on the write, none at delivery, and the instance simply never appears for anyone.
! Renaming a segment orphans every instance targeting itA rename does not update the instances that target the segment by its old name. Those instances keep the stale string, silently stop reaching anyone, and still look correct in the editor. Treat a segment name as a contract: if you must rename one, repoint every instance that carried the old name in the same change.

How it resolves at delivery

An instance reaches a device when any one of these is true:

  • Its seg is one of the device's segment names.
  • seg is absent.
  • seg is the empty string.
  • seg is exactly All users.

The last three all mean untargeted. Matching is byte exact and case sensitive, so VIP and vip are different segments. A device with no segments sees only untargeted instances, which is a valid answer and not an error. A device gets no segments if it never called identify, or if its identify calls never reached granted consent. Delivery also filters on status and on schedule windows, independently of targeting.

Timing

Segment membership is recomputed when a trait value the write proposes actually differs from what is stored, so re-sending identical traits does not trigger a recomputation. A device's segment snapshot then refreshes at most once every 60 seconds. Expect roughly a minute for a targeting change to reach a device, not instant.

i A check run immediately after identify can legitimately be staleThat is the refresh window doing its job, not a bug. Give the assertion room for the interval, or retry until it has passed.

5. Prove it landed

Ask the platform which segments an identifier is in, rather than eyeballing a block that happened to render.

curl
$ curl https://app.contentflow.click/api/v1/read/users/u_8812/segments \
     -H "X-CF-Read-Key: rk_live_4f2ab91c77d0e35b8a61c204"
data
{
  "userId": "u_8812",
  "segmentNames": ["All users", "Riyadh region"]
}

It answers with names, the same strings an instance's seg holds, so your assertion compares like with like and needs no id lookup. The {userId} slot accepts whatever the profile is keyed on, so a device id works there too. The full contract is on the REST API reference.

What you getWhat it means
404No profile exists for that identifier. Check the identifier before you check your rules.
200 with an empty segmentNamesA known profile in zero segments. The identifier resolved, the rules did not match. Check your rules, not your identifier.
✓ Where the read key comes fromShaped rk_live_ plus 24 lowercase hex characters, issued under Dashboard, Developers, "Your keys" at app.contentflow.click, admin or editor only. It is a server and CI credential: never ship it inside an app.

Glossary

TermDefinition
KeyPublishable workspace credential, shaped <workspaceId>_app or <workspaceId>_test, issued under Dashboard, Developers, "Your keys". On /sdk/* it is passed in X-CF-Key, and that is the only required header, because the key already names the workspace. X-API-Key is an accepted alias for it on the same routes; if both are sent, X-CF-Key wins silently. X-Tenant-Id is optional and redundant on /sdk/*, and if you do send it, it must agree with the key or the call is refused with 403 TENANT_KEY_MISMATCH. Where X-Tenant-Id is required is portal JWT calls, a different surface with a different credential. X-CF-Device is needed only by the calls that are about a device: identify, register-push, events, track-event, consent. The suffix is the only environment signal. Older cf_live_ / cf_test_ examples no longer parse.
TenantA workspace. All data is scoped to it.
Block typeA UI shape defined in code, with a set of typed fields, registered over the REST API.
FieldOne typed slot inside a block type: text, textarea, image, number, select, toggle, or collection. The first six are scalar. Addressed by tag, never by index.
CollectionThe one non-scalar field type: an ordered list of references to other block instances. The block holding it is the wrapper; each referenced instance is a card, and delivery inlines every eligible card in full under the field's tag. A card is a block, so it carries its own targeting, schedule, approval state, variants and analytics. See Collections.
TagThe stable name a field is read by, such as #title_main. Renaming one breaks any app version already reading it.
Block instanceA filled block, managed in the dashboard or seeded via API.
externalIdStable id you set so sync stays idempotent (no duplicates).
Handshake, retired termThere is no handshake call, in this SDK or any other, and no bootstrap or init round trip of any kind. Older material named one; nothing written against it ever ran. The check that replaces it: call GET /api/v1/sdk/sync with your key and confirm the response is enveloped ({ success, data }) and carries data.blocks. That proves your key, workspace and environment resolve before you ship.
ChannelDelivery surface: push, WhatsApp, SMS, popup, in-app.
SegmentAn audience a block or campaign targets, defined by rules over traits and other profile fields. Created with POST /segments (portal JWT, admin or editor) or under Audience, Segments in the dashboard. Referenced everywhere by its name, never by an id.
TraitA fact your app sends about a device or person, on POST /sdk/identify in the traits object. Key must match ^[a-zA-Z0-9_]{1,64}$, 50 per call, each value 4096 bytes of JSON or fewer. Stored only when consent is granted, and dropped silently when the key is invalid.
Segment ruleOne node of a segment's logic. Either a leaf, { field, operator, value }, or a group, { conditions, logicalOperator } where logicalOperator is AND or OR. Groups nest. See the operator table.
customAttributesThe field prefix a trait is addressed by inside a segment rule. The trait plan is matched as customAttributes.plan. Sibling prefixes for non-trait fields are demographic.*, behavioral.*, device.*, consent.*, location.*, metadata.* and enrichment.*.
segThe block instance field that holds targeting: one segment name, as a string. Not an id, not an array, so an instance targets at most one segment. null, "", absent, or exactly All users all mean untargeted. Never validated against an existing segment, so a typo reaches nobody in silence.
Read keyServer-side credential shaped rk_live_ plus 24 lowercase hex characters, passed as X-CF-Read-Key. Issued under Dashboard, Developers, "Your keys", admin or editor only. Reads back what the platform knows, such as a user's segment names. Never ship it inside an app.

FAQ

Do I need to redeploy my app to change content?

No. Publishing an instance makes it live on the next GET /api/v1/sdk/sync your app performs, with no app release, no store review and nothing to redeploy. That's the whole point. The quickstart shows the call.

Which SDK can I actually use today?

None of them from a package registry. The Web and React Native packages are written and tested but unpublished (npm 404). The Swift package builds and passes its tests but has no public Git URL or SPM tag, so it is a local package only. The Kotlin package is source that has never been compiled. Hand-writing a REST client against the REST API is the intended and supported path on every platform today, not a workaround.

Why is my block empty, or stuck on old content?

Two usual causes. A mismatched key or environment, confirm the key prefix matches the environment you expect. Or a wrong base URL: the retired api.contentflow.click/v1 host still answers 200 with frozen content instead of failing, so an app pointed at it looks healthy while never receiving anything you publish. The only supported base URL is https://app.contentflow.click/api/v1. See Base URL.

Why didn't my SMS / WhatsApp campaign deliver?

The audience likely had zero contacts with phone + consent, or the tenant has no configured Twilio channel. See Campaigns.

Can non-developers manage content?

Yes. Developers define block types in code; anyone can create and edit instances in the dashboard.

← Back to docs Home