iiamhuman Developer Docsv1

PERSONHOOD CREDENTIAL INTEGRATION · STEP 8

Submit and Verify the Wallet Presentation

Verify the wallet submission and establish Journal’s browser session.

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"
}
FieldRequirementMeaning
okRequired booleantrue acknowledges completed submission verification and persistence.
recordIdRequired string for backendsStable app-owned ID of the persisted result, not proof of validity.
statusOptional, deprecatedLegacy "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

CodeTypical HTTP statusMeaning and client handling
INVALID_INPUT400, 413, 415Body is malformed, too large, or has an unsupported format. Correct the request.
SESSION_NOT_FOUND404Verification request was not found. Start a new request.
SESSION_EXPIRED410Verification request expired. Obtain a new request.
SESSION_NOT_PENDING409Request can no longer accept a submission. Start a new request; do not assume a previous submission succeeded.
REQUEST_MISMATCH400, 403, 409Submission does not match the stored request. Obtain the current request; do not reuse stale request fields.
INVALID_PRESENTATION400, 422Presentation could not be verified. Do not grant access.
CREDENTIAL_EXPIRED400, 422Credential expired. Obtain a current credential. This differs from request expiry.
CREDENTIAL_REVOKED400, 422Credential was revoked. Obtain an accepted credential.
CREDENTIAL_NOT_ACCEPTED400, 422Credential does not meet the app’s issuer, type, attributes, lifetime, or status requirements.
RATE_LIMITED429Too many requests. Respect Retry-After and any retryAfterSeconds.
SERVICE_UNAVAILABLE503Verification could not finish because a required service is unavailable. This is not a credential rejection.
INTERNAL_ERROR500An 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.