Submit and Verify the Wallet Presentation
After the user approves the request, Credential Wallet signs a Verifiable Presentation and submits it to the submissionUrl embedded in the request. Your backend must verify the submission against the request state stored in document 06.
Submit the Credential from Credential Wallet
The wallet sends one JSON object to your submission endpoint:
{
"vpJwt": "<signed verifiable presentation JWT>",
"presentationSubmission": {
"id": "<submission id>",
"definition_id": "<presentation definition id>",
"descriptor_map": []
},
"pdRequestId": "<session id>",
"pdRequestType": "BasicAccessRequestVerifiableCredential",
"pdHash": "<presentation definition hash>",
"nonce": "<session nonce>",
"appId": "<registered app id>"
}
The frontend does not forward or transform this payload. The wallet posts directly to the backend submissionUrl.
Verify Wallet Submissions
Load the session before calling the SDK. Do not trust pdRequestId, pdHash, nonce, appId, or pdRequestType only because they arrived in the request body; compare them with the stored request.
This service-function excerpt adapts Journal’s backend. Its Firestore storage helpers are represented by an app-owned requests adapter, not SDK methods. Implement these operations before using the excerpt:
loadLivePending(id)rejects missing, expired, or non-pending requests with the corresponding shared session error code.rejectIfPending(id)marks a definitive rejection only if the request is still pending.acceptIfPending(id, result)atomically rechecks existence, expiry, and pending status before storing the result. A concurrent or replayed submission must fail rather than overwrite a verified request.
import {
classifyPresentationSubmissionError,
type PresentationSubmissionEnvelope,
type PresentationSubmissionSuccessResponse,
} from '@iamhuman.link/presentation-exchange-sdk';
import { presentation } from './presentation-service.js';
async function submitPresentation(id: string, submission: PresentationSubmissionEnvelope) {
const request = await requests.loadLivePending(id);
let verified;
try {
verified = await presentation.verifySubmission({
submission,
expected: {
pdRequestId: id,
pdRequestType: 'BasicAccessRequestVerifiableCredential',
pdHash: request.envelope.pdHash,
nonce: request.nonce,
appId: presentation.appConfig.appId,
subject: request.subject,
submissionUrl: request.submissionUrl,
targetCredentialType: request.verificationContext.targetCredentialType,
},
storedPresentationDefinition: request.presentationDefinition,
policy: request.verificationContext.policy,
credentialStatusPolicy: 'required',
});
} catch (error) {
const failure = classifyPresentationSubmissionError(error);
if (failure.status < 500) await requests.rejectIfPending(id);
throw error;
}
const verifiedAt = new Date();
if (!verified.holderId ||
Date.parse(verified.expirationDate) < verifiedAt.getTime() + 7 * 86400000) {
await requests.rejectIfPending(id);
// App-owned error; the route maps this code to a safe 400 response.
throw Object.assign(new Error('Credential not accepted'), {
code: 'CREDENTIAL_NOT_ACCEPTED',
});
}
await requests.acceptIfPending(id, {
status: 'verified',
holderId: verified.holderId,
credentialJwt: verified.credentialJwt,
verifiedAt,
});
return {
ok: true,
recordId: id,
status: 'verified', // Retained for older wallets.
} satisfies PresentationSubmissionSuccessResponse;
}
The route parses the wallet JSON with a body-size limit, calls this function, and returns the success object only after persistence succeeds. Use the SDK’s classifyPresentationSubmissionError and createPresentationSubmissionError helpers for SDK failures. Map app-owned session/lifetime errors to the shared codes and HTTP statuses below; do not assume SDK classification understands your custom error classes. Journal applies this mapping in its submission response adapter, including errors raised before verification.
Establish the browser session
The wallet submission marks a request verified; it does not set a browser login cookie. Journal’s browser then calls POST /api/personhood/sessions/{sessionId}/complete/.
Completion verifies the browser-ownership cookie and atomically creates a session from the verified request. Journal identifies the user from a hash of the verified holderId, reuses an existing profile, and retains the saved public name. It does not use walletDid as the account identifier. This Human credential flow does not establish one account per person.
The session expires seven days after successful presentation verification. Store its credential JWT only on the backend, and set an opaque session token in an HttpOnly, SameSite=Lax cookie, Secure on HTTPS/production. Remove credential and holder fields from the request record once copied to session/user storage. Check expiry on access rather than relying on delayed database cleanup.
Completion retries from the same browser reuse the existing live session. Journal permits this only while the original request is live; a deleted or logged-out session cannot be reclaimed. Unknown, expired, wrong-browser, pending, or rejected requests cannot create a session. Journal uses pending, verified, completed, and rejected request states; expiry is checked from the stored timestamp.
GET /api/personhood/session/ returns only the current user’s name and session expiry, or user: null. Never send credential JWTs or raw holder/wallet identifiers to the frontend or public comment responses. POST /api/personhood/logout/ deletes the session and clears both cookies.
Browser mutation routes require a trusted Origin. The wallet submission route accepts direct wallet requests without a browser cookie or Origin header and authenticates them through presentation verification.
Submission Response Contract
Return HTTP 200 or 201 and a JSON body with ok: true and a nonempty recordId
only after verification and required persistence succeed:
{
"ok": true,
"recordId": "verification_123",
"status": "verified"
}
| Field | Requirement | Meaning |
|---|---|---|
ok | Required boolean | true acknowledges completed submission verification and persistence. |
recordId | Required string for backends | Stable app-owned ID of the persisted result, not proof of validity. |
status | Optional, deprecated | Legacy "verified" field; will be removed in a future release. Do not depend on it. |
Updated Credential Wallet uses HTTP 200/201 and ok == true to determine success.
It accepts absent/null status and ignores its string value. Existing backends
continue sending status: "verified" because older wallet versions require it
during decoding. Remove it only after those versions are unsupported.
The wallet temporarily accepts an absent/null recordId for compatibility;
new integrations must return it. A future wallet release will require it.
Application-specific fields may be added; the wallet ignores fields it does not use.
Session polling statuses are a separate contract and remain meaningful.
On failure, return HTTP 4xx or 5xx, never a successful HTTP code:
{
"ok": false,
"error": {
"code": "INVALID_PRESENTATION",
"message": "Presentation could not be verified."
}
}
Apply this envelope to malformed JSON, oversized bodies, invalid or expired sessions, verification failures, and persistence failures, including errors raised before the route handler. Retain appropriate 4xx/5xx HTTP statuses.
Shared Submission Error Codes
| Code | Typical HTTP status | Meaning and client handling |
|---|---|---|
INVALID_INPUT | 400, 413, 415 | Body is malformed, too large, or has an unsupported format. Correct the request. |
SESSION_NOT_FOUND | 404 | Verification request was not found. Start a new request. |
SESSION_EXPIRED | 410 | Verification request expired. Obtain a new request. |
SESSION_NOT_PENDING | 409 | Request can no longer accept a submission. Start a new request; do not assume a previous submission succeeded. |
REQUEST_MISMATCH | 400, 403, 409 | Submission does not match the stored request. Obtain the current request; do not reuse stale request fields. |
INVALID_PRESENTATION | 400, 422 | Presentation could not be verified. Do not grant access. |
CREDENTIAL_EXPIRED | 400, 422 | Credential expired. Obtain a current credential. This differs from request expiry. |
CREDENTIAL_REVOKED | 400, 422 | Credential was revoked. Obtain an accepted credential. |
CREDENTIAL_NOT_ACCEPTED | 400, 422 | Credential does not meet the app’s issuer, type, attributes, lifetime, or status requirements. |
RATE_LIMITED | 429 | Too many requests. Respect Retry-After and any retryAfterSeconds. |
SERVICE_UNAVAILABLE | 503 | Verification could not finish because a required service is unavailable. This is not a credential rejection. |
INTERNAL_ERROR | 500 | An unexpected server failure prevented completion. Do not assume verification or persistence succeeded. |
Use the SDK’s PresentationSubmissionErrorCode constants directly in error.code.
The SDK response helper supplies safe English messages. No additional reason field is needed.
Stable codes let Credential Wallet and other clients select accurate localized messages
and handling without parsing English server text. For example, SESSION_EXPIRED can
map to “驗證請求已過期,請重新取得驗證請求。” while CREDENTIAL_EXPIRED needs a
different message about renewing the credential. Credential Wallet currently displays
the server message; code-based English/Traditional Chinese localization is a separate
follow-up, enabled by this contract.
Use shared codes for common failures. Application-specific conditions, such as
VoteApp’s VOTE_NOT_OPEN, may keep an application-specific code. For an unknown
code, clients must show a generic localized failure and must not treat it as success.
Keep message a safe string; never copy raw SDK errors, credential attributes,
cryptographic details, or server exceptions into the response.
What the SDK Verifies
verifySubmission validates the stored request and the wallet response together. It checks the request bindings, recomputes the stored Presentation Definition hash, validates the definition against app config, verifies the VP signature, evaluates Presentation Exchange, checks credential issuer and status, and confirms that the credential type satisfies the requested target type and policy.
The result is normalized for persistence:
{
holderId,
walletDid,
issuerDid,
credentialJwt,
credentialTypes,
credentialTier,
credentialSubject,
issuanceDate,
expirationDate,
statusList,
normalized: {
name,
profilePicture,
profileUrl,
socialMedia,
nationality,
},
vpDigest,
}
holderId is the opaque identifier carried in the presentation’s holder field; it may be a DID. Journal uses this verified value to recognize returning users. walletDid identifies the wallet signing key and can differ from holderId. holderDid is the deprecated alias throughout SDK 1.x; new integrations should use holderId.
If verification fails, do not grant access or use any credential attributes. For a definitive rejection, mark the pending session rejected or expired and return the corresponding shared code. For temporary verification outages, leave it pending and return SERVICE_UNAVAILABLE. Avoid exposing cryptographic or policy-validation details in the browser.
After verification succeeds, Journal completes the browser session and collects a name if missing. Every later comment still requires server-side session and credential-state checks; a polling status alone cannot authorize publication.
Requested User Attributes
Journal requests no personal profile attributes, so it does not populate a public name from verified.normalized. The reader chooses that name separately.
For other applications, requested attributes are available in verified.normalized. For example, if your registered policy requests name, profilePicture, socialMedia, and nationality:
const { name, profilePicture, socialMedia, nationality } = verified.normalized;
profileUrl is not currently supported.
Next Step
If you store a credential JWT for later authorization decisions, read Check the State of a Stored Personhood Credential.