Skip to content

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 ruleMeaning
after.stageThat stage must be completed first.
after.eventThe event must have been reported (or seen in PostHog when the journey has an eventSourceId and the uid is signed).
after.daysWait this long after the event, else after the stage, else after the user was first seen.
whenAttribute 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" } } }
FieldNotes
entryAn experiment entry slug, a journey id (jrn_...) or a form slug. The workspace is derived from it.
show: falseHoldout (reason: "holdout"), already completed, nothing due in a journey, paused or closed. Show nothing and go on.
resumeOnly with resumeByUid and a usable uid. Issues a new token. Send restart:true to discard, resume:false to skip.
definitionThe 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 WebView
kotlin
// 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 offline
ts
// 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 done
dart
// 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.