Tutorial v2 (30 min)
In this tutorial, we set up the walt.id Enterprise Stack locally and issue, receive, and verify a Verifiable Credential end-to-end using Issuer2, Wallet2, and Verifier2 — implementing OID4VCI 1.0 and OID4VP 1.0.
Still building against the original Issuer, Wallet, and Verifier Services? Use the v1 tutorial instead.
Please note: you need to be an Enterprise Stack customer & have access to the private Enterprise Stack images to go through this tutorial.
We'll work through the resource hierarchy top to bottom — organization, tenant, then services — before issuing, receiving, and verifying a credential:
- Bring up the stack and authenticate as super admin
- Create an organization, an API key, and a tenant
- Issue a credential with Issuer2
- Receive the credential into a Wallet2 wallet
- Verify the credential with Verifier2
Setup
We'll use the walt.id Enterprise quickstart repository to bring up MongoDB and the Enterprise API via Docker Compose.
Clone the repo
git clone https://github.com/walt-id/waltid-enterprise-quickstart.git && cd waltid-enterprise-quickstart
Add your Docker access token
echo "DOCKER_TOKEN_PROVIDED_BY_WALT_ID_HERE" > .docker-token
Run the stack
./waltid-enterprise run
This brings up a MongoDB instance and the Enterprise API. Once it's running, visit enterprise.localhost:3000/swagger to explore the API interactively.
Learn more about the base domain (enterprise.localhost)
configuration here and about the Enterprise
Stack's configuration files in
general here.
Super Admin
Register and log in as the super admin — every call from here on carries this bearer token. Credentials come from
superadmin-registration.conf in the
quickstart repo's config folder.
Activate the Super Admin
Endpoint: POST /v1/superadmin/create-by-token | API Reference
curl -X POST http://enterprise.localhost:3000/v1/superadmin/create-by-token \
-H 'Content-Type: text/plain' \
-d '<super-admin-token-from-superadmin-registration.conf>'
Provide the token without quotation marks — use the token map key from superadmin-registration.conf as the
value.
Response Codes
200- Super admin account activated successfully.
Log In as Super Admin
Endpoint: POST /auth/account/emailpass | API Reference
curl -X POST http://enterprise.localhost:3000/auth/account/emailpass \
-H 'Content-Type: application/json' \
-d '{
"email": "superadmin@walt.id",
"password": "pw-according-to-superadmin-registration.conf"
}'
Example Response
{
"session_id": "622b05ef-9f4c-4a0c-97f1-499836a70dc7",
"status": "SUCCESS",
"token": "eyJhbGciOiJFZERTQSJ9...",
"expiration": "2026-07-29T15:38:44.121997Z"
}
Save token — pass it as Authorization: Bearer {token} on every request from here on.
Organization
Everything in the Enterprise Stack hangs off an organization — the top of the resource hierarchy (new to the resource model? see Organizations). Under it you create tenants, and under tenants, services — so data for different customers or products stays isolated while sharing one deployment.
Endpoint: POST /v1/admin/organizations | API Reference
curl -X POST http://enterprise.localhost:3000/v1/admin/organizations \
-H "Authorization: Bearer {yourToken}" \
-H 'Content-Type: application/json' \
-d '{
"_id": "waltid",
"profile": { "name": "Test GmbH" }
}'
Body Parameters
_id: String (required) - Unique identifier for the organization, e.g.waltid.profile.name: String (required) - Human-readable name for the organization.
Response Codes
201- Organization created successfully.
Creating an organization automatically generates an admin role named {orgID}.admin — here, waltid.admin — and a
dedicated subdomain, {orgID}.enterprise.localhost, for operations scoped to it. From here on, every call targets
that subdomain: http://waltid.enterprise.localhost:3000.
API Key
Rather than using the super-admin token for day-to-day operations, create an API key scoped to this organization and
grant it the waltid.admin role generated above.
Create the Key
Endpoint: POST /v1/{target}/apikeys-api/api-keys/create | API Reference
curl -X POST http://waltid.enterprise.localhost:3000/v1/waltid.myApiKey/apikeys-api/api-keys/create \
-H "Authorization: Bearer {yourToken}" \
-H 'Content-Type: application/json' \
-d '{ "name": "My API Key", "expiration": "30d" }'
Path Parameters
target: resourceIdentifier (required) - The API key's own ID,{organizationID}.[YourID], e.g.waltid.myApiKey.
Example Response
{
"_id": "waltid.myApiKey",
"name": "My API Key",
"token": "eyJhbGciOiJFZERTQSJ9..."
}
Assign the Admin Role
A fresh API key has no permissions until you assign it a role.
Endpoint: POST /v1/{target}/roles-api/roles/apikey/assign
curl -X POST http://waltid.enterprise.localhost:3000/v1/waltid.admin/roles-api/roles/apikey/assign \
-H "Authorization: Bearer {yourToken}" \
-H 'Content-Type: application/json' \
-d '{ "apikey": "waltid.myApiKey" }'
Response Codes
200- Role assigned successfully.
From here on, use this API key's token instead of the super-admin token.
Tenant
Create a tenant inside the organization — the level under which services actually live. You can nest sub-tenants arbitrarily deep to keep customers' or products' data separate; we'll use a single flat tenant here.
We call this tenant tenant2, not tenant1 — if you've also gone through the v1 tutorial
against the same organization, it already created services named kms1, issuer1, wallet1, and verifier1
under waltid.tenant1. Using a separate tenant keeps this walkthrough's resources from colliding with those.
Endpoint: POST /v1/{target}/resource-api/tenants/create | API Reference
curl -X POST http://waltid.enterprise.localhost:3000/v1/waltid.tenant2/resource-api/tenants/create \
-H "Authorization: Bearer {yourToken}" \
-H 'Content-Type: application/json' \
-d '{ "name": "My Tenant" }'
Path Parameters
target: resourceIdentifier (required) -{organizationID}.[YourID], e.g.waltid.tenant2.
Response Codes
201- Tenant created successfully.
Issue a Credential with Issuer2
For this tutorial, we'll issue an Open Badge Credential — a
W3C Verifiable Credential with a JWT signature
(jwt_vc_json). Issuer2 also supports SD-JWT VC and mdoc (ISO 18013-5); this tutorial sticks to W3C VC to keep the
walkthrough focused.
Set Up a Key and DID for the Issuer
An issuer needs a key to sign credentials with and a DID to identify itself as — the issuer field on every
credential it issues (new to DIDs? see Decentralised Identifiers). Both live
in dedicated services under the tenant: a KMS Service
for the key, a DID Service to derive a DID from it.
Create a KMS Service
curl -X POST http://waltid.enterprise.localhost:3000/v1/waltid.tenant2.kms1/resource-api/services/create \
-H "Authorization: Bearer {yourToken}" -H 'Content-Type: application/json' \
-d '{ "type": "kms" }'
Generate a Signing Key
curl -X POST http://waltid.enterprise.localhost:3000/v1/waltid.tenant2.kms1.key1/kms-service-api/keys/generate \
-H "Authorization: Bearer {yourToken}" -H 'Content-Type: application/json' \
-d '{ "backend": "jwk", "keyType": "Ed25519" }'
Create a DID Service
curl -X POST http://waltid.enterprise.localhost:3000/v1/waltid.tenant2.did1/resource-api/services/create \
-H "Authorization: Bearer {yourToken}" -H 'Content-Type: application/json' \
-d '{ "type": "did" }'
Link the KMS as a dependency so the DID service can derive a DID from key1:
curl -X POST http://waltid.enterprise.localhost:3000/v1/waltid.tenant2.did1/did-service-api/dids/dependencies/add \
-H "Authorization: Bearer {yourToken}" -H 'Content-Type: application/json' \
-d 'waltid.tenant2.kms1'
Create a did:key
curl -X POST http://waltid.enterprise.localhost:3000/v1/waltid.tenant2.did1/did-service-api/dids/create/key \
-H "Authorization: Bearer {yourToken}" -H 'Content-Type: application/json' \
-d '{ "keyId": "waltid.tenant2.kms1.key1" }'
Example Response
{
"did": "did:key:zmYg9bgKmRiCqTTd9MA1ufVE9tfzUptwQp4GMRxptXquJWw4Uj5cnYqd6qgimWWRVguLp5NMKWtw4ZBJyNbZfqrDXNbAFqhGVJz35PRgCQyABvpg4",
"document": { "...": "the resolved DID document" }
}
Save this did — it's the issuerDid for the profile we create next.
Create the Issuer2 Service
Issuer2 declares which credential types it can issue via credentialConfigurations — a map of credential
configuration IDs to their format, signing algorithm, and key-binding rules. This becomes the issuer's published
OID4VCI metadata.
Endpoint: POST /v1/{target}/resource-api/services/create | API Reference
curl -X POST http://waltid.enterprise.localhost:3000/v1/waltid.tenant2.issuer1/resource-api/services/create \
-H "Authorization: Bearer {yourToken}" -H 'Content-Type: application/json' \
-d '{
"type": "issuer2",
"baseUrl": "http://waltid.enterprise.localhost:3000",
"kms": "waltid.tenant2.kms1",
"tokenKeyId": "waltid.tenant2.kms1.key1",
"credentialConfigurations": {
"OpenBadgeCredential_jwt_vc_json": {
"format": "jwt_vc_json",
"cryptographic_binding_methods_supported": ["jwk", "did:key", "did:web", "did:jwk"],
"credential_signing_alg_values_supported": ["ES256", "EdDSA"],
"proof_types_supported": {
"jwt": { "proof_signing_alg_values_supported": ["ES256", "EdDSA"] }
},
"credential_definition": {
"type": ["VerifiableCredential", "OpenBadgeCredential"]
}
}
}
}'
Body Parameters
type: String (required) - Must beissuer2.kms: resourceIdentifier (required) - The KMS service created above.tokenKeyId: resourceIdentifier (required) - Key used to sign OID4VCI access tokens; we reusekey1.credentialConfigurations: Object (required) - Map of credential types this issuer supports, keyed by a credential configuration ID of your choosing.cryptographic_binding_methods_supportedandproof_types_supportedare co-dependent — set both or neither. See Credential Types for the full field reference and per-format rules (SD-JWT VC, mDoc).
Response Codes
201- Service created successfully.
Create a Credential Profile
Issuer2 issues from profiles —
reusable configurations that bind a credentialConfigurationId to a signing key, issuer identity, and a credential
data template. A profile is addressed as a child of the issuer service, {issuerTarget}.{profileId}.
Endpoint: POST /v2/{target}.{profileId}/issuer-service-api/credentials/profiles | API Reference
curl -X POST http://waltid.enterprise.localhost:3000/v2/waltid.tenant2.issuer1.openbadge1/issuer-service-api/credentials/profiles \
-H "Authorization: Bearer {yourToken}" -H 'Content-Type: application/json' \
-d '{
"name": "Open Badge Credential Profile",
"credentialConfigurationId": "OpenBadgeCredential_jwt_vc_json",
"issuerKeyId": "waltid.tenant2.kms1.key1",
"issuerDid": "did:key:zmYg9bgKmRiCqTTd9MA1ufVE9tfzUptwQp4GMRxptXquJWw4Uj5cnYqd6qgimWWRVguLp5NMKWtw4ZBJyNbZfqrDXNbAFqhGVJz35PRgCQyABvpg4",
"w3cVersion": "W3CV2",
"credentialData": {
"@context": [
"https://www.w3.org/ns/credentials/v2",
"https://purl.imsglobal.org/spec/ob/v3p0/context-3.0.3.json"
],
"type": ["VerifiableCredential", "OpenBadgeCredential"],
"credentialSubject": {
"type": ["AchievementSubject"],
"achievement": {
"type": ["Achievement"],
"name": "Teamwork",
"description": "This badge recognizes the development of the capacity to collaborate within a group environment."
}
}
},
"mapping": {
"id": "<uuid>",
"credentialSubject": { "id": "<subjectDid>" },
"validFrom": "<timestamp>"
}
}'
Path Parameters
target: String (required) - The issuer service path plus the new profile's own ID as the final segment, e.g.waltid.tenant2.issuer1.openbadge1.
Body Parameters
name: String (required) - A human-readable name for the profile.credentialConfigurationId: String (required) - Must match a key in the issuer'scredentialConfigurations.issuerKeyId: resourceIdentifier (required) - The signing key.credentialData: Object (required) - The credential data template.issuerDid: String (optional) - The issuer's DID, from the previous step.mapping: Object (optional) - Dynamic value insertion at issuance time — here, a randomid, the holder's DID ascredentialSubject.id, and the current timestamp asvalidFrom. See Data Functions.
Example Response
{
"profileId": "openbadge1",
"name": "Open Badge Credential Profile",
"version": 1,
"credentialConfigurationId": "OpenBadgeCredential_jwt_vc_json",
"createdAt": 1784732057289,
"updatedAt": 1784732057289
}
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.
Endpoint: POST /v2/{profileTarget}/issuer-service-api/credentials/offers | API Reference
curl -X POST http://waltid.enterprise.localhost:3000/v2/waltid.tenant2.issuer1.openbadge1/issuer-service-api/credentials/offers \
-H "Authorization: Bearer {yourToken}" -H 'Content-Type: application/json' \
-d '{ "authMethod": "PRE_AUTHORIZED" }'
Body Parameters
authMethod: String (required) -PRE_AUTHORIZEDrequires no user login and is the simplest way to get started. Issuer2 also supportsAUTHORIZED(authorization-code flow against an external IdP) — see Protocol Flows.
Example Response
{
"offerId": "a7c6196c-e8d0-4a2e-818d-af66e3d45dc1",
"profileId": "openbadge1",
"profileVersion": 1,
"authMethod": "PRE_AUTHORIZED",
"expiresAt": 1784733115870,
"credentialOffer": "openid-credential-offer://?credential_offer_uri=http%3A%2F%2Fwaltid.enterprise.localhost%3A3000%2Fv2%2Fwaltid.tenant2.issuer1%2Fissuer-service-api%2Fopenid4vci%2Fcredential-offer%3Fid%3Da7c6196c-e8d0-4a2e-818d-af66e3d45dc1"
}
credentialOffer is the OID4VCI offer URL — hand it directly to
Wallet2 in the next step.
Receive the Credential in a Wallet
Create a Wallet
A wallet is its own service, wallet-service-api, addressed by its own target
(e.g. waltid.tenant2.wallet1). Rather than wiring up a KMS, DID Service, DID Store, and Credential Store by hand,
the init-wallet wizard creates and links all of them — plus a first key and DID — in a single call.
Endpoint: POST /v1/{target}/wallet-service-api/init-wallet | API Reference
curl -X POST http://waltid.enterprise.localhost:3000/v1/waltid.tenant2.wallet1/wallet-service-api/init-wallet \
-H "Authorization: Bearer {yourToken}" -H 'Content-Type: application/json' \
-d '{
"createKms": true,
"kmsName": "wallet-kms",
"createKeyInKms": { "backend": "jwk", "keyType": "Ed25519" },
"createDidStore": true,
"didStoreName": "wallet-did-store",
"createDidService": true,
"didServiceName": "wallet-did-service",
"createDidWithDidService": "key",
"createCredentialStore": true,
"credentialStoreName": "wallet-credential-store"
}'
Body Parameters — see Setup for the full field reference, including how to link existing services instead of creating new ones.
Example Response
{
"wallet": {
"_id": "waltid.tenant2.wallet1",
"dependencies": ["waltid.tenant2.wallet-did-store", "waltid.tenant2.wallet-kms", "waltid.tenant2.wallet-credential-store"]
},
"createdResources": {
"keyId": "waltid.tenant2.wallet-kms.wallet_key",
"didId": "waltid.tenant2.wallet-did-store.wallet_did",
"did": "did:key:z6MkidYoMBEPqyVXsihMPdLGfeWyfAUSD2RvW1Yidtr3KPuS"
}
}
The created key and DID automatically become the wallet's defaults — every call below can omit keyId and did
entirely and this key/DID pair is used.
Receive the Credential
This single call looks up the offer's details, exchanges its one-time code for a short-lived access token, signs a proof that the wallet owns the key the credential will be bound to, then fetches the credential and stores it in the linked Credential Store.
Endpoint: POST /v2/{target}/wallet-service-api/credentials/receive | API Reference
curl -X POST http://waltid.enterprise.localhost:3000/v2/waltid.tenant2.wallet1/wallet-service-api/credentials/receive \
-H "Authorization: Bearer {yourToken}" -H 'Content-Type: application/json' \
-d '{
"offerUrl": "openid-credential-offer://?credential_offer_uri=http%3A%2F%2Fwaltid.enterprise.localhost%3A3000%2Fv2%2Fwaltid.tenant2.issuer1%2Fissuer-service-api%2Fopenid4vci%2Fcredential-offer%3Fid%3Da7c6196c-e8d0-4a2e-818d-af66e3d45dc1"
}'
Body Parameters
offerUrl: String - ThecredentialOffervalue from the previous step.
Example Response
{
"credentialIds": ["c58d08ea-4c1a-4c0c-9bf9-bd6df4083936"],
"deferredTransactionIds": {}
}
Confirm It Was Stored
Endpoint: GET /v2/{target}/wallet-service-api/credentials | API Reference
curl http://waltid.enterprise.localhost:3000/v2/waltid.tenant2.wallet1/wallet-service-api/credentials \
-H "Authorization: Bearer {yourToken}"
Example Response
[
{
"id": "c58d08ea-4c1a-4c0c-9bf9-bd6df4083936",
"format": "jwt_vc_json",
"issuer": "did:key:zmYg9bgKmRiCqTTd9MA1ufVE9tfzUptwQp4GMRxptXquJWw4Uj5cnYqd6qgimWWRVguLp5NMKWtw4ZBJyNbZfqrDXNbAFqhGVJz35PRgCQyABvpg4",
"subject": "did:key:z6MkidYoMBEPqyVXsihMPdLGfeWyfAUSD2RvW1Yidtr3KPuS",
"addedAt": "2026-07-22T15:07:12.456308Z"
}
]
🎉 You've received an Open Badge Credential into the wallet. To see the full parsed contents, use
GET /v2/{target}/wallet-service-api/credentials/{credentialId} — see
Credential Management.
Verify the Credential with Verifier2
Create the Verifier2 Service
Endpoint: POST /v1/{target}/resource-api/services/create | API Reference
curl -X POST http://waltid.enterprise.localhost:3000/v1/waltid.tenant2.verifier1/resource-api/services/create \
-H "Authorization: Bearer {yourToken}" -H 'Content-Type: application/json' \
-d '{
"type": "verifier2",
"baseUrl": "http://waltid.enterprise.localhost:3000",
"clientMetadata": { "client_name": "walt.id Enterprise Verifier" }
}'
Body Parameters
type: String (required) - Must beverifier2.clientMetadata: Object (optional) - Human-readable verifier name/logo shown to the wallet. Defaults to{"client_name": "Verifier"}.
Response Codes
201- Service created successfully.
Create a Verification Session
We build an OID4VP authorization request. Verifier2 uses
DCQL (Credential Query Language) to describe the credentials it
wants — the credential type and format are expressed as a dcql_query directly in the request body.
Endpoint: POST /v1/{target}/verifier2-service-api/verification-session/create | API Reference
curl -X POST http://waltid.enterprise.localhost:3000/v1/waltid.tenant2.verifier1/verifier2-service-api/verification-session/create \
-H "Authorization: Bearer {yourToken}" -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 usecross_devicehere.core_flow.dcql_query.credentials: Array (required) - one entry per credential to request.idis a free-choice label used later to key policy results;format+meta.type_valuesdescribe what to accept.core_flow.policies.vc_policies: Array (optional) - validation rules applied to the received credential. See Policies for the full list. Omittingpoliciesentirely defaults to signature-only verification — the same as what we set explicitly here.
Example Response
{
"sessionId": "2a7033e0-45f7-4837-a042-847270ded8d7",
"bootstrapAuthorizationRequestUrl": "openid4vp://authorize?request_uri=http%3A%2F%2Fwaltid.enterprise.localhost%3A3000%2Fv1%2Fwaltid.tenant2.verifier1%2Fverifier2-service-api%2F2a7033e0-45f7-4837-a042-847270ded8d7%2Frequest",
"fullAuthorizationRequestUrl": "openid4vp://authorize?response_type=vp_token&state=...&response_uri=...&dcql_query=...",
"creationTarget": "waltid.tenant2.verifier1.2a7033e0-45f7-4837-a042-847270ded8d7"
}
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).creationTarget: the full resource path including the session ID — use this as{creationTarget}when checking the result.
Present the Credential
Back on the wallet, a single call resolves the verifier's request, matches it against the wallet's stored credentials via DCQL, signs with the default key/DID, and submits the response.
Endpoint: POST /v2/{target}/wallet-service-api/credentials/present | API Reference
curl -X POST http://waltid.enterprise.localhost:3000/v2/waltid.tenant2.wallet1/wallet-service-api/credentials/present \
-H "Authorization: Bearer {yourToken}" -H 'Content-Type: application/json' \
-d '{
"requestUrl": "openid4vp://authorize?request_uri=http%3A%2F%2Fwaltid.enterprise.localhost%3A3000%2Fv1%2Fwaltid.tenant2.verifier1%2Fverifier2-service-api%2F2a7033e0-45f7-4837-a042-847270ded8d7%2Frequest"
}'
Body Parameters
requestUrl: String - thebootstrapAuthorizationRequestUrlfrom 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
Endpoint: GET /v1/{creationTarget}/verifier2-service-api/verification-session/info | API Reference
curl http://waltid.enterprise.localhost:3000/v1/waltid.tenant2.verifier1.2a7033e0-45f7-4837-a042-847270ded8d7/verifier2-service-api/verification-session/info \
-H "Authorization: Bearer {yourToken}"
Example Response (shortened)
{
"session": {
"id": "2a7033e0-45f7-4837-a042-847270ded8d7",
"status": "SUCCESSFUL",
"policy_results": {
"vc_policies": [
{
"policy": { "policy": "signature", "id": "signature" },
"success": true,
"result": { "verification_result": true }
}
],
"overallSuccess": true
}
}
}
status: "SUCCESSFUL" and policy_results.overallSuccess: true confirm the credential was received and the
signature policy passed. 🎉
You've set up the Enterprise Stack and 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 services in detail below:
