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
| Phase | User-visible message | Typical duration |
|---|---|---|
| idle | No active flow — show the entry CTA | — |
| checking | Reading on-chain attestation | 200–800 ms |
| unverified | Show the verify form | — |
| verifying | Confirming your ID with Dojah | 1–3 s |
| proving | Generating proof in your browser | 3–8 s |
| submitting | Sign with your wallet | user-paced |
| done | Verified ✓ | — |
| error | Show 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.