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. Running the stack also requires a license — see Licensing.
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
Log in to Docker
Use the personal access token you received from walt.id as the password.
docker login -u waltid
Set a dev-mode access token
The quickstart enables dev-mode, and the Enterprise API refuses to start while config/dev-mode-access.conf still
contains the placeholder token. Set your own value:
accessToken = "{aValueOnlyYouKnow}"
Run the stack
docker compose pull
docker compose up
This brings up a MongoDB instance, the Enterprise API, and Caddy as a reverse proxy on port 80. Once it's running, visit enterprise.localhost/swagger to explore the API interactively.
If requests fail with 502 Bad Gateway, the waltid-enterprise container has exited, for example because of the
dev-mode token or the license. Check docker compose ps -a and docker compose logs waltid-enterprise, fix the cause,
and run docker compose up -d.
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/v1/superadmin/create-by-token \
-H 'Content-Type: application/json' \
-d '{ "token": "<super-admin-token-from-superadmin-registration.conf>" }'
Provide the token as the token field of a JSON body — 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/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/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. The API uses the hostname of each request to find the organization
(see Organization Hostnames).
The quickstart is set up for the waltid organization. If you use a different organization ID, add its subdomain as a
network alias to the caddy service in docker-compose.yml and run docker compose up -d. Otherwise the API cannot
reach its own public URLs, and issuing a credential fails with Failed to fetch credential offer. See
Using custom organisation names.
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/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/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/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/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/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/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/v1/waltid.tenant2.did1/did-service-api/dids/dependencies/add \
-H "Authorization: Bearer {yourToken}" -H 'Content-Type: application/json' \
-d '{
"dependency": "waltid.tenant2.kms1"
}'
Create a did:key
curl -X POST http://waltid.enterprise.localhost/v1/waltid.tenant2.did1/did-service-api/dids/create/key \
-H "Authorization: Bearer {yourToken}" -H 'Content-Type: application/json' \
-d '{ "keyReference": "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/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",
"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/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/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%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/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/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%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/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/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",
"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/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%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/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%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/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:
