Why a state machine

Swappr onboarding collects four distinct pieces of information — a tenancy document, a current-home listing, photos plus a description, and matching preferences — before a user is allowed into the matching surface. The flow is deliberately server-driven rather than client-driven so that:
  • A user can drop off mid-flow (kill the app, switch device, swap a SIM) and resume from exactly the right step.
  • The frontend cannot accidentally submit step N+1 before step N has been persisted.
  • The audit trail (who completed which step, when) is unambiguous on the server.

The four steps

StepEndpointPersistence targetOutput
1POST /onboarding/step-1-tenancytenancy_verificationscurrentStep = 2
2POST /onboarding/step-2-current-homecurrent_homescurrentStep = 3
3aPOST /onboarding/step-3-photoscurrent_homes.photos[] (append)currentStep stays 3
3bPOST /onboarding/step-3-descriptioncurrent_homes.descriptioncurrentStep = 4 (once both 3a and 3b done)
4POST /onboarding/step-4-desired-homeuser_preferencesonboardingStatus = COMPLETE, currentStep = null
Step 3 has two sub-endpoints because photos are typically uploaded one batch at a time on slow mobile connections, while the description is a single short text submit. The user remains on currentStep = 3 until both have been recorded.

The resume oracle

GET /onboarding/state is the resume oracle. The mobile client calls it:
  • On app launch, before deciding which screen to render.
  • After every reconnect from background.
  • After any 5xx or STATE_CONFLICT it can’t otherwise explain.
The response carries onboardingStatus, currentStep, and tenancyStatus. The client uses currentStep to navigate to the right step screen; once onboardingStatus === "COMPLETE" the client drops the user into the main app and never calls the onboarding step endpoints again. tenancyStatus is reported alongside because it can flip independently (the admin queue approves or rejects the document after step 1 has been submitted). tenancyStatus === "APPROVED" is one of the four publish eligibility gates.

Step-out-of-sequence enforcement

Each step endpoint asserts that users.currentStep equals the expected value for that step before it will write. If it doesn’t, the request is rejected with 409 STATE_CONFLICT:
{
  "type": "https://api.swappr.co.uk/errors/state-conflict",
  "title": "State conflict",
  "status": 409,
  "code": "STATE_CONFLICT",
  "detail": "Expected step 2, current is 3"
}
Two failure modes produce this error in practice:
  1. The client tried to skip ahead. A buggy app calling step 3 while the user is still on step 2. The fix is for the client to call GET /onboarding/state and re-render.
  2. The user already completed this step on another device. A user installs the app on a new phone and the local state thinks they are on step 2, but the server has already advanced them to step 3 from the original device. Again: GET /onboarding/state is the cure.
Once onboardingStatus === "COMPLETE", all five step endpoints return 409 STATE_CONFLICT permanently. Edits from that point go through /current-home/me/* and /preferences/me.

Diagram

Why requireOnboarded middleware gates downstream features

Several Phase 3+ surfaces — match candidates, conversation creation, post-share — are wrapped in a requireOnboarded middleware that returns 403 FORBIDDEN when onboardingStatus !== "COMPLETE". The reasoning:
  • A user without a current-home listing cannot meaningfully be matched against anyone.
  • A user whose tenancy document has not been uploaded cannot prove they have a tenancy to swap.
  • Letting a half-onboarded user into chat creates abuse vectors.
This is enforced server-side so a buggy or malicious client cannot bypass it.

See also