Skip to main content

User Onboarding

Overview

After account creation, users progress through a configurable series of onboarding steps. Each step represents a task the user must complete (or that can be skipped by platform configuration) before they are fully onboarded. The onboarding system is:
  • Step-based — a linear sequence of steps, each with a status
  • Configurable — steps can be enabled or disabled per-user by the platform
  • Idempotent — submitting the same step twice is a no-op, not an error
  • Event-sourced — every transition is recorded as an auditable event

Quick Start

1. Create an account

curl -X POST /v0/user/ \
  -H "x-juno-jwt: <token>" \
  -H "x-juno-orgid: <your-org-id>" \
  -H "Content-Type: application/json" \
  -d '{
    "primary_eoa_address": "0x742d35Cc6634C0532925a3b844Bc9e7595f2bD78",
    "chain_id": 1329
  }'
Onboarding starts automatically. The response includes the user profile (201 Created).

2. Check onboarding state

curl /v0/user/me/onboarding/steps \
  -H "x-juno-jwt: <token>" \
  -H "x-juno-orgid: <your-org-id>"
The response shows the current step, all steps with their status, and whether onboarding is complete.

3. Complete steps

For each step that requires user action, submit it when the user finishes:
curl -X POST /v0/user/me/onboarding/steps \
  -H "x-juno-jwt: <token>" \
  -H "x-juno-orgid: <your-org-id>" \
  -H "Content-Type: application/json" \
  -d '{"step": "phone_verification"}'

4. Onboarding complete

When all steps are done, is_complete becomes true and current_step becomes "complete".
curl /v0/user/me/onboarding/steps \
  -H "x-juno-jwt: <token>" \
  -H "x-juno-orgid: <your-org-id>"
# → { "onboarding": { "current_step": "complete", "is_complete": true, ... } }

Onboarding State

Reading State

Fetch the current onboarding state from the dedicated resource:
GET /v0/user/me/onboarding/steps

Response Shape

{
  "onboarding": {
    "current_step": "kyc_verification",
    "is_complete": false,
    "steps": [
      { "step": "phone_verification", "status": "completed", "gated": true, "meta": null },
      { "step": "kyc_verification",   "status": "current",   "gated": true, "meta": { "kyc_mode": "websdk" } },
      { "step": "open_banking",       "status": "pending",   "gated": true, "meta": null },
      { "step": "card_setup",         "status": "pending",   "gated": true, "meta": null },
      { "step": "feature_selection",  "status": "pending",   "gated": true, "meta": null }
    ]
  },
  "_links": { ... }
}
The steps array only contains steps in the user’s cohort flow. A consumer (no org_id) and AGENT_CREATE2 user see five steps. A BYO user sees six (plus byo_safe); a USER_SIGNED_DEPLOY user sees six (plus safe_deploy). Clients should iterate the array rather than indexing into specific positions.
FieldTypeDescription
current_stepstringThe step the user is currently on, or "complete"
is_completebooleantrue when all steps are finished
stepsarrayAll onboarding steps in order
steps[].stepstringStep identifier
steps[].statusstringOne of: pending, current, submitted, completed, skipped
steps[].gatedbooleanWhether this step is configurable (can be skipped by the platform)
steps[].metaobject or nullStep-specific configuration metadata (see Step Metadata)

Step Statuses

StatusMeaning
pendingStep has not been reached yet
currentUser is on this step now — action required
submittedUser submitted this step (transient — auto-advances immediately)
completedStep finished successfully
skippedStep was skipped by platform configuration

Step Metadata

Each step exposes a meta field. When a step has no per-user configuration to communicate, meta is always null — clients can treat meta: null as “no metadata for this step” rather than “metadata pending”.

Per-step meta shape

Stepmeta keysNotes
phone_verificationAlways null.
kyc_verificationkyc_modeAlways populated; see below.
byo_safechain_idOnly present in the BYO cohort flow; chain_id populated when the user has a primary_chain_id (see Wallet cohort).
safe_deploychain_idOnly present in the USER_SIGNED_DEPLOY cohort flow; chain_id populated when the user has a primary_chain_id (see Wallet cohort).
open_bankingAlways null.
card_setupAlways null.
feature_selectionAlways null.
The byo_safe and safe_deploy entries only appear in the response for users in their respective cohorts. A consumer or AGENT_CREATE2 user will never see them in the steps array.

KYC Verification Meta

The kyc_verification step is where a user earns their Sigil (Proof of Personhood) — completing it is what unlocks the identity downstream steps read off.
KeyValuesDescription
kyc_mode"websdk", "hybrid", "document_only"Which verification flow to render
  • websdk — use the full embedded verification SDK (default)
  • hybrid — use native API document upload with SDK-based liveness check only
  • document_only — use native API document upload with no liveness check or SDK required
The kyc_mode value is selected per integration server-side. Your frontend should read this value and render the appropriate verification flow. See the KYC guide for integration details, including how partner organisations can override the mode per environment.

State Machine

Any gated step that is disabled will be automatically skipped during advancement. The state machine jumps to the next enabled step (or to complete if all remaining steps are skipped).

Wallet cohort: byo_safe vs safe_deploy

Every onboarded user gets a Safe smart wallet, but how the Safe gets there depends on which SIS environment features are enabled for the user’s organisation. The byo_safe and safe_deploy steps are mutually exclusive — for any given user, at most one of them is current; the other is skipped.
AI_AGENTUSER_SIGNED_DEPLOYbyo_safesafe_deployWhat runs
enableddisabledskippedskippedSumvin’s signing service deploys a Safe with an agent signer attached. No user action.
disabledenabledskippedrequiredThe user signs the Safe-deployment UserOp themselves (see Safe user operations).
disableddisabledrequiredskippedThe user submits the address of a Safe they already control on-chain.
enabledenabledRejected at SIS feature-write — SIS-409-008. See Environment features.
Users without an org_id skip both — they’re outside the SIS-managed cohort.

Submitting the Safe step

The Safe step uses one polymorphic endpoint that branches on the user’s cohort:
GET  /v0/user/me/onboarding/safe
POST /v0/user/me/onboarding/safe
GET returns the user’s mode (byo, user_signed_deploy, or agent_create2) along with a cohort-specific config block and the persisted wallet once available. config is always present and its shape matches mode; inner fields may be null when no work is currently prepared (e.g. a user_signed_deploy config returns user_operation: null, user_op_hash: null, predicted_safe_address: null until preparation runs). The POST body is a shape-only payload — the cohort is derived server-side from the user’s SIS environment, so the body does not carry a mode field. Submitting a payload whose shape doesn’t match the server-resolved cohort returns 409 (see Cohort mismatch below).

mode=byo

curl -X POST https://api.sumvin.com/v0/user/me/onboarding/safe \
  -H "x-juno-jwt: <token>" \
  -H "x-juno-orgid: <your-org-id>" \
  -H "Content-Type: application/json" \
  -d '{"address": "0x..."}'
Sumvin verifies on-chain that the address is a Safe owned by the user’s primary EOA, persists the wallet in completed, and auto-advances onboarding. Resubmission with a different address replaces the previously-submitted Safe — the previous row is soft-deleted (deleted_at set, is_primary cleared) so the audit trail is preserved. A submission for a user already past this step returns 409 with ONB-409-002 (Onboarding Step Already Completed).

mode=user_signed_deploy

GET returns the prepared sponsored UserOp envelope when the step is current and no Safe is persisted yet (config.user_operation, config.user_op_hash, config.predicted_safe_address). When the step has not yet been prepared, the same config envelope is returned with those inner fields set to null:
{
  "mode": "user_signed_deploy",
  "config": {
    "mode": "user_signed_deploy",
    "chain_id": 1329,
    "user_operation": null,
    "user_op_hash": null,
    "predicted_safe_address": null,
    "user_eoa": null,
    "salt_nonce": null
  },
  "wallet": null,
  "_links": { ... }
}
Once prepared, the user signs the hash with their EOA and replays it to POST:
curl -X POST https://api.sumvin.com/v0/user/me/onboarding/safe \
  -H "x-juno-jwt: <token>" \
  -H "Content-Type: application/json" \
  -d '{
    "user_operation": { "...": "...", "signature": "0x<user-eoa-signature>" },
    "expected_user_op_hash": "0x..."
  }'
Sumvin forwards the signed UserOp to the bundler and persists a UserWallet row immediately in pending so downstream features (like DID minting) can target the predicted address. Bundler inclusion promotes the row to completed via the status route below; revert flips it to failed. After submission, clients poll the on-chain UserOp status. While a UserOp is in flight, both the GET and POST responses surface a _links.user_op_status HAL link pointing to /v0/safe/rpc/{user_op_hash}/status — clients should consume that link rather than constructing the URL manually. See Submitting UserOperations for the full polling protocol — note especially the 503 + Retry-After semantics when finalisation hits a transient downstream error.

mode=agent_create2

The agent signer workflow creates the Safe automatically; the user has nothing to submit. GET returns the predicted Safe address once the agent signer is provisioned. POST is a uniform 204 No Content no-op so clients can use a single “submit” pattern across all cohorts:
curl -X POST https://api.sumvin.com/v0/user/me/onboarding/safe \
  -H "x-juno-jwt: <token>" \
  -H "Content-Type: application/json" \
  -d '{}'
# → HTTP 204 No Content (empty body)
Clients should not parse the response body for this cohort. The current state is read from GET.

Idempotency

POST accepts an optional Idempotency-Key header (max 255 characters). Retrying the same request with the same key returns the original cached response; reusing a key with a different request body returns 409 with SAF-409-001 (Idempotency Conflict). The cache window is 24 hours.
curl -X POST https://api.sumvin.com/v0/user/me/onboarding/safe \
  -H "x-juno-jwt: <token>" \
  -H "Idempotency-Key: 4f8e3b21-9d2c-4a7e-bb5f-1c8a09e2d3f1" \
  -H "Content-Type: application/json" \
  -d '{"address": "0x..."}'
Generate a fresh UUID per logical submission attempt and reuse it across retries until the request resolves successfully.

Cohort mismatch

If the submitted payload shape does not match the server-derived cohort (typically a stale client cache after an SIS feature flip — e.g. a BYO-shaped {"address": "0x..."} for a user the server resolved as agent_create2), the route returns 409 with ONB-409-003 (Onboarding Cohort Mismatch). Clients should re-fetch GET to learn the new cohort and re-submit.

Rate limit

GET /v0/user/me/onboarding/safe is rate-limited per authenticated user — 60 requests per 60 seconds. Polling clients should respect the limit and back off on 429.

Endpoints

Check Onboarding State

GET /v0/user/me/onboarding/steps
Returns the computed onboarding state. Pure read — does not record events or mutate state.
Building a React UI? useOnboardingSteps() from our query patterns handles auth gating via useAuthEnabled() and shares the cached state across components.
Response: 200 OK — see Response Shape above.

Submit Step Completion

POST /v0/user/me/onboarding/steps
Use when the user has completed an action for the current step (e.g. verified their phone, completed KYC, made a selection).
In React, useSubmitOnboardingStep() from our mutation patterns wraps this call and invalidates the cached onboarding query on success — no manual refetch needed.
Request body:
{
  "step": "phone_verification"
}
Response: 200 OK for synchronous transitions, 202 Accepted when the submission enqueues background provisioning work (creation flow, KYC completion). Body shape is identical in both cases:
{
  "onboarding": {
    "current_step": "kyc_verification",
    "is_complete": false,
    "steps": [
      { "step": "phone_verification", "status": "completed", "gated": true, "meta": null },
      { "step": "kyc_verification",   "status": "current",   "gated": true, "meta": { "kyc_mode": "websdk" } },
      { "step": "open_banking",       "status": "pending",   "gated": true, "meta": null },
      { "step": "card_setup",         "status": "pending",   "gated": true, "meta": null },
      { "step": "feature_selection",  "status": "pending",   "gated": true, "meta": null }
    ]
  },
  "_links": { ... }
}
The steps array only contains steps in the user’s cohort flow. A consumer (no org_id) and AGENT_CREATE2 user see five steps. A BYO user sees six (plus byo_safe); a USER_SIGNED_DEPLOY user sees six (plus safe_deploy). Clients should iterate the array rather than indexing into specific positions. Behaviour:
  • Records a step_submitted event, then immediately advances to the next step
  • If step does not match the user’s current step, returns 409 Conflict with the user’s current step in the response body so the client can resync without a follow-up read
  • Calling submit on an already-completed user is a safe no-op that returns the terminal state
  • On a 202 Accepted, the response includes a Retry-After header (seconds) recommending the next poll cadence for GET /v0/user/me/onboarding/steps

Get Event History

GET /v0/user/me/onboarding/events
Returns the full chronological audit trail of onboarding events. Response: 200 OK
{
  "events": [
    {
      "step": "phone_verification",
      "event_type": "step_entered",
      "from_step": "created",
      "duration_ms": null,
      "created_at": 1700000000000
    },
    {
      "step": "phone_verification",
      "event_type": "step_submitted",
      "from_step": null,
      "duration_ms": null,
      "created_at": 1700000060000
    },
    {
      "step": "phone_verification",
      "event_type": "step_completed",
      "from_step": null,
      "duration_ms": 60000,
      "created_at": 1700000060000
    },
    {
      "step": "kyc_verification",
      "event_type": "step_entered",
      "from_step": "phone_verification",
      "duration_ms": null,
      "created_at": 1700000060000
    }
  ],
  "_links": { ... }
}
FieldTypeDescription
stepstringThe step this event relates to
event_typestringOne of: step_entered, step_submitted, step_completed, step_skipped
from_stepstring or nullThe previous step at the time of transition
duration_msinteger or nullTime spent in the step (only on step_completed events)
created_atintegerEvent timestamp (epoch milliseconds)

Step Reference

StepDescriptionRelated EndpointsAuto-advances?
phone_verificationVerify phone number via SMS codeUpdate phone, then verify code — see belowYes — successful code verification auto-submits this step
kyc_verificationComplete identity verification (mode determined by meta.kyc_mode)Start KYC, then poll status — see belowYes — approved KYC status auto-submits this step
byo_safeSubmit a Safe wallet address you already controlPOST /v0/user/me/onboarding/safe — appears only when the user’s organisation is configured for Bring-Your-Own-SafeNo
safe_deploySign a deploy UserOp to deploy a new SafePOST /v0/user/me/onboarding/safe — appears only when the user’s organisation is configured for user-signed Safe deploymentYes — auto-advances after the bundler confirms the deploy
open_bankingConnect bank accountsExplicit submit onlyNo
card_setupSet up payment cardExplicit submit onlyNo
feature_selectionChoose platform featuresExplicit submit onlyNo
Endpoints referenced above:

Integration Patterns

Building an Onboarding UI

  1. After account creation, fetch onboarding state.
  2. Find the step with "status": "current" and render that step’s UI.
  3. When the user completes the step’s action, submit the step.
  4. The response contains the updated state — re-render based on the new current step.
  5. Repeat until is_complete is true.
Endpoints used in this loop: Pseudocode:
state = GET /v0/user/me/onboarding/steps

while not state.onboarding.is_complete:
    current = find step where status == "current"
    render_step_ui(current.step)
    await user_action()
    state = POST /v0/user/me/onboarding/steps { step: current.step }

show_onboarding_complete()
Progress bar: Calculate progress from the steps array:
total = len(steps)
done = count(steps where status in ["completed", "skipped"])
progress = done / total

Handling Configurable Steps

Steps with "gated": true may have "status": "skipped" if the platform has disabled them. When building a step list UI:
  • Filter the steps array to exclude skipped steps, or show them as completed
  • Do not hardcode which steps exist — always derive from the steps array
  • The order in the array is the canonical step order
active_steps = [s for s in steps if s.status != "skipped"]

Implicit Step Advancement

Two steps auto-advance without requiring an explicit submit call: Phone verification: When the user successfully verifies their phone, the phone_verification onboarding step is automatically submitted. After calling the phone verification endpoint, re-fetch onboarding state to see the updated step. PUT /v0/user/me/phone/code — Confirm the SMS code. KYC verification: When the KYC status endpoint returns an approved result and the user is currently on the kyc_verification step, it is automatically submitted. After initiating KYC, poll the status endpoint and re-fetch onboarding state once approved. GET /v0/kyc/status — Poll for the current KYC status. In both cases, the client should re-fetch state after the triggering action to see the advancement:
# Phone verification auto-advances onboarding
curl -X PUT /v0/user/me/phone/code \
  -H "x-juno-jwt: <token>" \
  -H "x-juno-orgid: <your-org-id>" \
  -d '{"verification_code": "123456"}'

# Re-fetch to see updated onboarding state
curl /v0/user/me/onboarding/steps \
  -H "x-juno-jwt: <token>" \
  -H "x-juno-orgid: <your-org-id>"

Error Handling

All error responses follow RFC 7807 Problem Details format. The error_code field is the stable machine-readable identifier; detail is human-readable context for the specific occurrence.
StatusError codeWhen it fires
401 UnauthorizedUSR-401-001Missing or invalid authentication token
404 Not FoundUSR-404-001User account does not exist — call POST /v0/user/ first
409 ConflictONB-409-001The submitted step does not match the user’s current step
422 Unprocessable Content(validation)Request body missing required step field
401 Unauthorized:
{
  "type": "https://api.sumvin.com/errors/usr-401-001",
  "title": "User Authentication Failed",
  "status": 401,
  "detail": "Missing or invalid authentication token",
  "instance": "/v0/user/me/onboarding/steps",
  "error_code": "USR-401-001"
}
404 Not Found — the authenticated identity has no matching user record yet:
{
  "type": "https://api.sumvin.com/errors/usr-404-001",
  "title": "User Not Found",
  "status": 404,
  "detail": "User not found",
  "instance": "/v0/user/me/onboarding/steps",
  "error_code": "USR-404-001"
}
409 Conflict — the submitted step is not the current step. The detail names both the submitted step and the user’s actual current step so the client can resync without a follow-up GET:
{
  "type": "https://api.sumvin.com/errors/onb-409-001",
  "title": "Wrong Onboarding Step",
  "status": 409,
  "detail": "Submitted step 'feature_selection' does not match the user's current step 'kyc_verification'. Read GET /v0/user/me/onboarding/steps and submit the step with status='current'.",
  "instance": "/v0/user/me/onboarding/steps",
  "error_code": "ONB-409-001"
}
422 Unprocessable Content — request body validation (e.g. missing step field):
{
  "detail": [
    {
      "loc": ["body", "step"],
      "msg": "Field required",
      "type": "missing"
    }
  ]
}

Polling After 202 Accepted

When the API returns 202 Accepted, background provisioning is in flight and the response includes a Retry-After header that is the recommended seconds to wait before the next poll of GET /v0/user/me/onboarding/steps. Recommended client behaviour:
  • Honour Retry-After if present.
  • If you implement your own backoff, start at 2 seconds and back off exponentially (e.g. 2s → 4s → 8s) up to a 30-second ceiling, until current_step advances or is_complete becomes true.
  • Do not poll faster than once per second.
  • A client that keeps seeing the same current_step after several polls should surface a “still working” UI rather than retrying indefinitely.

Reference Tables

Step Identifiers

ValueDescription
createdInitial state after account creation (not a user-facing step)
phone_verificationPhone number verification via SMS
kyc_verificationIdentity verification (KYC)
byo_safeBring-Your-Own-Safe submission — appears only when the user’s organisation is configured for that mode
safe_deployUser-signed Safe deployment — appears only when the user’s organisation is configured for that mode
open_bankingBank account connection
card_setupPayment card setup
feature_selectionPlatform feature selection
completeTerminal state — onboarding finished

Event Types

ValueDescription
step_enteredUser arrived at a new step
step_submittedUser submitted a step (recorded by submit endpoint, not advance)
step_completedStep was completed and user advanced past it
step_skippedStep was skipped by platform configuration

Status Values

ValueDescription
pendingStep not yet reached
currentUser is on this step — action required
submittedStep was submitted (transient — advances immediately)
completedStep finished
skippedStep disabled by platform configuration