VC Wallet SDK Quickstart
Install @passkeybridge/vc-wallet-sdk, issue your first JWT-VC, verify a presentation, and check revocation status from any fetch-capable runtime.
Overview
@passkeybridge/vc-wallet-sdk is a typed client for the credential surface. One PBWallet object wraps four edge functions:
| Function | Methods |
|---|---|
shield-vc-issue | issue |
shield-vc-present | verifyPresentation, verify, verifyBatchPresentation, createPresentationRequest |
shield-vc-status | checkStatus, batchCheckStatus, revoke, suspend, reinstate, refreshStatusList |
shield-vc-oid4vci | createCredentialOffer, redeemOffer, getIssuerMetadata |
Alongside those it ships a local wallet with encrypted storage, SD-JWT-VC holder binding with KB-JWTs, and a Presentation Exchange matcher that runs entirely in the caller's process.
It runs in any runtime with a global fetch: Node.js, Deno, Bun, modern browsers, Cloudflare Workers and Supabase Edge Functions. The package is ESM-first with a CJS fallback, ships its own types, and declares no runtime dependencies.
The current release is 0.6.0. This guide covers installation, the four common operations, the scopes each needs, and the error model.
Install
npm install @passkeybridge/vc-wallet-sdk
# or: pnpm add @passkeybridge/vc-wallet-sdk
# or: bun add @passkeybridge/vc-wallet-sdkThe package requires Node.js 20.19 or later, which is what engines declares. The package README currently says Node 18 and names a vc_status scope. Both are out of date: the engine requirement is Node 20.19, and the scope for lifecycle calls is vc_revoke. This guide follows the shipped code.
Pin to the current minor, ^0.6.0. The 0.x line may break between minors while the surface fills out, and the CHANGELOG records what moved.
Configure the client
Construct one client per tenant and reuse it. It holds no per-request state beyond the storage adapter.
import { PBWallet } from "@passkeybridge/vc-wallet-sdk";
const wallet = new PBWallet({
apiKey: process.env.PB_API_KEY!, // pb_live_... or pb_test_...
tenantId: process.env.PB_TENANT_ID!, // tenant UUID
});| Option | Type | Default | Purpose |
|---|---|---|---|
apiKey | string | required | Tenant API key from Dashboard > API keys |
tenantId | string | required | Tenant UUID, sent as x-pb-tenant-id |
baseUrl | string | https://api.passkeybridge.io/v1 | Override for staging or a proxy |
fetch | typeof fetch | global fetch | Inject a custom implementation |
timeoutMs | number | 15000 | Per-request timeout in milliseconds |
persist | boolean | true | Whether to use IndexedDB when the runtime has it |
storage | WalletStorage | chosen automatically | Replace the storage backend entirely |
The constructor throws PBWalletError with code invalid_options when apiKey or tenantId is missing, or when no fetch is available.
Storage is chosen as persist && hasIndexedDb(): a browser gets IndexedDbStorage, which encrypts records at rest with a non-extractable AES-256-GCM key held in IndexedDB; Node, Deno, workers and SSR get MemoryStorage, which lives for the life of the process and is not encrypted. persist: false forces memory even in a browser. To persist elsewhere, implement WalletStorage and pass it as storage.
Every request carries x-pb-api-key, x-pb-tenant-id and a User-Agent naming the SDK version. Those three are set last and cannot be overridden by a caller-supplied header.
Issue a credential
wallet.issue posts to shield-vc-issue and returns the signed credential with its proof metadata.
const issued = await wallet.issue({
subjectDid: "did:web:example.com:users:alice",
credentialType: "IdentityAttestation",
claims: { verificationLevel: "enhanced" },
expirationDays: 90,
});
console.log(issued.credential.credential_id); // urn:uuid:...
console.log(issued.credential.jwt); // the token
console.log(issued.pqc.algorithm); // ML-DSA-65Parameters are subjectDid and credentialType (both required), plus claims, expirationDays, issuerDid, disclosureFrame, holderPublicJwk and holderBinding. The fields of the response are the server's own, in snake_case under credential: jwt, format, credential_id, issuer, subject, type, issued_at, expires_at.
Passing a disclosureFrame switches the format to SD-JWT-VC and adds an sd object with the disclosures. When a frame is present, or holderBinding: true is set, and no holderPublicJwk is supplied, the SDK generates an ECDSA P-256 key pair, sends the public JWK so the credential carries cnf.jwk, and stores the private key locally against the credential id. That key is what presentWithBinding later needs, so a credential issued in a process with memory storage loses its binding key when the process exits.
const sdJwt = await wallet.issue({
subjectDid: "did:web:example.com:users:alice",
credentialType: "KYCAttestation",
claims: { age_over_18: true, country: "US" },
disclosureFrame: { age_over_18: true, country: true },
});Issuance needs vc_issue on the key, and the tenant must have credentials enabled or the call throws with httpStatus 422.
Verify a presentation
wallet.verifyPresentation, aliased as wallet.verify, posts to shield-vc-present with action: "verify".
const result = await wallet.verifyPresentation({
vpToken: holderResponse.vp_token,
presentationDefinition: definition,
expectedNonce: request.nonce,
});
if (result.verified) {
console.log(result.credentials?.[0]?.checks);
console.log(result.crossReferenceId);
}The result is { verified, holder, credentials, crossReferenceId, presentationExchange, engine, latencyMs, error }. A presentation that fails verification is a resolved promise with verified: false and an error string, so branch on verified rather than relying on a throw.
Options are vpToken, presentationDefinition, expectedNonce, bindToSignalHash, skipIssuerResolution and credentialTypeHint. Two of them need care:
skipIssuerResolution: truestops the DID fetch that supplies the verifying key for a standards-based credential, which then fails withsignature_valid: false. Leave it unset unless you are verifying a legacyPQC-HYBRIDcredential.credentialTypeHintis sent ascredential_type_hint, which the native verifier ignores. Constrain types through a presentation definition instead.
wallet.verifyBatchPresentation(vpTokens, presentationDefinition?, options?) sends up to 20 tokens in one round trip and adds batch and tokenCount; it throws invalid_params before sending if given more. wallet.createPresentationRequest(definition, responseUri?) builds the OpenID4VP authorization request. All of these need vc_verify.
shield-vc-verify is a different endpoint that delegates to an external provider such as walt.id or Trinsic, and answers 422 unless one is configured. The SDK does not call it; verifyPresentation always uses the native engine.
Status and lifecycle
Status reads are public, so a verifier can resolve revocation without a key of its own. The client still sends its headers; no scope is checked.
const status = await wallet.checkStatus("urn:uuid:abc123");
// { credentialId, status, credentialType?, issuer?, issuedAt?, expiresAt?, revokedAt? }
if (status.status === "revoked") { /* refuse */ }
const { credentials, checkedAt } = await wallet.batchCheckStatus([
"urn:uuid:abc123",
"urn:uuid:def456",
]);checkStatus returns a StatusResult object, so read status.status for the state, one of active, suspended, revoked, expired or unknown. batchCheckStatus takes at most 100 ids and throws invalid_params beyond that.
Lifecycle changes and the status-list rebuild need vc_revoke:
await wallet.revoke("urn:uuid:abc123", "key_compromise");
await wallet.suspend("urn:uuid:def456", "investigation");
await wallet.reinstate("urn:uuid:def456");
const list = await wallet.refreshStatusList();
// { etag, totalCredentials, revokedCount, generatedAt, statusListCredential }revoke, suspend and reinstate return the server's raw response object, including the inline status_list rebuild. refreshStatusList takes an optional issuer DID, which must belong to your tenant.
There is no method that fetches another issuer's status list; request the public endpoint with fetch, as shown in StatusList2021 & Revocation.
Required API key scopes
| Scope | Methods |
|---|---|
vc_issue | issue, createCredentialOffer |
vc_verify | verifyPresentation, verify, verifyBatchPresentation, createPresentationRequest |
vc_revoke | revoke, suspend, reinstate, refreshStatusList |
| none | checkStatus, batchCheckStatus, getIssuerMetadata, redeemOffer, and every local wallet method |
Mint a key at Dashboard > API keys with "Generate API key", picking the scopes in the wizard. redeemOffer needs no scope because it authenticates with the pre-authorized code from the offer.
An admin scope satisfies all of these but cannot be minted onto a key; it belongs to a tenant admin acting through a dashboard session.
Error model
Every transport and HTTP failure throws PBWalletError.
import { PBWalletError } from "@passkeybridge/vc-wallet-sdk";
try {
await wallet.issue({ subjectDid, credentialType });
} catch (err) {
if (err instanceof PBWalletError) {
console.error(err.code, err.httpStatus, err.message, err.requestId);
}
throw err;
}code is stable and safe to branch on: invalid_options, invalid_params, network_error, timeout, http_error, invalid_response, unauthorized, forbidden, not_found, rate_limited, server_error, decode_error. Status codes map as 401 to unauthorized, 403 to forbidden, 404 to not_found, 429 to rate_limited, 5xx to server_error, and anything else to http_error. message carries the server's own error string when there was one.
requestId comes from the response's x-request-id or cf-ray header; quote it in a support request. A timeout throws timeout rather than a network error, naming the configured limit.
Validation failures throw before anything is sent, with code invalid_params: a missing subjectDid, an oversized batch, an empty id list. Verification results never throw.
Advanced: local wallet, holder binding, OID4VCI
Local wallet. store(credential) and storeJwt(jwt) persist a credential with its parsed metadata; list(filter?), get(id), remove(id) and clearAll() manage it. list filters on type, issuer and validOnly, and sorts newest first. remove drops the holder key alongside the credential.
Holder binding. hasHolderBinding(credentialId) reports whether a private key is stored for a credential. presentWithBinding(credentialId, audience, nonce, disclosureNames?) returns the SD-JWT-VC with only the named disclosures and a KB-JWT appended, signed with the stored key over aud, nonce, iat and the sd_hash of what is being presented. Omit disclosureNames to reveal every disclosure.
const presentation = await wallet.presentWithBinding(
sdJwt.credential.credential_id,
"did:web:verifier.example.com",
authRequest.nonce,
["age_over_18"],
);Presentation Exchange. selectCredentials(definition) matches the local wallet against a definition and returns one credential per descriptor. Because the definition comes from a verifier, its filter.pattern is treated as untrusted: patterns over 512 characters, patterns whose shape admits catastrophic backtracking, and values over 4096 characters are refused, and a refusal withholds the credential. assembleBatchTokens(ids) turns stored ids into JWT strings for a batch verification.
OID4VCI. createCredentialOffer(params) creates an offer on the issuer side and needs vc_issue. redeemOffer(preAuthorizedCode, format?, autoStore?) runs the wallet side end to end: token exchange, then the credential request, generating an ECDSA P-256 holder proof when the format is vc+sd-jwt. format defaults to jwt_vc_json and autoStore to true.
const offer = await wallet.createCredentialOffer({
credentialType: "IdentityAttestation",
formats: ["vc+sd-jwt"],
disclosureFrame: { verificationLevel: true },
});
const redeemed = await wallet.redeemOffer(offer.preAuthorizedCode, "vc+sd-jwt");
if (redeemed.holderKeyPersisted === false) {
// The credential arrived but its binding key did not survive; re-issue it.
}redeemOffer requires 0.5.0 or later: earlier releases dropped the access token they had just been issued, and the credential request answered 401.
Helpers. The package root also exports decodeCredential (header and payload of a JWT or SD-JWT, with no signature check), decodeJwtPayload, resolveSimplePath, generateHolderKeyPair, cnfJwk, createKbJwt, createOid4vciProof, splitSdJwt, selectDisclosures, matchCredentials, the storage classes, and the deprecated hashPhone. hashPhone computes an unkeyed client-side SHA-256, which is a different value from the server's keyed HMAC-SHA-256 digest, so it will not correlate with server-hashed identifiers; send the raw phone to ingest and let the platform key it.
Related from the blog
- A Verifier Can Hang Your Wallet with One Regular Expressionengineering · 9 min read
- The IDV Margin Compression: Why Per-Verification Pricing Is Collapsing Toward Zero by 2027intel · 16 min read
- OpenID4VP over the Digital Credentials API: Browser-Native Wallet Selection in Chrome and Safariengineering · 17 min read