Quickstart
This is Hello Mobile’s identity step, reduced to the parts you copy. Every code block is tested against the package before each release.
You need Node.js 20+, a web project (the samples are React, plain HTML works too), the SDK zip, your publishable and secret keys, and a phone with the demo wallet.
-
Install the box.
Terminal window npm install ./vendor/wallet-verify-0.7.0.tgz -
Describe the fields once. Labels are what the customer reads; claim names are what the wallet returns.
lib/claims.ts // The fields the identity step asks for. Labels are what the customer reads; claim names are what the// wallet returns. Shared by the page (for the box) and the server (for the result).export const VERIFY_FIELDS = ['Given names=given_name','Family name=family_name','Emirates ID number=person_id','Date of birth=birth_date','Nationality=nationality','ID expiry date=expiry_date','Photo=portrait',].join(', ') -
Put the element on the page. Importing the package registers
<wallet-verify>. Theverifiedevent does not move the customer on by itself: it asks your server (step 5).identity-step.tsx // The identity step of a signup: the <wallet-verify> box, and a server check before moving on.'use client'import { useEffect, useRef } from 'react'import type { WalletVerifyElement } from '@wallet/verify'import { VERIFY_FIELDS } from './claims'export function IdentityStep({ sessionId, onVerified }: { sessionId?: string; onVerified: () => void }) {const ref = useRef<WalletVerifyElement>(null)useEffect(() => { void import('@wallet/verify') }, []) // registers <wallet-verify> in the browseruseEffect(() => {const el = ref.currentif (!el) returnconst onVerifiedEvent = async () => {const id = el.session?.id ?? sessionIdconst res = await fetch(`/api/identity/${id}`) // ask YOUR server, not the browserconst { state } = (await res.json()) as { state: string }if (state === 'verified') onVerified()}el.addEventListener('verified', onVerifiedEvent)return () => el.removeEventListener('verified', onVerifiedEvent)}, [sessionId, onVerified])return (<wallet-verifyref={ref}endpoint="https://hakim-verify-api.vercel.app/v1/pk_live_yourkey"session-id={sessionId}wallet-name="ICP Wallet"wallet-logo="/icp-wallet.png"fields={VERIFY_FIELDS}tagline="Instant approval"heading-expired="QR code expired"reload-label="Refresh QR Code"idle-timeout="0"app-link="false"/>)}With TypeScript, declare the element for JSX once per project:
wallet-verify.d.ts // Teach React's JSX about the <wallet-verify> element (one file per project).import type { WalletVerifyElement } from '@wallet/verify'declare module 'react' {namespace JSX {interface IntrinsicElements {'wallet-verify': React.DetailedHTMLProps<React.HTMLAttributes<HTMLElement>, HTMLElement> & {ref?: React.Ref<WalletVerifyElement>endpoint?: string'wallet-name'?: string'wallet-logo'?: stringfields?: string'session-id'?: string'link-host'?: string'ios-scheme'?: string'app-link'?: string'dc-api'?: stringtagline?: stringheading?: string'heading-tap'?: string'heading-verified'?: string'heading-expired'?: string'reload-label'?: string'idle-timeout'?: string}}}}Plain HTML is the same element with a
<script type="module">import '@wallet/verify'</script>. -
Talk to the Verify API from your server. The publishable key may reach the browser; the secret key never does.
verify-api.ts // Server-side helper for the Verify API. The publishable key may reach the browser; the secret key// must never leave the server. Configure with environment variables.export type SessionState = 'waiting' | 'verified' | 'failed' | 'cancelled' | 'expired'export interface VerifyResult {state: SessionStatedocType?: stringclaims?: Record<string, unknown>error?: string}export interface NewSession {id: stringdeepLink: stringexpiresAt: string}export interface VerifyApiOptions {baseUrl: string // https://hakim-verify-api.vercel.apppublishableKey: string // pk_…secretKey: string // sk_…fetch?: typeof fetch // injectable for tests}export function verifyApi(opts: VerifyApiOptions) {const f = opts.fetch ?? fetchconst pageBase = `${opts.baseUrl.replace(/\/$/, '')}/v1/${opts.publishableKey}`const secret = { authorization: `Bearer ${opts.secretKey}` }async function json<T>(res: Response): Promise<T> {const body = (await res.json()) as T & { error?: string }if (!res.ok) throw new Error(body?.error ?? `HTTP ${res.status}`)return body}return {/** Start a session (what the box does with `POST /new`). */createSession: async (): Promise<NewSession> =>json(await f(`${pageBase}/new`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}' })),/** The hand-off details of an existing session; 410 once expired. */describeSession: async (id: string): Promise<NewSession> =>json(await f(`${pageBase}/session/${id}`, { cache: 'no-store' })),/** The outcome, read with the SECRET key. This is the result you act on. */result: async (id: string): Promise<VerifyResult> =>json(await f(`${opts.baseUrl}/v1/results/${id}`, { headers: secret, cache: 'no-store' })),/** Cancel a session you no longer want approved. */cancel: async (id: string): Promise<VerifyResult> =>json(await f(`${opts.baseUrl}/v1/results/${id}`, { method: 'DELETE', headers: secret })),}}export const api = () =>verifyApi({baseUrl: process.env.VERIFY_API_URL ?? 'https://hakim-verify-api.vercel.app',publishableKey: process.env.VERIFY_PUBLISHABLE_KEY ?? '',secretKey: process.env.VERIFY_SECRET_KEY ?? '',}) -
Read the result before moving on. The page’s tick is for the customer. Your server reads the outcome with the secret key and stores the applicant only when the state is
verified.app/api/identity/[id]/route.ts // Your server decides. The page calls this after the box fires `verified`; it reads the outcome from the// Verify API with the secret key and stores the applicant only when the state is `verified`.// Next.js: app/api/identity/[id]/route.tsimport { api, type VerifyResult } from './verify-api'export async function saveApplicant(_sessionId: string, _claims: Record<string, unknown>): Promise<void> {// your database}export async function readAndStore(id: string, read: (id: string) => Promise<VerifyResult> = api().result) {const result = await read(id)if (result.state === 'verified' && result.claims) {await saveApplicant(id, result.claims)}return { state: result.state }}export async function GET(_req: Request, { params }: { params: Promise<{ id: string }> }): Promise<Response> {const { id } = await paramsreturn Response.json(await readAndStore(id))}States are
waiting,verified,failed,cancelledandexpired. Onlyverifiedcontinues. -
Match your brand. The box inherits your font and takes a few CSS variables.
wallet-verify { --wallet-accent: #235f3f; --wallet-accent-tint: #e9f4ee; --wallet-star: #d97706; } -
Test it. The server helpers take an injectable
fetch, so they test without the network:test/verify-api.test.ts import { test } from 'node:test'import assert from 'node:assert/strict'import { verifyApi } from '../src/verify-api'import { readAndStore } from '../src/identity-route'const stub = (routes: Record<string, { status?: number; body: unknown }>): typeof fetch =>(async (input: RequestInfo | URL, init?: RequestInit) => {const url = String(input)const key = `${init?.method ?? 'GET'} ${url}`const hit = routes[key]if (!hit) throw new Error(`unexpected ${key}`)if (init?.headers && 'authorization' in (init.headers as Record<string, string>)) {assert.equal((init.headers as Record<string, string>).authorization, 'Bearer sk_test')}return new Response(JSON.stringify(hit.body), { status: hit.status ?? 200, headers: { 'content-type': 'application/json' } })}) as typeof fetchconst opts = { baseUrl: 'https://api.example', publishableKey: 'pk_test', secretKey: 'sk_test' }test('createSession posts to the tenant and returns the hand-off', async () => {const api = verifyApi({ ...opts, fetch: stub({ 'POST https://api.example/v1/pk_test/new': { body: { id: 'abc', deepLink: 'openid4vp://x', expiresAt: 'soon' } } }) })const s = await api.createSession()assert.equal(s.id, 'abc')assert.match(s.deepLink, /^openid4vp:/)})test('result uses the secret key and surfaces the state', async () => {const api = verifyApi({ ...opts, fetch: stub({ 'GET https://api.example/v1/results/abc': { body: { state: 'verified', claims: { family_name: 'Hakim' } } } }) })const r = await api.result('abc')assert.equal(r.state, 'verified')assert.equal(r.claims?.family_name, 'Hakim')})test('an expired session reads as expired (410) without throwing on the state', async () => {const api = verifyApi({ ...opts, fetch: stub({ 'GET https://api.example/v1/pk_test/session/old': { status: 410, body: { error: 'expired' } } }) })await assert.rejects(api.describeSession('old'), /expired/)})test('the identity route only reports verified when the API says so', async () => {assert.deepEqual(await readAndStore('a', async () => ({ state: 'waiting' })), { state: 'waiting' })assert.deepEqual(await readAndStore('b', async () => ({ state: 'verified', claims: { person_id: '784-…' } })), { state: 'verified' })})Then for real: open the page, scan with the demo wallet, approve. Try the failure paths: let the QR expire and refresh it; cancel in the wallet; open the step in two tabs.
Rules you can rely on
Section titled “Rules you can rely on”- An expired session can never become verified (the API answers 410).
- A second submission for the same session returns the first result.
- A late “closed” from the wallet never overrides a verified result.
- Unknown session ids read as expired, so a forged QR never verifies.
Next: Same device on phones, then Go live.