Guides

Handling Verification States

The verification flow has six distinct UX-relevant states. A discriminated union keeps your UI honest — every state has its own rendering and there are no impossible combinations.

The six states

PhaseUser-visible messageTypical duration
idleNo active flow — show the entry CTA
checkingReading on-chain attestation200–800 ms
unverifiedShow the verify form
verifyingConfirming your ID with Dojah1–3 s
provingGenerating proof in your browser3–8 s
submittingSign with your walletuser-paced
doneVerified ✓
errorShow error + reset CTA

Reducer

verify-reducer.ts
type Phase =
  | { kind: 'idle' }
  | { kind: 'checking' }
  | { kind: 'unverified' }
  | { kind: 'verifying' }              // talking to backend
  | { kind: 'proving'; percent: number } // running circuit in browser
  | { kind: 'submitting'; tx: string }   // user signing
  | { kind: 'done'; verifiedAt: number }
  | { kind: 'error'; message: string }

type Action =
  | { type: 'CHECK_START' }
  | { type: 'CHECK_RESULT'; verified: boolean; verifiedAt?: number }
  | { type: 'VERIFY_START' }
  | { type: 'PROVE_START' }
  | { type: 'PROVE_PROGRESS'; percent: number }
  | { type: 'SUBMIT_START'; tx: string }
  | { type: 'SUCCESS'; verifiedAt: number }
  | { type: 'FAIL'; message: string }
  | { type: 'RESET' }

export function reducer(state: Phase, action: Action): Phase {
  switch (action.type) {
    case 'CHECK_START':
      return { kind: 'checking' }
    case 'CHECK_RESULT':
      return action.verified
        ? { kind: 'done', verifiedAt: action.verifiedAt! }
        : { kind: 'unverified' }
    case 'VERIFY_START':
      return { kind: 'verifying' }
    case 'PROVE_START':
      return { kind: 'proving', percent: 0 }
    case 'PROVE_PROGRESS':
      return { kind: 'proving', percent: action.percent }
    case 'SUBMIT_START':
      return { kind: 'submitting', tx: action.tx }
    case 'SUCCESS':
      return { kind: 'done', verifiedAt: action.verifiedAt }
    case 'FAIL':
      return { kind: 'error', message: action.message }
    case 'RESET':
      return { kind: 'idle' }
  }
}

Wiring it to the SDK

VerifyFlow.tsx
function VerifyFlow() {
  const [state, dispatch] = useReducer(reducer, { kind: 'idle' })
  const wallet = useWallet()

  async function run(nin: string, dob: string) {
    try {
      dispatch({ type: 'VERIFY_START' })
      const cred = await verifyIdentity({ idType: 'NIN', idNumber: nin, dob })

      dispatch({ type: 'PROVE_START' })
      const proof = await generateProof(cred)

      dispatch({ type: 'SUBMIT_START', tx: 'pending' })
      const result = await submitProof(proof, wallet, 'mainnet-beta')

      dispatch({ type: 'SUCCESS', verifiedAt: Date.now() })
      return result
    } catch (e) {
      dispatch({ type: 'FAIL', message: (e as Error).message })
    }
  }

  return <FlowUI state={state} onRun={run} onReset={() => dispatch({ type: 'RESET' })} />
}

UX tips

  • The proving state lasts longer than people expect. Show a progress bar with a reassuring sub-message ("running zero-knowledge circuit — this runs on your device, your NIN never left").
  • The submitting state is paced by the user's wallet popup — don't auto-dismiss. Show the wallet they need to click.
  • On error, always offer a clear retry path. Most errors are recoverable (network blip, user rejected) — the only unrecoverable one is DUPLICATE_NULLIFIER.
  • On done, navigate the user where they were trying to go. Don't make them re-click after success.