Quickstart
This guide gets the issuer and verifier running on your own machine, issues a credential into a wallet, and verifies it. That is two backend containers and a PostgreSQL database, with nothing hosted elsewhere.
Steps 1 to 5 need nothing but your laptop. Steps 6 and 7 need a phone with a wallet installed: both flows end in the holder's own wallet, so a phone is part of the setup. Allow around fifteen minutes.
Requirements
-
A phone with the Partisia ID Wallet installed. This is not optional: the wallet is the holder's side of both flows.
- Android: Google Play
- iOS: App Store
The phone must be able to reach your machine — see step 1.
-
Docker with the Compose plugin — check with
docker compose version. - OpenSSL — used once, to generate a signing certificate.
- Access to Partisia's container registry. Book a call to arrange it,
then
docker login registry.gitlab.com. - Optional:
jq, to lift values out of the JSON responses, andqrencode, to render a URI as a QR code in the terminal. Neither is required — both steps below give an alternative.
1. Decide the address the wallet will use
Do this first: it goes into the configuration in the next step, and changing it later means editing files and restarting containers.
The backends tell the wallet where to reach them, using a baseUrl you configure. Your phone will not resolve
localhost, so pick an address it can actually reach — usually your machine's address on the network you share:
export HOST=192.168.1.42 # your machine's LAN address, or a tunnel hostname
A tunnel (ngrok, cloudflared, or similar) works too, and is easier if the phone and laptop are on different
networks. Use $HOST wherever it appears below. The curl commands stay on localhost, since you run those on
the laptop and they reach the containers directly.
2. Lay out the files
Create a working directory with this structure. The two server.json files and the SQL are given below; the
three empty directories are filled in by step 3.
quickstart/
├── docker-compose.yml
├── postgres-init/
│ └── 01-create-databases.sql
├── issuer/conf/
│ ├── server.json
│ └── signing-key/
└── verifier/conf/
├── server.json
├── signing-key/
└── issuer-trust-anchors/
docker-compose.yml:
services:
postgres:
image: postgres:17
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
ports:
- '5433:5432'
volumes:
- postgres-data:/var/lib/postgresql/data
- ./postgres-init:/docker-entrypoint-initdb.d:ro
healthcheck:
test: ['CMD-SHELL', 'pg_isready -U postgres -d postgres']
interval: 5s
timeout: 5s
retries: 10
verifier-backend:
image: registry.gitlab.com/secata/platform/did/release/verifier-backend:latest
depends_on:
postgres:
condition: service_healthy
working_dir: /app
ports:
- '8081:8081'
volumes:
- ./verifier/conf:/app/conf:ro
issuer-backend:
image: registry.gitlab.com/secata/platform/did/release/issuer-backend:latest
depends_on:
postgres:
condition: service_healthy
working_dir: /app
ports:
- '8079:8079'
volumes:
- ./issuer/conf:/app/conf:ro
volumes:
postgres-data:
The latest tags give you the current release of each backend. The issuer and verifier are released
independently, so pull both at the same time to get a matched pair.
postgres-init/01-create-databases.sql — these scripts run only when the data volume is created:
CREATE DATABASE verifier ENCODING 'UTF8';
CREATE DATABASE issuer ENCODING 'UTF8';
issuer/conf/server.json:
{
"port": 8079,
"baseUrl": "http://HOST:8079",
"database": {
"persistenceUnitName": "model",
"driver": "org.postgresql.Driver",
"url": "jdbc:postgresql://postgres:5432/issuer",
"user": "postgres",
"password": "postgres"
},
"issuer": {
"id": "localhost",
"display": {
"name": "Credential Issuer"
}
},
"signing": {
"keyFile": "conf/signing-key/issuer-localhost.key",
"certificateFiles": ["conf/signing-key/issuer-localhost.pem"]
},
"integrations": {
"authentication": {
"api-key": "Local development"
}
}
}
verifier/conf/server.json:
{
"port": 8081,
"baseUrl": "http://HOST:8081",
"database": {
"persistenceUnitName": "model",
"driver": "org.postgresql.Driver",
"url": "jdbc:postgresql://postgres:5432/verifier",
"user": "postgres",
"password": "postgres"
},
"clientMetadata": {
"clientName": "Verifier"
},
"integrations": {
"authentication": {
"api-key": "Local development"
}
},
"signing": {
"keyFile": "conf/signing-key/verifier-localhost.key",
"certificateFiles": ["conf/signing-key/verifier-localhost.pem"]
},
"issuerTrustAnchors": "conf/issuer-trust-anchors"
}
Replace HOST in both files with the address from step 1. It is the one value here that must be right for the
wallet steps to work.
Paths inside server.json are relative to the container's working directory, /app, which is where conf/ is
mounted. integrations.authentication maps an API key to a label — the JSON key is the token, the value is a
human-readable note. See API keys.
3. Generate the signing certificates
Each backend signs what it sends with its own key and certificate. For local use, self-signed pairs are fine.
for b in issuer verifier; do
mkdir -p "$b/conf/signing-key"
openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:P-256 -nodes \
-keyout "$b/conf/signing-key/$b-localhost.key" \
-out "$b/conf/signing-key/$b-localhost.pem" \
-days 365 -subj "/CN=$HOST" -sha256
done
signing is a required setting, so the backends will not start without these files.
Now tell the verifier which issuer to trust, by copying the issuer's certificate into its trust-anchors directory:
mkdir -p verifier/conf/issuer-trust-anchors
cp issuer/conf/signing-key/issuer-localhost.pem verifier/conf/issuer-trust-anchors/
Copy the certificate (.pem), never the key.
This is what makes the verifier check who issued a credential, rather than only that the credential is intact — so the verification in step 7 means "this came from my issuer" instead of "this is well-formed". A verifier started with no anchors logs a warning saying so.
In a real deployment the anchor is a proper root rather than a leaf certificate copied across; see Trust model and Signing certificates.
4. Start everything
docker compose pull
docker compose up
When the log shows Starting server for both backends, they are ready. Each serves its own API specification:
- Issuer: http://localhost:8079/openapi
- Verifier: http://localhost:8081/openapi
5. Create a credential configuration
A credential configuration declares what the issuer is able to issue: the format, the claims, and how a wallet should display it. Every issuance names one.
The response body is the new configuration id as plain text, so capture it — the next step needs it:
export CONFIG_ID=$(curl -s -X POST http://localhost:8079/credential-configuration/create \
-H 'Authorization: Bearer api-key' \
-H 'Content-Type: application/json' \
-d '{
"format": "jwt_vc_json-ld",
"credential_definition": {
"@context": ["https://www.w3.org/2018/credentials/v1"],
"type": ["VerifiableCredential", "IdCard"]
},
"credential_metadata": {
"display": [{ "name": "ID Card", "locale": "en-US" }],
"claims": [
{ "path": ["credentialSubject", "first_name"], "display": [{ "name": "First Name", "locale": "en-US" }] },
{ "path": ["credentialSubject", "last_name"], "display": [{ "name": "Last Name", "locale": "en-US" }] },
{ "path": ["credentialSubject", "id_number"], "display": [{ "name": "ID Number", "locale": "en-US" }] }
]
}
}')
echo "$CONFIG_ID"
You can read a configuration back later with GET /credential-configuration/{id}, and remove one with
POST /credential-configuration/remove/{id}.
For the full set of fields, including display metadata and revocation, see Credential configurations.
6. Issue a credential
Start an issuance session, supplying the data that goes into the credential:
ISSUANCE=$(curl -s -X POST http://localhost:8079/issuance/oid4vci/new-session \
-H 'Authorization: Bearer api-key' \
-H 'Content-Type: application/json' \
-d '{
"credential_dataset": {
"credentialSubject": {
"first_name": "Jens",
"last_name": "Jensen",
"id_number": 12345678
}
},
"credential_configurations": ["'"$CONFIG_ID"'"]
}')
echo "$ISSUANCE"
The response contains a session_id and a credential_offer_uri:
{
"session_id": "4d794d52...",
"credential_offer_uri": "haip-vci://?credential_offer_uri=http%3A%2F%2F192.168.1.42%3A8079%2Fissuance%2Foid4vci%2Fcredential-offer%2F4d794d52..."
}
credential_offer_uri is a deep link, not a web address — the haip-vci:// scheme is what makes a
wallet open it. The URL nested inside it, under a query parameter of the same name, is where the wallet then
fetches the offer from, which is why your baseUrl has to be reachable from the phone.
Copy both values out of that response:
export ISSUANCE_SESSION='4d794d52...' # session_id
export OFFER='haip-vci://?credential_offer_uri=http%3A...' # credential_offer_uri
Keep the single quotes around the URI: it contains &, which the shell would otherwise read as an
instruction to run the command in the background.
With jq, instead of copying
export ISSUANCE_SESSION=$(echo "$ISSUANCE" | jq -r .session_id)
export OFFER=$(echo "$ISSUANCE" | jq -r .credential_offer_uri)
Now get $OFFER in front of the wallet. Either route ends with the wallet opening the link:
- Tap it. Send the URI to yourself — a message, a note that syncs, an email — and tap it on the
phone. The
haip-vci://scheme hands it to the wallet. -
Scan it, if you have
qrencode:printf '%s' "$OFFER" | qrencode -t ANSI
The wallet fetches the offer, collects the credential, and stores it. Watch the session move from STARTED
to SUCCEEDED:
curl -s "http://localhost:8079/issuance/oid4vci/status/$ISSUANCE_SESSION" \
-H 'Authorization: Bearer api-key'
7. Ask for it back
Now switch to the verifier. First declare what you want to see, as a verification query:
export QUERY_ID=$(curl -s -X POST http://localhost:8081/verification-query/create \
-H 'Authorization: Bearer api-key' \
-H 'Content-Type: application/json' \
-d '{
"dcql_query": {
"credentials": [{
"id": "id-card",
"format": "jwt_vc_json-ld",
"claims": [
{ "path": ["credentialSubject", "first_name"] },
{ "path": ["credentialSubject", "last_name"] }
]
}]
}
}')
echo "$QUERY_ID"
The response body is the verification query's id as plain text. A verification query is reusable: create it once, then start as many verification sessions from it as you like.
Start a session:
PRESENTATION=$(curl -s -X POST http://localhost:8081/presentation/oid4vp/new-session \
-H 'Authorization: Bearer api-key' \
-H 'Content-Type: application/json' \
-d '{ "verification_query_id": "'"$QUERY_ID"'" }')
echo "$PRESENTATION"
{
"session_id": "14eb99d3...",
"request_uri": "haip-vp://?request_uri=http%3A%2F%2F192.168.1.42%3A8081%2Fpresentation%2Foid4vp%2Fauthorization-request%2F14eb99d3...&client_id=x509_hash%3AVA7rWJULU4O_gDap9PCzgpjMyo8-R3dz7zaU-MRkaBs"
}
As on the issuer side, request_uri is a deep link rather than a web address. The haip-vp:// scheme opens
the wallet, the nested URL is where it fetches the signed request from, and client_id identifies this
verifier by a hash of its signing certificate — see
Trust model.
Copy the two values out again:
export PRESENTATION_SESSION='14eb99d3...' # session_id
export REQUEST='haip-vp://?request_uri=http%3A...' # request_uri
With jq, instead of copying
export PRESENTATION_SESSION=$(echo "$PRESENTATION" | jq -r .session_id)
export REQUEST=$(echo "$PRESENTATION" | jq -r .request_uri)
Get $REQUEST to the wallet the same way as the offer — tap a link you send yourself, or scan it:
printf '%s' "$REQUEST" | qrencode -t ANSI
The wallet shows the holder which claims are being requested. On approval it sends back a presentation containing
only those claims, and the verifier checks them against the trust anchor you configured in step 3. Watch for
VERIFICATION_SUCCEEDED:
curl -s "http://localhost:8081/presentation/oid4vp/status/$PRESENTATION_SESSION" \
-H 'Authorization: Bearer api-key'
That is the full round trip: a credential you issued, held in a wallet you do not control, presented back with only the claims you asked for and validated against your own issuer certificate.
Self-signed certificates and wallet trust
Whether a wallet checks who the verifier is, is up to the wallet. Some validate the certificate in the signed request against their own trusted roots and refuse a verifier they cannot place; others accept any well-formed request. The self-signed pair from step 3 is therefore fine against a wallet you control, or one that does not check. Facing wallets you do not operate, assume they do check, and use a certificate they already accept. See Trust model.
8. Get the results into your own application
Polling is fine for a first look, but in a real integration you configure a callback and the services push results
to you as they complete. That is sessionCallback in both server.json files:
- Issuer: Receive the issued credential
- Verifier: Receive verified data
Troubleshooting
Common failures — pull authentication, port conflicts, missing certificates, database state — are collected in Troubleshooting.
What's Next
- Issue Credentials: The full issuance flow and what your application is responsible for.
- Verify Credentials: The same for verification.
- Going to production: What has to change before this faces real users.