Credential Issuance
WalletSdk.acceptOffer runs the complete OID4VCI issuance flow from a single call: resolve the offer, exchange it for a credential, evaluate issuer trust, ask the user for consent, store the credential, and acknowledge the offer with the issuer.
Requires a promptHandler in WalletSdkOptions. Issuance is always available after WalletSdk.initialize.
Flow
Input — AcceptOfferRequest
| Field | Type | Required | Description |
|---|---|---|---|
walletExternalId | String | Yes | API wallet id the credential will be stored under |
offer | String | Yes | The offer URI — openid-credential-offer://... from a QR code or deep link |
did | String | Yes | Holder DID used for binding |
placeholder | VaultCredential? | No | Pre-rendered pending credential your UI can show while the flow runs |
credentialTypesForTrust | List<String>? | No | Override the credential types used for the issuer trust check |
Output — AcceptOfferResult
A sealed type with three cases:
final result = await sdk.acceptOffer(
AcceptOfferRequest(
walletExternalId: apiWalletId,
offer: offerUri,
did: holderDid,
),
);
switch (result) {
case AcceptOfferSuccess(
:final credential, // stored VaultCredential
:final resolution, // CredentialOfferResolution metadata
:final isDuplicate, // an equivalent credential already existed
:final isIssuerTrusted, // trust network verdict
:final issuerTrustWarningMessage,
):
showSuccess(credential);
case AcceptOfferDeclined():
// User declined on the consent prompt. Offer was rejected upstream.
break;
case AcceptOfferFailure(:final message):
showError(message);
}
CredentialOfferResolution describes the resolved offer:
| Member | Description |
|---|---|
credentialIssuer | Issuer identifier (DID or URL) |
credentialConfigurationIds | Offered configuration ids, e.g. VerifiedPerson_vc+sd-jwt |
primaryCredentialType | First configuration id |
resolveDisplayName() | Human-readable label for prompts (e.g. "Verified Person") |
The consent prompt
Before storing anything, the SDK raises a CredentialAcceptancePrompt through your prompt handler. It carries everything a review screen needs:
| Field | Description |
|---|---|
credential | PromptCredentialPreview — display name, logo, claims, and issuer (with trust status) |
issuedCredential | The full VaultCredential payload for preview rendering |
resolution | The CredentialOfferResolution above |
isDuplicate | Whether an equivalent credential is already stored |
isIssuerTrusted / issuerTrustWarningMessage | Trust verdict for warning banners |
Respond with CredentialAcceptanceAccepted() or CredentialAcceptanceDeclined():
registry.register<CredentialAcceptancePrompt, CredentialAcceptanceResult>(
(prompt) async {
final accepted = await showDialog<bool>(
context: context,
barrierDismissible: false,
builder: (_) => AlertDialog(
title: Text('Accept ${prompt.credential.displayName}?'),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Issuer: ${prompt.credential.issuer.displayName}'),
if (!prompt.isIssuerTrusted)
Text(prompt.issuerTrustWarningMessage ?? 'Issuer not trusted',
style: const TextStyle(color: Colors.red)),
if (prompt.isDuplicate) const Text('You already hold this credential'),
const SizedBox(height: 8),
for (final claim in prompt.credential.claims)
Text('${claim.label}: ${claim.value}'),
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('Decline'),
),
FilledButton(
onPressed: () => Navigator.pop(context, true),
child: const Text('Accept'),
),
],
),
);
return accepted == true
? const CredentialAcceptanceAccepted()
: const CredentialAcceptanceDeclined();
},
);
See Prompts for the full contract, including the shared RecoverableErrorPrompt used for retryable failures during this flow.
Issuer trust behavior
The issuer trust check follows the active TrustNetworkMode:
off— no check;isIssuerTrustedis not meaningful.warn— the flow proceeds, but the consent prompt carriesisIssuerTrusted: falseand a warning message; the user decides.strict— the flow is blocked for untrusted issuers before consent.
Handling offers from QR codes and deep links
Offers arrive as openid-credential-offer:// URIs from scanned QR codes or registered deep links. Use handleLink to classify inbound URIs and obtain a normalized offer URL before calling acceptOffer:
final linkResult = await sdk.handleLink(
HandleLinkInput(uri: scannedText, walletExternalId: apiWalletId),
);
if (linkResult case HandleLinkCredentialOffer(:final normalizedOfferUrl)) {
final result = await sdk.acceptOffer(
AcceptOfferRequest(
walletExternalId: apiWalletId,
offer: normalizedOfferUrl,
did: holderDid,
),
);
// switch on result ...
}
Errors
- Configuration problems (issuance not enabled, missing prompt handler) throw
WalletSdkExceptionimmediately. - Flow failures (unreachable issuer, invalid offer, exchange errors) surface either as a
RecoverableErrorPrompt(letting the user retry) or asAcceptOfferFailurewith a diagnostic message. - If your prompt handler throws
PromptCancelledException, the flow unwinds cleanly and the offer is not accepted.