Skip to content

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.

It comes in two parts. Part 1 builds the local setup: the configuration files, the signing certificates, and the running containers. Part 2 uses that setup to issue a credential into a wallet and ask for it back. The two are independent — if you already have the services running, whether from Partisia's release repository or from an earlier pass through this guide, start at Part 2.

Part 1 needs nothing but your laptop, and neither does the first step of Part 2. The two steps after that 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.

    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, and qrencode, to render a URI as a QR code in the terminal. Neither is required — both steps below give an alternative.

Part 1. Set up the services

With access to the release repository

Partisia's release repository ships everything this part produces — docker-compose.yml, both conf trees, the database init script, and scripts for the certificates and baseUrl. Access is arranged per customer; book a call to ask about it. If you have it, this whole part is:

./scripts/generate-certs.sh
./scripts/set-baseurl.sh          # or: HOST_IP=<your-lan-ip> ./scripts/set-baseurl.sh
docker compose pull
docker compose up

Then continue at Part 2. Two details differ from the instructions below: the certificate files are named issuer-signing.pem rather than issuer-localhost.pem, and the verifier's trust anchor is already in place, so step 3 below is done for you.

Without access, build the same setup by hand in the four steps below.

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 Part 2 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:

Part 2. Issue and verify a credential

Everything below talks to the services on localhost, so it works the same whether you built the setup by hand or got it from the release repository. The API key is api-key in both cases.

1. 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.

2. 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'

3. 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 issuer trust anchor from Part 1. 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.

4. 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:

Troubleshooting

Common failures — pull authentication, port conflicts, missing certificates, database state — are collected in Troubleshooting.

What's Next