Display and Scan the Request
Use the Vue frontend SDK to display the backend’s pdFetchUrl as a QR code or open Credential Wallet on iOS. This example applies Journal’s commenting and browser-session flow to Vue. Journal itself uses React; the Vue component below is an integration example, not Journal’s deployed frontend.
The frontend starts a request, displays its URL, and checks backend status. It never signs a request, handles credential JWTs, or treats a successful scan as verification.
Install the frontend SDK
npm install @iamhuman.link/presentation-request-frontend-sdk qrcode.vue
Open the request on iOS or show a QR code
On iOS, openPresentationRequest opens the wallet handoff URL. Pass the current page as returnUrl so the browser can resume status checks when the reader returns. On other devices, show PresentationRequestQr with qr-value-mode="pd" so the QR contains the raw PD fetch URL. The component’s Open wallet link uses the wallet handoff URL separately.
This Vue example assumes the backend routes and browser-ownership cookie from Create a request. It polls every three seconds while visible, supports a manual status check, and completes the browser session after wallet verification.
<template>
<section>
<p v-if="user">{{ user.name ? 'Ready to comment.' : 'Choose your public name next.' }}</p>
<template v-else>
<button v-if="!session" type="button" :disabled="loading" @click="startVerification">
{{ loading ? 'Preparing request…' : 'Comment / get a new request' }}
</button>
<PresentationRequestQr
v-if="session"
:pd-url="session.pdFetchUrl"
:return-url="returnUrl"
:expires-at="session.expiresAt"
:busy="checking"
link-label="Open wallet"
expired-label="Expired"
:expires-in-label="(seconds) => `Expires in ${seconds}s`"
status-label="Waiting for wallet"
check-status-label="Check status"
qr-value-mode="pd"
@check-status="checkStatus"
/>
</template>
<p v-if="error" role="alert">{{ error }}</p>
</section>
</template>
<script setup lang="ts">
import { onMounted, onUnmounted, ref } from 'vue';
import {
PresentationRequestQr,
isIOSMobileBrowser,
openPresentationRequest,
} from '@iamhuman.link/presentation-request-frontend-sdk';
type Verification = { sessionId: string; pdFetchUrl: string; expiresAt: string };
type User = { name: string | null; expiresAt: string };
const session = ref<Verification | null>(null);
const user = ref<User | null>(null);
const loading = ref(true);
const checking = ref(false);
const error = ref('');
const returnUrl = ref('');
const storageKey = 'journal-personhood-request';
let timer: ReturnType<typeof setInterval> | undefined;
let disposed = false;
async function api(path: string, method = 'GET') {
const response = await fetch(`/api/personhood/${path}/`, {
method, credentials: 'same-origin', cache: 'no-store',
});
const data = await response.json();
if (!response.ok) throw new Error(data.error ?? 'unavailable');
return data;
}
function clearRequest() {
sessionStorage.removeItem(storageKey);
session.value = null;
}
async function startVerification() {
if (loading.value || checking.value || disposed) return;
loading.value = true;
error.value = '';
try {
const current = await api('session');
if (disposed) return;
user.value = current.user;
if (current.user) return clearRequest();
const result = await api('sessions', 'POST');
if (disposed) return;
sessionStorage.setItem(storageKey, JSON.stringify(result.session));
session.value = result.session;
if (isIOSMobileBrowser()) {
openPresentationRequest({ pdUrl: result.session.pdFetchUrl, returnUrl: returnUrl.value });
}
} catch {
if (!disposed) error.value = 'Could not start verification. Please try again.';
} finally { loading.value = false; }
}
async function checkStatus() {
const request = session.value;
if (!request || loading.value || checking.value || disposed) return;
if (Date.parse(request.expiresAt) <= Date.now()) {
clearRequest();
error.value = 'Request expired. Get a new request.';
return;
}
checking.value = true;
try {
const result = await api(`sessions/${request.sessionId}/status`);
if (disposed) return;
if (result.status === 'verified' || result.status === 'completed') {
await api(`sessions/${request.sessionId}/complete`, 'POST');
const current = await api('session');
if (disposed) return;
user.value = current.user;
clearRequest();
error.value = '';
} else if (result.status === 'rejected') {
clearRequest();
error.value = 'Verification rejected. Get a new request.';
} else error.value = '';
} catch (cause) {
if (disposed) return;
const code = cause instanceof Error ? cause.message : '';
if (['expired', 'not_found', 'verification_required'].includes(code)) {
clearRequest();
error.value = 'Request ended. Get a new request.';
} else error.value = 'Temporarily unavailable. We will retry.';
} finally { checking.value = false; }
}
function onFocus() { void checkStatus(); }
onMounted(async () => {
returnUrl.value = window.location.href;
timer = setInterval(() => {
if (document.visibilityState === 'visible') void checkStatus();
}, 3000);
window.addEventListener('focus', onFocus);
try {
const current = await api('session');
if (disposed) return;
user.value = current.user;
if (current.user) return clearRequest();
try {
const saved = JSON.parse(sessionStorage.getItem(storageKey) ?? 'null');
if (saved && typeof saved.sessionId === 'string' &&
Date.parse(saved.expiresAt) > Date.now() &&
new URL(saved.pdFetchUrl).origin === location.origin) {
session.value = saved;
} else clearRequest();
} catch { clearRequest(); }
} catch { if (!disposed) error.value = 'Please try again.'; }
finally {
loading.value = false;
if (!disposed) void checkStatus();
}
});
onUnmounted(() => {
disposed = true;
clearInterval(timer);
window.removeEventListener('focus', onFocus);
});
</script>
sessionStorage holds only the short-lived request ID, fetch URL, and expiry so the example can resume after wallet return or reload. The private browser-ownership token remains in an HttpOnly cookie. Restoring a request does not bypass backend ownership checks.
The example stops at session readiness. Add the public-name form below, logout handling, and localized errors in your application. A rate_limited response should ask the reader to wait and respect Retry-After; it must not trigger rapid request creation. The SDK displays the QR and wallet link; your app owns polling, persistence, and authorization.
Complete the browser session and collect a name
A verified polling status means the backend accepted the wallet submission. Call POST /api/personhood/sessions/{sessionId}/complete/ from the initiating browser to receive the login cookie, then refresh GET /api/personhood/session/. A retry may see completed; completion is safe to retry from the same browser while the request and session remain live.
If the returned user has no name, submit the chosen name as JSON to POST /api/personhood/profile/:
const response = await fetch('/api/personhood/profile/', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: publicName }),
});
if (!response.ok) {
// Handle invalid_name (400), name_taken (409), or a temporary failure.
throw new Error('Could not save public name');
}
Journal trims names, accepts up to 40 Unicode characters, rejects control characters and names already reserved by another user, and saves a name only when one is missing. Refresh the session after saving. Returning users reuse their saved name. This is a public display name, not credential-verified personal data.
Show the comment form only after the session has a saved name. The comment endpoint still authorizes the request on the server; displaying a form is not permission to publish.
Scan the QR code from Credential Wallet
Credential Wallet fetches the request through pdFetchUrl, reads pdVc, verifies the signed request and bindings, and displays the requested Human credential for consent. The fetch endpoint remains reachable without browser cookies during the pending request’s lifetime.
{
"ok": true,
"pdVc": {
"jwtVc": "<signed request VC>",
"expiresAt": "<request expiry ISO timestamp>",
"pdRequestId": "<request id>",
"pdRequestType": "BasicAccessRequestVerifiableCredential",
"pdHash": "<presentation definition hash>",
"appId": "<your registered app id>",
"nonce": "<request nonce>"
}
}
The frontend does not fetch or decode jwtVc. After consent, the wallet submits directly to the backend.
Try the live QR code
Open Journal’s proof-of-personhood app integration article and select Comment to start verification. You can scan the live QR code displayed by the frontend with Credential Wallet and approve the request to try the flow yourself. If you already have an active Journal session, log out first to request a new QR code.
Continue with Submit and verify.