Receiving Credential Batches via OID4VCI
This guide shows you how to select offered credentials, request copies bound to different holder keys, and resume pending results with Wallet API v2.
OID4VCI delivers credentials from an issuer to a wallet. A target is one credential configuration and dataset; copies share that target but have explicit holder bindings.
Prerequisites
Before you begin, ensure you have:
- A running Wallet API v2, a wallet, two existing signing keys, and a writable credential store.
- A fresh credential-offer URL or protocol offer JSON. For the two-copy example, its issuer must advertise JWT proofs and a batch limit of at least two.
Examples use Docker Compose Wallet2 at http://localhost:7006; standalone Wallet2 defaults to port 7005. Add your deployment's bearer token when wallet authentication is enabled. If a configuration uses DID binding, supply each key's matching did in its holder binding.
Select Targets and Copies
Community Issuer2 accepts the original profileId body or the multi-offer credentials[] body at /issuer2/credential-offers. Both receipts contain credentialOffer. Pass that URL as offerUrl, or pass the resolved protocol offer object as offerJson; supply exactly one. The issuer management body is not a wallet request. Management profile IDs can differ from protocol configuration IDs.
First call POST /wallet/{walletId}/credentials/receive/resolve-offer/batch with the same offer source as the isolated resolve step. The response contains offer (the existing offer details) and batchSize. Use offer.credentialConfigurationIds for selections and check batchSize before requesting copies. An absent batch limit permits only one holder binding.
| Selection | Result |
|---|---|
Omit credentials in pre-authorized receive | All granted targets, one holder binding each |
| Select configurations with one binding each | Request one copy of each granted target in that subset |
| Select one configuration with two bindings | Request two copies per granted dataset for that configuration |
| Select two datasets, each with two bindings | Two Credential Endpoint requests with two JWT proofs each |
The wallet expands token authorization_details[].credential_identifiers into dataset targets. Do not invent identifiers or substitute profile IDs. If selecting a specific dataset, use an identifier actually granted by the issuer. Different configurations or datasets use separate requests; copies of one target use proofs.jwt. The advertised batch_credential_issuance.batch_size limits copies in each request, not the number of offer entries.
Omitting holderBindings defaults to one binding. Neither the issuer's limit nor the number of offered targets generates holder keys or requests extra copies. Obtain consent for the selections and copy count before receiving.
Receive a Pre-Authorized Offer
Replace the offer, configuration ID, and key references below with values from your deployment. This example selects one configuration and requests two copies per granted dataset. Add txCode only when the resolved offer requires a separately delivered transaction code.
Endpoint: POST /wallet/{walletId}/credentials/receive | API Reference
Example Request
curl --fail-with-body -X 'POST' \
'http://localhost:7006/wallet/{walletId}/credentials/receive' \
-H 'accept: application/json' \
-H 'Content-Type: application/json' \
-d '{
"offerUrl": "{credentialOffer}",
"credentials": [
{
"credentialConfigurationId": "{credentialConfigurationId}",
"holderBindings": [
{
"keyId": "{holderKeyId1}"
},
{
"keyId": "{holderKeyId2}"
}
]
}
]
}'
Path Parameters
- walletId: String (required) - The ID of the wallet receiving the credentials.
Body Parameters
- offerUrl: String (required here) - The receipt's credential-offer URL;
offerJsonis the alternative. - credentials: Array (optional) - Explicit configuration selections. Each entry has
credentialConfigurationId, optional issuer-grantedcredentialIdentifier, and optionalholderBindings. - holderBindings: Array (optional, inside each selection) - One entry per requested copy; each
keyIdidentifies an existing signing key. Each copy requires a distinct public key; different references to the same key are rejected before grant redemption. An omitted array uses one default binding; an empty array is invalid. - txCode: String (optional) - Transaction code required by the offer.
Example Response
{
"credentialIds": [
"{credentialId1}",
"{credentialId2}"
],
"deferredCredentials": []
}
Response Fields
- credentialIds: String - Stored credential IDs. This example assumes one dataset and two successful copies.
- deferredCredentials: Array - Pending issuer targets, including their opaque
deferredCredentialIdwhen retained. - failure: Object (optional) - The stopped target, stage, and unattempted targets; earlier results remain valid.
- storageOutcome: Object (optional) - Local-save recovery, including committed IDs and retained handles.
Receive an Authorization-Code Offer
Use a fresh authorization-code offer and call POST /wallet/{walletId}/credentials/receive/authorization-url/batch with this body:
{
"offerUrl": "openid-credential-offer://?credential_offer_uri=...",
"credentialConfigurationIds": ["identity_credential"],
"clientId": "wallet-client",
"redirectUri": "https://wallet.example.org/callback"
}
Select offered configuration IDs, or omit the list to authorize all offered configurations. For issuance without an offer, replace offerUrl with credentialIssuer and supply the configuration IDs. The response contains authorizationUrl, state, codeVerifier, credentialConfigurationIds, credentialIssuerBaseUrl, and optional nonceEndpoint. Keep the selected IDs, holder bindings, client/redirect values, state and verifier until the browser returns.
Authorization-code flows use authorization_details when the authorization server advertises openid_credential, otherwise the selected configurations' advertised scopes. They require one of these selectors. An offered pre-authorized code already authorizes its credentials; its token request uses supported selectors for narrowing but does not require them. The batch endpoint and retained sessions negotiate automatically; the separate single-configuration endpoint retains its useScope option.
Validate the callback destination, state, and any required authorization-server iss before accepting its code. Then call authorized below, using issuer endpoints from offer resolution. This call exchanges the code itself; do not first redeem it through exchange-code.
Endpoint: POST /wallet/{walletId}/credentials/receive/authorized/batch | API Reference
Example Request
curl --fail-with-body -X 'POST' \
'http://localhost:7006/wallet/{walletId}/credentials/receive/authorized/batch' \
-H 'accept: application/json' \
-H 'Content-Type: application/json' \
-d '{
"code": "{validatedAuthorizationCode}",
"codeVerifier": "{codeVerifier}",
"clientId": "wallet-client",
"redirectUri": "https://wallet.example.org/callback",
"credentialIssuer": "{credentialIssuer}",
"credentialEndpoint": "{credentialEndpoint}",
"nonceEndpoint": "{nonceEndpoint}",
"credentials": [
{
"credentialConfigurationId": "{credentialConfigurationId}",
"holderBindings": [
{
"keyId": "{holderKeyId1}"
},
{
"keyId": "{holderKeyId2}"
}
]
}
]
}'
Path Parameters
- walletId: String (required) - The ID of the wallet receiving the credentials.
Body Parameters
- code: String (required) - Authorization code from the validated callback.
- codeVerifier: String (required when PKCE was used) - Verifier returned during authorization URL generation.
- clientId / redirectUri: String (required here) - The same values used for authorization.
- credentialIssuer / credentialEndpoint: String (required) - Values from offer resolution.
- nonceEndpoint: String (optional) - Include only when advertised by the issuer.
- credentials: Array (required, nonempty) - The selections and holder bindings approved before browser continuation. The separate original
/authorizedendpoint accepts the singularcredentialConfigurationIdinput for one target.
Example Response
{
"credentialIds": [
"{credentialId1}",
"{credentialId2}"
],
"deferredCredentials": []
}
Response Fields
- Result fields: Same stored IDs, pending targets, and optional failure/storage recovery as pre-authorized receive.
For DPoP deployments, keep the same wallet sender key throughout the flow and set useDpop: true on authorized receive. Copy holder keys do not replace the key that binds the access token.
Keep Partial Results and Resume
Always inspect the response body. HTTP 200 can contain both stored credentials and deferred targets. HTTP 207 reports retained progress alongside a failure, including a local-save handle even if no credential was saved yet. Without retained progress, target proof failures use 422, issuer request/response failures use 502, and storage/observer failures use 500.
Keep credentialIds, deferredCredentials, and handles in storageOutcome.deferredCredentials. Deduplicate IDs: the top-level IDs cover the whole flow, while IDs inside storageOutcome cover the stopped batch. Batch responses use ReceiveCredentialsResult and its complete target list. Requests without explicit pre-authorized selections and the original /authorized endpoint retain the released successful ReceiveCredentialResult shape with deferredTransactionIds. If multiple deferred datasets cannot fit that map, the legacy HTTP boundary returns 409 with the complete detailed result and retained handles; do not redeem the grant again.
List retained handles after reconnecting:
Endpoint: GET /wallet/{walletId}/credentials/receive/deferred | API Reference
Example Request
curl --fail-with-body -X 'GET' \
'http://localhost:7006/wallet/{walletId}/credentials/receive/deferred' \
-H 'accept: application/json'
Path Parameters
- walletId: String (required) - The ID of the wallet receiving the credentials.
Example Response
[
{
"id": "{deferredCredentialId}",
"credentialConfigurationId": "{credentialConfigurationId}",
"intervalSeconds": 5
}
]
Response Fields
- id: String - Opaque wallet continuation; it is not an issuer transaction ID.
- credentialConfigurationId / credentialIdentifier: String (optional) - The retained configuration and dataset, when known.
- intervalSeconds: Integer (optional) - Wait before a remote poll. Null or absent means an already received response can be saved locally now.
Resume each handle separately, without resending tokens or redeeming the grant again:
Endpoint: POST /wallet/{walletId}/credentials/receive/deferred/{deferredCredentialId} | API Reference
Example Request
curl --fail-with-body -X 'POST' \
'http://localhost:7006/wallet/{walletId}/credentials/receive/deferred/{deferredCredentialId}' \
-H 'accept: application/json'
Path Parameters
- walletId: String (required) - The ID of the wallet receiving the credentials.
- deferredCredentialId: String (required) - The opaque handle returned by this wallet.
Example Response
{
"type": "stored",
"sessionId": "{deferredCredentialId}",
"credentialIds": [
"{credentialId}"
]
}
Response Fields
- type: String -
stored,deferred,failed, orcancelled. - credentialIds: String - IDs from a
storedresult. Merge them with previous results. - storedCredentialIds: String - Committed progress in
deferredorfailedresults. - credentials / deferredCredentials: Array - Pending handles in
deferred/failedresults respectively. Keep them until completion.
An early resume returns deferred with the remaining wait and does not contact the issuer. Retained remote deadlines survive restart when persistence is configured. Local-save retries save only missing credentials from the already received response.
REMOTE_OUTCOME_UNCERTAIN or STORAGE_OUTCOME_UNCERTAIN means another operation may still be running or stopped without a confirmed outcome. Keep the handle and reported IDs for reconciliation; do not automatically replay the offer, poll again, or take over the claim. If the initial response checkpoint failed, only the surviving runtime may retain that response.
With SQL persistence configured, store-backed holder bindings and retained responses survive service recreation. In-memory deployments have process-local continuation semantics. Storage-producing receive, fetch, and poll operations require wallet key IDs; inline holder keys are rejected even if their public material matches a stored key. Inline keys remain available for isolated proof signing. Keep the selected holder keys available until completion.
Isolated Steps and Presentation
For a custom isolated flow, retain token authorization details and issue a separate request per granted dataset. Pass all holderBindings to sign-proof, then the returned proofJwt or proofs field to fetch-credential, together with the issuer's credentialIdentifier when present and credentialIssuerBaseUrl for batch-limit validation. When storeInWallet is true, pass the same holder bindings used for those proofs. Preserve the token type and DPoP sender-key context independently of holder keys.
A single signed proof retains the released {"proofJwt":"..."} response; multiple proofs use
{"proofs":{"jwt":["...","..."]}}. Supply the returned proof field in the fetch request.
Requests using only the original fetch/poll fields retain their original successful response
bodies. Explicit batch fields select detailed progress; preserve deferred or local-save handles
from any error body instead of repeating the issuer request.
For caller-managed deferred polling, preserve the original access token, sender key, holder bindings, proofRequired, and dataset identifier; respect the issuer's interval. If storage fails after receipt, resume the returned local-save handle instead of polling the issuer again.
The wallet matches received credentials to holder keys cryptographically, even when the response order differs. For presentation, select stored credential IDs and omit a signing-key override to use their retained holder associations.
Next Steps
- Isolated Flow — Control token, proof, and credential requests individually.
- Issuer2-to-Wallet2 Shell Walkthrough — All four Community/Enterprise management contracts, both grants, and explicit copies.
