Skip to main content

Issuance

Issuance is a suspending state machine. You resolve a credential offer (or point the wallet straight at an issuer), then drive a session toward a terminal state. The session pauses at every point that needs a human or the app — browser authorization, a transaction code — and resumes when you call back into it.

Both platforms model the same machine. Kotlin exposes it as a coroutine StateFlow; Swift exposes it as an AsyncStream.

The session state machine​

wallet.issuance.start(…) returns an IssuanceSession. It emits a sequence of states; three of them are terminal (isTerminal == true), and every flow ends on one of those.

StageKotlin IssuanceStateSwift IssuanceStateMeaning
PreparingPreparing.preparingFetching issuer metadata, building PAR + PKCE.
AuthorizationAuthorizationRequired(authorizationUrl).authorizationRequired(String)Open the URL in a browser (authorization-code flow), then call completeAuthorization.
Tx codeTxCodeRequired(txCode).txCodeRequired(TxCodeSpec?)The issuer expects an out-of-band code; call submitTxCode. txCode carries §4.1.1 input hints, if any.
ProcessingProcessing.processingExchanging the code and requesting the credential(s).
DeferredDeferred(credentialId, retryAfter).deferred(credentialId:, retryAfter:)Terminal. Issuer accepted but isn't ready (§9.2). The credential is stored deferred; call resumeDeferred(credentialId) after retryAfter.
CompletedCompleted(result).completed(IssuanceResult)Terminal. result.issued is the list of stored CredentialIds.
FailedFailed(error).failed(IssuanceError)Terminal. Carries the error.

:::note Deferred is terminal Deferred is not a failure and not a pause you can resume in place — the session ends there. The else -> {} / default branch in the terminal idiom below swallows it, so if your issuer defers, match Deferred explicitly and schedule a resumeDeferred(credentialId) for after retryAfter (null → retry immediately). :::

Session controls: submitTxCode(code), completeAuthorization(redirectUri), and cancel(). Kotlin reads the current value from session.state; Swift from session.states (and the synchronous snapshot session.currentState).

Awaiting a terminal state​

The idiom you will reuse in every flow below is "run until the machine reaches a terminal state":

import kotlinx.coroutines.flow.first

when (val terminal = session.state.first { it.isTerminal }) {
is IssuanceState.Completed -> terminal.result.issued // List<CredentialId>
is IssuanceState.Failed -> handle(terminal.error)
else -> {}
}

What Failed carries​

Failed.error is a WalletError.Issuance (Kotlin) / IssuanceError (Swift):

CaseWhen
InvalidOffer / .invalidOfferThe offer URI or JSON could not be parsed / resolved.
AuthorizationFailed / .authorizationFailed(oauthError:)The authorization or token step was rejected — the OAuth error code is preserved.
CredentialRequestFailed / .credentialRequestFailedThe issuer refused or could not fulfil the credential request.
Unexpected / .unexpectedAny other error (transport, encoding), wrapping the cause.

Pre-authorized code flow​

The most common flow: the issuer hands you an offer (deep link, QR, or raw JSON). You resolve it, start a request for one of its configurations, and — if the offer carries a transaction code you already know — pre-fill txCode. Then you drive the session to a terminal state.

import kotlinx.coroutines.flow.first

val offer = wallet.issuance.resolveOffer(offerUri) // deep link / QR / raw JSON

val session = wallet.issuance.start(
IssuanceRequest.fromOffer(
offer,
configurationId = offer.credentialConfigurationIds.first(),
txCode = "1234", // pre-known transaction code, if any
),
)

when (val terminal = session.state.first { it.isTerminal }) {
is IssuanceState.Completed -> terminal.result.issued // stored credential ids
is IssuanceState.Failed -> handle(terminal.error)
else -> {}
}

offer.requiresTxCode tells you up front whether the issuer will demand a transaction code, so you can decide whether to prompt the user.

Interactive transaction code​

If the offer requires a tx_code you don't have yet — for example, one the issuer sends by SMS or email — start without txCode. The session pauses at TxCodeRequired; collect the code from the user and call submitTxCode, then continue to a terminal state.

import kotlinx.coroutines.flow.first

val session = wallet.issuance.start(
IssuanceRequest.fromOffer(offer, offer.credentialConfigurationIds.first()), // no txCode
)

// The session pauses here until the code arrives out-of-band:
session.state.first { it is IssuanceState.TxCodeRequired }
session.submitTxCode(userEnteredCode) // e.g. from SMS / email

val terminal = session.state.first { it.isTerminal } // Completed | Failed

Authorization-code flow (browser)​

When there is no offer — a wallet-initiated request — or when the offer uses the authorization-code grant, use fromIssuer with the issuer URL and configuration id. The session pauses at AuthorizationRequired, whose value is the authorization URL. Open it in a browser; when the browser redirects back to your redirectUri, hand the full redirect to completeAuthorization and the session resumes.

import kotlinx.coroutines.flow.first

val session = wallet.issuance.start(
IssuanceRequest.fromIssuer(
credentialIssuer = "https://issuer.example.org",
configurationId = "eu.europa.ec.eudi.pid_jwt_vc_json",
),
)

// 1. Pause at AuthorizationRequired — open the URL in a browser:
val pending = session.state.first { it is IssuanceState.AuthorizationRequired }
openInBrowser((pending as IssuanceState.AuthorizationRequired).authorizationUrl)

// 2. From your redirect (deep-link) handler, feed the full redirect back in:
session.completeAuthorization(redirectUri) // "eudi-wallet://authorize?code=…&state=…"

// 3. It resumes to a terminal state:
val terminal = session.state.first { it.isTerminal }

The clientId and redirectUri come from your IssuanceConfig (defaults "wallet-dev" and "eudi-wallet://authorize").

What the SDK handles for you​

Everything below the session API is done for you — you never touch these wire details:

  • PAR + PKCE + DPoP (RFC 9449) — pushed authorization requests, code-verifier binding, and proof-of-possession JWTs on every token and credential call.
  • Key attestation — proof-of-possession and key-attestation JWTs per HAIP §8.2.1.1.
  • Credential identifiers (§8.2) — when the token response's authorization_details bind concrete credential_identifiers to a configuration, the SDK requests by credential_identifier instead of credential_configuration_id, transparently.
  • Batch issuance — request N instances of a credential in one shot (see key policy below).
  • Deferred (§9) — polls the deferred endpoint until the credential is ready.
  • Notification (§10) — sends the credential_accepted notification automatically.
  • Refresh / reissuance — stores the refresh token and reuses it to renew credentials.
  • Metadata capture — the issuer identity and display metadata are stored on the credential at issuance time (credential.issuer, credential.display).

Every credential is bound to a fresh holder key created inside your SecureArea; private keys never leave it.

Deferred and reissuance​

Both operations return an IssuanceSession that you drive exactly like the flows above.

resumeDeferred(id) resumes a credential the issuer accepted but hadn't finished minting (a deferred transaction) — the id is the credentialId a prior session surfaced in its terminal Deferred state. reissue(id) renews an existing credential using its stored refresh token and metadata.

import kotlinx.coroutines.flow.first

// Deferred — resume a not-yet-ready credential:
val deferred = wallet.issuance.resumeDeferred(credentialId)
deferred.state.first { it.isTerminal }

// Reissue — renew an existing credential via its stored refresh token:
val renewed = wallet.issuance.reissue(credentialId)
renewed.state.first { it.isTerminal }

Key policy and batch size​

CredentialPolicy controls how many holder keys are minted and how they are consumed at presentation time. It defaults to CredentialPolicy(batchSize = 1, use = Rotate).

IssuanceRequest.fromOffer(
offer,
configurationId = offer.credentialConfigurationIds.first(),
policy = CredentialPolicy(batchSize = 5, use = KeyUse.OneTime),
)
  • OneTime (Kotlin KeyUse.OneTime / Swift .oneTime) — each presentation consumes a fresh instance, so no two presentations share a key. This gives unlinkability across verifiers. Pair it with a batchSize large enough to cover expected presentations before reissuance.
  • Rotate (Kotlin KeyUse.Rotate / Swift .rotate) — instances are reused across presentations. Fewer keys and issuance round-trips, but presentations made with the same key are correlatable.

batchSize is the number of instances requested in a single batch. With OneTime, each instance is spent once; when they run low, reissue(id) tops them up.

Encrypted Credential Requests and Responses​

OpenID4VCI §8.2/§10 lets the issuer encrypt the Credential Response to a key the wallet supplies. The SDK negotiates it from the issuer's metadata:

Openid4VciClient(http, rng, clock, credentialEncryption = CredentialEncryption.Preferred)
PolicyBehaviour
WhenRequired (default)Encrypt only if the issuer sets encryption_required — plaintext otherwise
PreferredEncrypt whenever the issuer advertises credential_response_encryption
RequiredAlways encrypt; fail against an issuer that cannot

:::note Both directions, or neither

Credential Request encryption MUST be used if the credential_response_encryption parameter is included, to prevent it being substituted by an attacker.

Otherwise an attacker could swap the wallet's jwk in the plaintext request and have the credential encrypted to their own key. So when encryption is on, the SDK also sends the Credential Request as a compact JWE (application/jwt) encrypted to a key from credential_request_encryption.jwks, echoing that key's kid in the JWE header as §10 requires. An issuer that advertises response encryption but no request-encryption key is rejected as non-conformant. :::

The wallet's response-encryption key is a fresh in-process P-256 key per request (JweRecipientKey) — short-lived transport material, not credential key material, so it does not go through SecureArea. ECDH-ES with A128GCM/A192GCM/A256GCM is supported; the strongest mutually supported enc wins. A plaintext answer to an encrypted request is an error, not a fallback (§10).