Tutorial v2 (30 min)

In this 30 min tutorial, we will take a look at the Issuer2, Wallet2, and Verifier2 APIs of the walt.id Community Stack — the latest generation of the issuer, wallet, and verifier services, implementing OID4VCI 1.0 and OID4VP 1.0.

Still building against the original Issuer, Wallet, and Verifier? Use the v1 tutorial instead.

The tutorial is split into three sections:

  1. Create a wallet
  2. Issue a credential into the wallet
  3. Request & verify the credential from the wallet

With that, we are covering a complete digital credential use case end-to-end using the walt.id v2 API components:

The Credential

For this tutorial, we will issue ourselves an Open Badge Credential — a W3C Verifiable Credential with a JWT signature (jwt_vc_json), issued from a profile that ships out of the box with Issuer2, so there's no configuration to write before your first credential.

If you are curious to learn more about other credential formats, go here. Issuer2, Wallet2, and Verifier2 also support SD-JWT VC and mdoc (ISO 18013-5) — this tutorial sticks to W3C VC to keep the walkthrough focused.

Example Open Badge Credential (as issued)

{
  "@context": [
    "https://www.w3.org/ns/credentials/v2",
    "https://purl.imsglobal.org/spec/ob/v3p0/context-3.0.3.json",
    "https://purl.imsglobal.org/spec/ob/v3p0/extensions.json"
  ],
  "id": "urn:uuid:6bc9b310-4ef2-4d0a-97e7-c577b4c944d4",
  "type": ["VerifiableCredential", "OpenBadgeCredential"],
  "issuer": {
    "id": "did:jwk:...",
    "name": "Jobs for the Future (JFF)",
    "type": ["Profile"],
    "url": "https://www.jff.org/"
  },
  "credentialSchema": [
    {
      "type": "1EdTechJsonSchemaValidator2019",
      "id": "https://purl.imsglobal.org/spec/ob/v3p0/schema/json/ob_v3p0_achievementcredential_schema.json"
    }
  ],
  "credentialSubject": {
    "id": "did:key:...",
    "type": ["AchievementSubject"],
    "achievement": {
      "id": "https://example.com/achievements/21st-century-skills/teamwork",
      "type": ["Achievement"],
      "name": "Teamwork",
      "description": "This badge recognizes the development of the capacity to collaborate within a group environment.",
      "criteria": {
        "narrative": "Team members are nominated for this badge by their peers and recognized upon review by Example Corp management."
      }
    }
  },
  "validFrom": "2026-07-22T12:50:10.122342336Z"
}

Setup

To bring up Issuer2, Wallet2, and Verifier2 locally, we'll use our docker compose setup.

Clone walt.id identity

git clone https://github.com/walt-id/waltid-identity.git && cd waltid-identity/docker-compose

Launch the services

docker compose up

Access APIs


Create a Wallet

By default, Wallet2's auth feature is off — there's no account, no registration, no login, and no bearer token to manage. A wallet is just a container for keys, a DIDs, and credentials, and you get its ID back the moment you create it. Authentication can be enabled — see Authentication.

A new wallet doesn't have any key or DID yet. The key is what the wallet signs with; the DIDs are identifier derived from that key, which credentials get bound to when receiving them (new to DIDs? see Decentralised Identifiers). We generate both next, as two explicit calls right after creating the wallet.

Create the Wallet

CURL

Endpoint: POST /wallet | API Reference

curl -X POST http://localhost:7006/wallet \
  -H 'Content-Type: application/json' \
  -d '{}'

Body Parameters

An empty body is enough — all fields are optional for the default, fully-managed wallet. See Managing Wallets for noDidStore and the store-less-wallet options.

Example Response

{
  "walletId": "39c44fcf-63bf-41fb-a7de-d99d09458940"
}

Save the walletId — every subsequent call is scoped to it.

Generate a Key

CURL

Endpoint: POST /wallet/{walletId}/keys/generate | API Reference

curl -X POST http://localhost:7006/wallet/{walletId}/keys/generate \
  -H 'Content-Type: application/json' \
  -d '{ "keyType": "Ed25519" }'

Body Parameters

  • keyType: String (optional) - One of Ed25519, secp256r1, secp256k1, RSA. Defaults to Ed25519.

Example Response

{
  "keyId": "onxChakLCfOVM_YyZOOJ09fO1hgPrXxKxiBIPwTeack",
  "keyType": "Ed25519"
}

This is the wallet's first (and so far only) key — it automatically becomes the default key, used for signing whenever a later call doesn't specify keyId explicitly.

Create a DID

W3C VCs bind the credential subject to a DID, not just a key, so we need one before we can receive one.

CURL

Endpoint: POST /wallet/{walletId}/dids/create | API Reference

curl -X POST http://localhost:7006/wallet/{walletId}/dids/create \
  -H 'Content-Type: application/json' \
  -d '{ "method": "key" }'

Body Parameters

  • method: String (required) - The DID method to create. Supported: key, jwk.
  • keyId: String (optional) - Key to derive the DID from. Defaults to the wallet's default key — the one we just generated.

Example Response

{
  "did": "did:key:z6MksLjzRVB47ciKofd8AmNNrtvjrEd5DfTHUsUAQtFmiimD",
  "document": {
    "@context": ["https://www.w3.org/ns/did/v1", "https://w3id.org/security/suites/jws-2020/v1"],
    "id": "did:key:z6MksLjzRVB47ciKofd8AmNNrtvjrEd5DfTHUsUAQtFmiimD",
    "verificationMethod": [
      {
        "id": "did:key:z6MksLjz...#z6MksLjz...",
        "type": "JsonWebKey2020",
        "controller": "did:key:z6MksLjzRVB47ciKofd8AmNNrtvjrEd5DfTHUsUAQtFmiimD",
        "publicKeyJwk": { "kty": "OKP", "crv": "Ed25519", "x": "..." }
      }
    ],
    "assertionMethod": ["did:key:z6MksLjz...#z6MksLjz..."],
    "authentication": ["did:key:z6MksLjz...#z6MksLjz..."]
  }
}

This is now the wallet's default DID too. Both defaults mean every call from here on can omit keyId and did entirely.


Issue Credential

Confirm the Profile Is Loaded (optional)

Issuer2 issues from profiles — reusable credential configurations declared ahead of time (issuer key, issuer DID, credential template, etc.). openBadgeCredential ships pre-configured:

CURL

Endpoint: GET /issuer2/profiles/{profileId} | API Reference

curl http://localhost:7005/issuer2/profiles/openBadgeCredential

Example Response (shortened)

{
  "profileId": "openBadgeCredential",
  "name": "OpenBadgeCredential",
  "credentialConfigurationId": "OpenBadgeCredential_jwt_vc_json",
  "issuerKey": { "type": "jwk", "jwk": { "kty": "EC", "crv": "P-256", "...": "..." } },
  "issuerDid": "did:jwk:...",
  "credentialData": {
    "@context": ["https://www.w3.org/ns/credentials/v2", "..."],
    "type": ["VerifiableCredential", "OpenBadgeCredential"],
    "credentialSubject": { "achievement": { "name": "Teamwork", "...": "..." } }
  },
  "mapping": {
    "id": "<uuid>",
    "issuer": { "id": "<issuerDid>" },
    "credentialSubject": { "id": "<subjectDid>" },
    "validFrom": "<timestamp>"
  }
}

Create a Credential Offer

Now let's actually issue a credential from that profile. This call creates a credential offer — a one-time, short-lived pointer to a not-yet-issued credential that a wallet can redeem.

CURL

Endpoint: POST /issuer2/credential-offers | API Reference

curl -X POST http://localhost:7005/issuer2/credential-offers \
  -H 'Content-Type: application/json' \
  -d '{
    "profileId": "openBadgeCredential",
    "authMethod": "PRE_AUTHORIZED"
  }'

Body Parameters

  • profileId: String (required) - The profile to issue from.
  • authMethod: String (required) - PRE_AUTHORIZED requires no user login and is the simplest way to get started. Issuer2 also supports AUTHORIZED (authorization-code flow against an external IdP) — see Authorization Code Flow.

Example Response

{
  "offerId": "e124ba49-df08-45e1-8caa-810a712122e6",
  "profileId": "openBadgeCredential",
  "authMethod": "PRE_AUTHORIZED",
  "expiresAt": 1784724878505,
  "credentialOffer": "openid-credential-offer://?credential_offer_uri=http%3A%2F%2Fhost.docker.internal%3A7005%2Fopenid4vci%2Fcredential-offer%3Fid%3De124ba49-df08-45e1-8caa-810a712122e6"
}

credentialOffer is the OID4VCI offer URL — hand it directly to Wallet2 in the next step. Notice it already resolves via host.docker.internal, which every container in the compose network can reach, so it works whether the caller is your terminal, the demo wallet, or (as we'll do next) another container.


Receive Credential in Wallet

This single call looks up the offer's details, signs a proof that the wallet owns the key the credential will be bound to, then fetches the credential and stores it.

CURL

Endpoint: POST /wallet/{walletId}/credentials/receive | API Reference

curl -X POST http://localhost:7006/wallet/{walletId}/credentials/receive \
  -H 'Content-Type: application/json' \
  -d '{
    "offerUrl": "openid-credential-offer://?credential_offer_uri=http%3A%2F%2Fhost.docker.internal%3A7005%2Fopenid4vci%2Fcredential-offer%3Fid%3De124ba49-df08-45e1-8caa-810a712122e6"
  }'

Body Parameters

  • offerUrl: String - The credentialOffer value from the previous step.

Example Response

{
  "credentialIds": ["3d14e4b9-9478-41ed-bb52-bcf76b7dd36c"],
  "deferredTransactionIds": {}
}

Confirm It Was Stored

CURL

Endpoint: GET /wallet/{walletId}/credentials | API Reference

curl http://localhost:7006/wallet/{walletId}/credentials

Example Response

[
  {
    "id": "3d14e4b9-9478-41ed-bb52-bcf76b7dd36c",
    "format": "jwt_vc_json",
    "issuer": "did:jwk:eyJrdHkiOiJFQyIsImNydiI6IlAtMjU2Ii...",
    "subject": "did:key:z6MksLjzRVB47ciKofd8AmNNrtvjrEd5DfTHUsUAQtFmiimD",
    "addedAt": "2026-07-22T12:50:10.138097127Z"
  }
]

🎉 You've received an Open Badge Credential into the wallet. To see the full parsed contents (not just metadata), use GET /wallet/{walletId}/credentials/{credentialId} — see Credential Management.


Request & Verify Credential

We now switch to Verifier2 to build an OID4VP authorization request. Verifier2 uses DCQL (Credential Query Language) to describe the credentials it wants — the credential type/format is expressed as a dcql_query directly in the request body.

CURL

Endpoint: POST /verification-session/create | API Reference

curl -X POST http://localhost:7004/verification-session/create \
  -H 'Content-Type: application/json' \
  -d '{
    "flow_type": "cross_device",
    "core_flow": {
      "dcql_query": {
        "credentials": [
          {
            "id": "credential_1",
            "format": "jwt_vc_json",
            "meta": { "type_values": [["VerifiableCredential", "OpenBadgeCredential"]] }
          }
        ]
      },
      "policies": {
        "vc_policies": [{ "policy": "signature" }]
      }
    }
  }'

Body Parameters

  • flow_type: String (required) - cross_device (QR code), same_device (deep link), dc_api / dc_api-annex-c (browser-native Digital Credentials API). We use cross_device here; it needs no extra flow-specific fields.
  • core_flow.dcql_query.credentials: Array (required) - one entry per credential to request. id is a free-choice label used later to key policy results; format + meta.type_values describe what to accept — here, any credential whose type array matches ["VerifiableCredential", "OpenBadgeCredential"].
  • core_flow.policies.vc_policies: Array (optional) - validation rules applied to the received credential. See Policies for the full list. Omitting policies entirely defaults to signature-only verification — the same as what we set explicitly here.

Example Response

{
  "sessionId": "d20e3de1-0695-4c54-bb47-4104d85c41d7",
  "bootstrapAuthorizationRequestUrl": "openid4vp://authorize?client_id=verifier2&request_uri=http%3A%2F%2Fhost.docker.internal%3A7004%2Fverification-session%2Fd20e3de1-0695-4c54-bb47-4104d85c41d7%2Frequest",
  "fullAuthorizationRequestUrl": "openid4vp://authorize?response_type=vp_token&client_id=verifier2&state=1c7706bd-8e23-41a6-8bd9-291c51e8010a&response_mode=direct_post&nonce=1ccec52e-f0d0-48fc-b51e-fc1115c5d930&response_uri=http%3A%2F%2Fhost.docker.internal%3A7004%2Fverification-session%2Fd20e3de1-0695-4c54-bb47-4104d85c41d7%2Fresponse&dcql_query=%7B%22credentials%22%3A%5B%7B%22id%22%3A%22credential_1%22%2C%22format%22%3A%22jwt_vc_json%22%2C%22meta%22%3A%7B%22type_values%22%3A%5B%5B%22VerifiableCredential%22%2C%22OpenBadgeCredential%22%5D%5D%7D%7D%5D%7D&client_metadata=..."
}
  • sessionId: use this to check the result later.
  • bootstrapAuthorizationRequestUrl: the short, request_uri-by-reference form — this is what we hand to the wallet next (and what you'd turn into a QR code for a real cross-device flow).

Present Credential

A single call resolves the verifier's request, runs the DCQL match against the wallet's stored credentials, signs with the default key/DID, and submits the response.

CURL

Endpoint: POST /wallet/{walletId}/credentials/present | API Reference

curl -X POST http://localhost:7006/wallet/{walletId}/credentials/present \
  -H 'Content-Type: application/json' \
  -d '{
    "requestUrl": "openid4vp://authorize?client_id=verifier2&request_uri=http%3A%2F%2Fhost.docker.internal%3A7004%2Fverification-session%2Fd20e3de1-0695-4c54-bb47-4104d85c41d7%2Frequest"
  }'

Body Parameters

  • requestUrl: String - the bootstrapAuthorizationRequestUrl (or fullAuthorizationRequestUrl) from the previous step.

Example Response

{
  "transmission_success": true,
  "verifier_response": {
    "status": "received",
    "message": "Presentation received and is being processed."
  }
}

transmission_success: true only confirms the wallet delivered the presentation — it says nothing about whether the credential actually passed verification. Check that on the verifier side next.


Check the Verification Result

Back on Verifier2, poll (or subscribe via SSE/callback) the session to see the final status and policy results.

CURL

Endpoint: GET /verification-session/{sessionId}/info | API Reference

curl http://localhost:7004/verification-session/{sessionId}/info

Example Response (shortened)

{
  "id": "d20e3de1-0695-4c54-bb47-4104d85c41d7",
  "status": "SUCCESSFUL",
  "attempted": true,
  "reattemptable": true,
  "policy_results": {
    "vp_policies": {
      "credential_1": {
        "jwt_vc_json/audience-check": { "success": true },
        "jwt_vc_json/nonce-check": { "success": true },
        "jwt_vc_json/envelope_signature": { "success": true }
      }
    },
    "vc_policies": [
      {
        "policy": { "policy": "signature", "id": "signature" },
        "success": true,
        "result": {
          "verification_result": true,
          "verified_data": {
            "iss": "did:jwk:...",
            "sub": "did:key:z6MksLjz...",
            "vc": {
              "type": ["VerifiableCredential", "OpenBadgeCredential"],
              "credentialSubject": {
                "id": "did:key:z6MksLjz...",
                "achievement": { "name": "Teamwork", "...": "..." }
              }
            }
          }
        }
      }
    ],
    "overallSuccess": true
  },
  "presented_credentials": {
    "credential_1": [
      { "type": "vc-w3c_1_1", "credentialData": { "...": "the presented credential" } }
    ]
  }
}

status: "SUCCESSFUL" and policy_results.overallSuccess: true confirm the credential was received and the signature policy passed. 🎉

You've issued, received, requested, and verified an Open Badge Credential end-to-end using Issuer2, Wallet2, and Verifier2.


Next Steps

If you enjoy our tools, please leave us a star ⭐ on GitHub.

You can learn more about the different APIs in detail below:

Last updated on July 28, 2026