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 the Enterprise Wallet2 Service.

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 Wallet2 service with an attached KMS containing two signing keys and a writable Credential Store.
  • An Enterprise bearer token authorized to receive credentials and use those dependencies.
  • 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.

Use your organization host and wallet resource target below. Replace the example KMS paths with keys in the wallet's attached KMS; keys remain in that KMS. If a configuration uses DID binding, supply each key's matching did or attached didReference in its holder binding.

Select Targets and Copies

Enterprise Issuer2 accepts the original single-offer body at a profile target, or a credentials[] body at the issuer target. 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 /v2/{target}/wallet-service-api/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.

SelectionResult
Omit credentials in pre-authorized receiveAll granted targets, one holder binding each
Select configurations with one binding eachRequest one copy of each granted target in that subset
Select one configuration with two bindingsRequest two copies per granted dataset for that configuration
Select two datasets, each with two bindingsTwo 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.

CURL

Endpoint: POST /v2/{target}/wallet-service-api/credentials/receive | API Reference

Example Request
curl --fail-with-body -X 'POST' \
  'https://{orgID}.enterprise-sandbox.waltid.dev/v2/{target}/wallet-service-api/credentials/receive' \
  -H 'accept: application/json' \
  -H 'Content-Type: application/json' \
  -H 'Authorization: Bearer {yourToken}' \
  -d '{
  "offerUrl": "{credentialOffer}",
  "credentials": [
    {
      "credentialConfigurationId": "{credentialConfigurationId}",
      "holderBindings": [
        {
          "keyReference": "org.tenant.kms.holder1"
        },
        {
          "keyReference": "org.tenant.kms.holder2"
        }
      ]
    }
  ]
}'

Path Parameters

  • orgID: String (required) - Your organization host alias.
  • target: String (required) - The wallet resource path, such as org.tenant.wallet.

Header Parameters

  • Authorization: String (required) - Enterprise bearer token: Bearer {yourToken}.

Body Parameters

  • offerUrl: String (required here) - The receipt's credential-offer URL; offerJson is the alternative.
  • credentials: Array (optional) - Explicit configuration selections. Each entry has credentialConfigurationId, optional issuer-granted credentialIdentifier, and optional holderBindings.
  • holderBindings: Array (optional, inside each selection) - One entry per requested copy; each keyReference identifies 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 deferredCredentialId when 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 /v2/{target}/wallet-service-api/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.

CURL

Endpoint: POST /v2/{target}/wallet-service-api/credentials/receive/authorized/batch | API Reference

Example Request
curl --fail-with-body -X 'POST' \
  'https://{orgID}.enterprise-sandbox.waltid.dev/v2/{target}/wallet-service-api/credentials/receive/authorized/batch' \
  -H 'accept: application/json' \
  -H 'Content-Type: application/json' \
  -H 'Authorization: Bearer {yourToken}' \
  -d '{
  "code": "{validatedAuthorizationCode}",
  "codeVerifier": "{codeVerifier}",
  "clientId": "wallet-client",
  "redirectUri": "https://wallet.example.org/callback",
  "credentialIssuer": "{credentialIssuer}",
  "credentialEndpoint": "{credentialEndpoint}",
  "nonceEndpoint": "{nonceEndpoint}",
  "credentials": [
    {
      "credentialConfigurationId": "{credentialConfigurationId}",
      "holderBindings": [
        {
          "keyReference": "org.tenant.kms.holder1"
        },
        {
          "keyReference": "org.tenant.kms.holder2"
        }
      ]
    }
  ]
}'

Path Parameters

  • orgID: String (required) - Your organization host alias.
  • target: String (required) - The wallet resource path, such as org.tenant.wallet.

Header Parameters

  • Authorization: String (required) - Enterprise bearer token: Bearer {yourToken}.

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 /authorized endpoint accepts the singular credentialConfigurationId input 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:

CURL

Endpoint: GET /v2/{target}/wallet-service-api/credentials/receive/deferred | API Reference

Example Request
curl --fail-with-body -X 'GET' \
  'https://{orgID}.enterprise-sandbox.waltid.dev/v2/{target}/wallet-service-api/credentials/receive/deferred' \
  -H 'accept: application/json' \
  -H 'Authorization: Bearer {yourToken}'

Path Parameters

  • orgID: String (required) - Your organization host alias.
  • target: String (required) - The wallet resource path, such as org.tenant.wallet.

Header Parameters

  • Authorization: String (required) - Enterprise bearer token: Bearer {yourToken}.

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:

CURL

Endpoint: POST /v2/{target}/wallet-service-api/credentials/receive/deferred/{deferredCredentialId} | API Reference

Example Request
curl --fail-with-body -X 'POST' \
  'https://{orgID}.enterprise-sandbox.waltid.dev/v2/{target}/wallet-service-api/credentials/receive/deferred/{deferredCredentialId}' \
  -H 'accept: application/json' \
  -H 'Authorization: Bearer {yourToken}'

Path Parameters

  • orgID: String (required) - Your organization host alias.
  • target: String (required) - The wallet resource path, such as org.tenant.wallet.
  • deferredCredentialId: String (required) - The opaque handle returned by this wallet.

Header Parameters

  • Authorization: String (required) - Enterprise bearer token: Bearer {yourToken}.

Example Response
{
  "type": "stored",
  "sessionId": "{deferredCredentialId}",
  "credentialIds": [
    "{credentialId}"
  ]
}

Response Fields

  • type: String - stored, deferred, failed, or cancelled.
  • credentialIds: String - IDs from a stored result. Merge them with previous results.
  • storedCredentialIds: String - Committed progress in deferred or failed results.
  • credentials / deferredCredentials: Array - Pending handles in deferred / failed results 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.

Retained state requires the same wallet resource and accessible holder keys. Each resume uses the caller's current permissions and attached dependencies; fix missing access, quota, or store configuration before retrying a recoverable local save. Successful saves emit per-credential events and usage callbacks. Transaction-enabled stores serialize wallet deletion with credential and continuation writes. Deployments with multiDocumentTransactions=false retain best-effort writes; stop in-flight issuance before deletion because that mode cannot guarantee cross-replica atomicity.

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.

Enterprise full receive and sign-proof use keyReference. The shared isolated fetch and poll bodies use holderBindings[].keyId containing the same full KMS key path, plus a resolved did when needed. Their DPoP sender-key field is dpopKeyId.

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

Last updated on October 12, 2026