Journeys and the decide endpoint
Multi-stage onboarding, and one call that says what a user should see right now.
Journeys
A journey is an ordered list of stages. Each stage is a form (or an experiment entry slug) with entry rules. A stage is done when the user has a completed response to it. The first stage that is not done and whose rules hold is the current one.
bash
curl -X POST https://YOUR_HOST/api/v1/journeys \
-H "Authorization: Bearer $FORMFLOW_API_KEY" -H "Content-Type: application/json" \
-d '{
"name": "Activation",
"status": "active",
"definition": { "stages": [
{ "id": "welcome", "name": "Welcome", "entrySlug": "first-launch" },
{ "id": "first_project", "name": "After first project", "formId": "frm_2", "after": { "event": "first_project_created", "days": 0 } },
{ "id": "admin_setup", "name": "Admin setup", "formId": "frm_3", "after": { "stage": "welcome", "days": 3 },
"when": { "type": "condition", "source": { "kind": "attribute", "name": "role" }, "operator": "eq", "value": "admin" } }
] } }'| Entry rule | Meaning |
|---|---|
after.stage | That stage must be completed first. |
after.event | The event must have been reported (or seen in PostHog when the journey has an eventSourceId and the uid is signed). |
after.days | Wait this long after the event, else after the stage, else after the user was first seen. |
when | Attribute conditions, for example role = admin. |
Report events
bash
curl -X POST https://YOUR_HOST/api/v1/journeys/jrn_1/events \
-H "Authorization: Bearer $FORMFLOW_API_KEY" -H "Content-Type: application/json" \
-d '{"uid":"u_42","event":"first_project_created"}'GET /journeys/{id}/analytics returns, per stage, users who started, completed and the conversion from the previous stage.
Decide
POST /public/onboarding/decide needs no API key. It is rate limited per IP and per workspace.
bash
curl -X POST https://YOUR_HOST/api/v1/public/onboarding/decide \
-H "Content-Type: application/json" \
-d '{"entry":"first-launch","uid":"u_42","uid_sig":"9f2c...","attributes":{"plan":"pro"},"ph_distinct":"phc_123"}'json
{ "data": {
"show": true, "reason": "assigned",
"experimentId": "exp_1", "variantId": "short", "formId": "frm_9", "slug": "onboarding-short", "versionId": "fv_4",
"journeyStep": { "journeyId": "jrn_1", "stageId": "welcome", "name": "Welcome", "index": 0, "total": 3 },
"resume": { "token": "...", "submissionId": "sub_5", "progress": { "answered": 2, "total": 6, "percent": 33 } },
"definition": { "...": "published form, sensitive config removed" },
"attributes": { "plan": "pro" }, "known": { "company_q": "Acme" } } }| Field | Notes |
|---|---|
entry | An experiment entry slug, a journey id (jrn_...) or a form slug. The workspace is derived from it. |
show: false | Holdout (reason: "holdout"), already completed, nothing due in a journey, paused or closed. Show nothing and go on. |
resume | Only with resumeByUid and a usable uid. Issues a new token. Send restart:true to discard, resume:false to skip. |
definition | The published form for native rendering. No password hash, coupon codes or lookup wiring. Send definition:false to omit. |
Backends can call POST /onboarding/decide with an API key; the uid is trusted so no uid_sig is needed.
From code
ts
import { FormFlowClient } from "@formizeflow/sdk";
const ff = new FormFlowClient({ apiKey: process.env.FORMFLOW_API_KEY!, baseUrl: "https://forms.example.com" });
const d = await ff.public.decide({ entry: "first-launch", uid: user.id, uid_sig: sig, attributes: { plan: user.plan } });
if (d.show) render(d.definition!, { variant: d.variantId, resume: d.resume });
await ff.onboarding.journeys.event("jrn_1", { uid: user.id, event: "first_project_created" });Mobile SDKs
swift
// iOS
let req = FormFlowDecideRequest(entry: "first-launch", uid: user.id, uidSignature: user.formflowSig, attributes: ["plan": user.plan])
let d = try await client.decide(req) // then d.config(from: base) for the WebViewkotlin
// Android
val req = DecideRequest("first-launch", uid = user.id, uidSignature = user.formflowSig, attributes = mapOf("plan" to user.plan))
val config = runCatching { client.decide(req) }.getOrNull()?.config(base) // null = holdout, done, or offlinets
// React Native
const d = await client.decide({ entry: "first-launch", uid: user.id, uidSignature: user.formflowSig, attributes: { plan: user.plan } });
const props = decisionToProps(d); // null = holdout or already donedart
// Flutter
final d = await client.decide(entry: 'first-launch', uid: user.id, uidSignature: user.formflowSig);
final config = d.config(base);The SDKs send
uidSignature as uid_sig, attributes as attrs.<key> and the PostHog ids as ph_session and ph_distinct. Compute the signature on your server, never in the app.