Skip to main content

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​

FieldTypeRequiredDescription
walletExternalIdStringYesAPI wallet id the credential will be stored under
offerStringYesThe offer URI — openid-credential-offer://... from a QR code or deep link
didStringYesHolder DID used for binding
placeholderVaultCredential?NoPre-rendered pending credential your UI can show while the flow runs
credentialTypesForTrustList<String>?NoOverride 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:

MemberDescription
credentialIssuerIssuer identifier (DID or URL)
credentialConfigurationIdsOffered configuration ids, e.g. VerifiedPerson_vc+sd-jwt
primaryCredentialTypeFirst configuration id
resolveDisplayName()Human-readable label for prompts (e.g. "Verified Person")

Before storing anything, the SDK raises a CredentialAcceptancePrompt through your prompt handler. It carries everything a review screen needs:

FieldDescription
credentialPromptCredentialPreview — display name, logo, claims, and issuer (with trust status)
issuedCredentialThe full VaultCredential payload for preview rendering
resolutionThe CredentialOfferResolution above
isDuplicateWhether an equivalent credential is already stored
isIssuerTrusted / issuerTrustWarningMessageTrust 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; isIssuerTrusted is not meaningful.
  • warn — the flow proceeds, but the consent prompt carries isIssuerTrusted: false and a warning message; the user decides.
  • strict — the flow is blocked for untrusted issuers before consent.

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 WalletSdkException immediately.
  • Flow failures (unreachable issuer, invalid offer, exchange errors) surface either as a RecoverableErrorPrompt (letting the user retry) or as AcceptOfferFailure with a diagnostic message.
  • If your prompt handler throws PromptCancelledException, the flow unwinds cleanly and the offer is not accepted.