This guide explains how to configure and test onboarding journeys for different user personas.
The API uses role-based access control:
Operator: manage flows, customers, sessions, and webhooksApplicant: run onboarding sessions, submit steps, and access owned sessionsReadOnly: read-only access to operator endpoints where permitted by policy
X-Api-Key maps a request to a full Operator principal. It is for server-to-server callers and
local API exploration only — never send it from a browser, where it would hand every visitor
operator access. Browser-based callers use the credentials in section 4 instead.
At API startup, development seed data creates a flow with ID:
11111111-1111-1111-1111-111111111111
Use:
POST /api/flowsPUT /api/flows/{flowId}GET /api/flows/{flowId}
Flow nodes support:
FormDocumentUploadRedirectInformationLogic
Connection conditions use operators from ConditionOperator (for example: Equals, NotEquals, GreaterThan, Contains).
Create representative customer profiles with:
POST /api/customers
Use external IDs and metadata to model persona variants (for example: region, risk tier, business type).
- Endpoint:
POST /api/workflow/sessions/start - Payload:
flowIdplus optionalcustomerProfileId - Anonymous. This is the entry point of a public journey, so there is no credential to present yet.
The response carries applicantToken — a bearer token scoped to the single session it names — plus
applicantTokenExpiresAt. Send it as Authorization: Bearer <token> on every subsequent request
for that session. It carries no operator rights, so a leaked one exposes the journey its holder was
already completing and nothing else.
An operator who starts a session gets no token back: their own credential already covers it.
- Endpoint:
POST /api/workflow/sessions/{sessionId}/steps/{nodeId}/submit - Payload format:
{ "payload": { ... } } - Response:
sessionId,isCompleted,currentNode(null once complete), and a renewedapplicantToken
Store the renewed token in place of the one you hold. Activity is what extends the credential, so a long journey never outlives its own token — but only if the caller keeps the renewal.
| Situation | Result |
|---|---|
| Token renewed on each step submission | Journey stays alive indefinitely while being worked on |
Token expired (Authentication:ApplicantToken:LifetimeMinutes, default SessionTimeoutMinutes) |
401 |
Session reached Completed or Abandoned |
Writes refused with 403; reads still work so a completion screen can render |
A client should treat 401 and 403 on a session it was completing as the same dead end — the
credential is spent and the journey must be restarted — and distinguish both from a 5xx, which is
retryable.
- Endpoint:
POST /api/workflow/sessions/{sessionId}/steps/{nodeId}/documents - Multipart field name:
files - File size limit controlled by
DocumentUpload:MaxFileSizeBytes - Accepted types and a maximum file count come from the node's
jsonContent
- Endpoint:
GET /api/workflow/sessions/{sessionId}/steps/{nodeId}/documents/{fileId}
Both document endpoints are scoped to the session in the route. A token for one session cannot
upload to or read from another. The download additionally scopes the lookup: a fileId resolves
only when an upload recorded against that session and node carries it, so holding another
applicant's file id does not make it readable through your own session. A file that exists but
belongs elsewhere returns 404, never 403.
- Next step polling endpoint:
GET /api/workflow/sessions/{sessionId}/next - SSE stream endpoint:
GET /api/workflow/sessions/{sessionId}/events - Session details:
GET /api/workflow/sessions/{sessionId}
For each persona, validate:
- Start node selection is correct.
- Compliance validation blocks invalid payloads.
- Conditional transitions route to expected nodes.
- Document uploads respect type/size constraints.
- Session reaches expected terminal status (
CompletedorAbandoned).
Operators can register callbacks:
POST /api/flows/{flowId}/webhooksGET /api/flows/{flowId}/webhooksDELETE /api/flows/{flowId}/webhooks/{webhookId}GET /api/flows/{flowId}/webhook-deliveries
Validation points:
session.completedwebhook fires at completion- Signature header is present:
X-Webhook-Signature - Retry behavior is visible in delivery history on transient failure
In the frontend .env.local file, one value is needed:
VITE_API_BASE_URL— backend host, for examplehttp://localhost:5072
Do not set an API key. Vite inlines every VITE_* variable into the built bundle as a literal
string, and Authentication:ApiKey maps to a full Operator principal — so a key configured here
ships to every visitor of the public onboarding page. The app needs no credential of its own: the
journey starts anonymously and is handed a token scoped to that session (section 4), and operator
screens use the SSO cookie.
The frontend includes:
- Visual Journey Builder (admin UI for creating and editing flows — see section 10)
- JourneyBuilder (read-only React Flow graph showing branch-path progress during an active session)
- StepRenderer (schema-driven form/doc upload/redirect/info/logic rendering)
Use these components for manual exploratory testing and branch-path verification.
| Route | Audience | Credential |
|---|---|---|
/ (/?flowId=<id> selects a journey) |
Applicant | None to start; the per-session token thereafter |
/admin |
Operator | SSO cookie |
/admin/journey-builder |
Operator | SSO cookie |
Operator surfaces render only behind the SSO check. Nothing on the applicant route reads operator data, and the applicant page never calls an operator endpoint.
Open /admin → Sessions → (a session). Two complementary views sit side by side:
- Submissions — what the applicant entered, per step.
- Event trail — how they got there: events in the order they occurred, each with its type, step, absolute timestamp, source, payload summary, and the gap since the previous event. The gap is what makes a stall visible.
Reading the trail:
- Events are labelled
serverorclient. Client events are self-reported by the applicant's browser and can be absent entirely — an ad blocker or a tab closed before the batch flushed stops them reaching the API. A trail showing only server events is normal, and the panel says so. - An empty trail means either that no events were raised or that they aged past
Analytics__RetentionDaysand were deleted. After the retention sweep the two are indistinguishable, so treat an empty panel on an old session as expected rather than as data loss.
Aggregate drop-off in the flow analytics view is still derived from session status and submissions rather than from this event stream, so per-step timing is available per session but not across a flow.
Limits are partitioned per caller, so one client exhausting a budget does not affect another. A
rejected request returns 429 with Retry-After: 60.
| Policy | Applies to | Partitioned by | Default (per minute) |
|---|---|---|---|
session-start |
POST /api/workflow/sessions/start |
Client IP | 100 |
analytics-ingest |
POST /api/analytics/events |
Applicant session | 120 |
webhook-registration |
webhook registration | Authenticated principal | 20 |
general |
every other controller endpoint | Authenticated principal, else client IP | 300 |
| global ceiling | every request | not partitioned | 3000 |
Two things to know when load-testing:
- Callers sharing a credential share a partition. A test harness using one API key spends one
generalbudget across all of it. - Limits are per-instance, not cluster-wide, and are disabled entirely in the
Testingenvironment.
Configure any of them with RateLimiting__<Key>. See
the runbook for proxy configuration and multi-replica behaviour.
The Visual Journey Builder is a drag-and-drop admin interface for designing and publishing onboarding flows without touching raw JSON.
Navigate to /admin/journey-builder. The route is protected by SSO: users must authenticate with the Operator role. Unauthenticated requests are redirected to /login with a returnUrl parameter.
| Action | How |
|---|---|
| Move a node | Drag it to the desired position |
| Create a connection | Drag from a node's bottom handle to another node's top handle |
| Select a node or edge | Click it — the Properties Panel opens on the right |
| Delete a selected node or edge | Press Delete or Backspace, or use the Delete button in the Properties Panel |
| Zoom / pan | Scroll to zoom; drag the background to pan; use the Controls toolbar |
Use the node palette above the canvas. Each button adds a new node of the corresponding type at a default position:
| Type | Description |
|---|---|
| Form | Renders a dynamic form from jsonContent field definitions |
| DocumentUpload | Renders a file picker with type/size constraints |
| Redirect | Navigates the customer to an external URL (supports {{token}} interpolation) |
| Information | Displays a message; no submission required |
| Logic | Executes a server-side action automatically (e.g., SetProfileField, HttpCallback) |
Clicking a node opens the Properties Panel with editable fields:
- Title — display label shown to the end user
- Key — stable slug identifier used in routing and URL interpolation
- Type — dropdown selector; changing type does not clear
jsonContent - Start node — only one node per flow can be the start node; checking this box automatically clears the flag on any other node
jsonContent— JSON string carrying type-specific configuration (fields, upload config, redirect URL, action definition)- Compliance rules — optional
complianceRuleJsonfor server-side validation at submission time
Clicking an edge opens the edge's condition fields:
- Condition field, operator, value — define the routing predicate evaluated against the step payload
- Priority — lower values are evaluated first; leave
conditionFieldempty for a fallback (unconditional) edge
| Button | Effect |
|---|---|
| Load flow | Fetches an existing flow by ID and hydrates the canvas |
| Create new | POSTs the current draft to /api/flows and assigns the returned ID |
| Save new version | PUTs the current draft to /api/flows/{id}, incrementing the version counter |
| Reset | Clears the canvas back to a single empty start node |
Validation errors (missing flow name, invalid GUIDs, no start node, broken connection references) are shown inline before any save is attempted.