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.

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.

    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.

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:

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:

Troubleshooting

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

What's Next