Medici Protocol

How to Onboard

The complete integrator journey: login, onboard, re-mint your token, and make your first read and submit. Every code snippet is runnable against the dev environment — copy, paste, run.

No approval step. Onboarding and regular protocol access are fully automatic. No authorization gate, no admin approval, no waiting. The only exception is compliant-regime vaults, which require a separate authorization process (Brief 30).

1. Prerequisites

WhatRequirement
Keycloak account An account in the AppUser realm on the target environment's Keycloak. Contact the Medici team if you need one created.
Profile fields firstName, lastName, and email must be set. Keycloak 26 silently drops undeclared attributes and the password grant fails with Account is not fully set up when these are missing. (BUGS #17)
Network access Outbound HTTPS to the dev environment: canton.dev.medici.loan (Ledger Service) and keycloak.dev.medici.loan (Keycloak)
Tools curl and jq for the shell examples. TypeScript/Python/Go runtimes for those languages.

Set these environment variables (substitute your credentials):

export KEYCLOAK_URL="https://keycloak.dev.medici.loan"
export LEDGER_URL="https://canton.dev.medici.loan"
export USERNAME="your-username"
export PASSWORD="your-password"

2. Login

Request a token from Keycloak using the password grant (ROPC). The client medici-app is a public client — no client secret is required.

On the dev environment, the Keycloak URL includes an /auth prefix: https://keycloak.dev.medici.loan/auth/realms/AppUser/protocol/openid-connect/token. Production environments omit this prefix.

TOKEN_RESP=$(curl -s -X POST \
  "${KEYCLOAK_URL}/auth/realms/AppUser/protocol/openid-connect/token" \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -d "grant_type=password" \
  -d "client_id=medici-app" \
  -d "username=${USERNAME}" \
  -d "password=${PASSWORD}")

TOKEN=$(echo "$TOKEN_RESP" | jq -r '.access_token')

# Decode the payload (middle segment) to inspect claims
echo "$TOKEN" | cut -d. -f2 | base64 -d 2>/dev/null | jq
const kcUrl = process.env.KEYCLOAK_URL || "https://keycloak.dev.medici.loan";
const username = process.env.USERNAME!;
const password = process.env.PASSWORD!;

const tokenRes = await fetch(
  `${kcUrl}/auth/realms/AppUser/protocol/openid-connect/token`,
  {
    method: "POST",
    headers: { "Content-Type": "application/x-www-form-urlencoded" },
    body: new URLSearchParams({
      grant_type: "password",
      client_id: "medici-app",
      username,
      password,
    }),
  }
);
const data = await tokenRes.json();
const token: string = data.access_token;

// Decode claims (middle segment)
const payload = JSON.parse(
  Buffer.from(token.split(".")[1], "base64url").toString()
);
console.log("sub:", payload.sub);
console.log("actAs:", payload.actAs);   // absent — no canton_party yet
console.log("readAs:", payload.readAs); // [medici-price-feed]
import os, requests, base64, json

kc_url = os.getenv("KEYCLOAK_URL", "https://keycloak.dev.medici.loan")

resp = requests.post(
    f"{kc_url}/auth/realms/AppUser/protocol/openid-connect/token",
    data={
        "grant_type": "password",
        "client_id": "medici-app",
        "username": os.getenv("USERNAME"),
        "password": os.getenv("PASSWORD"),
    },
)
resp.raise_for_status()
data = resp.json()
token = data["access_token"]

# Decode claims
payload_b64 = token.split(".")[1]
# Pad for base64 decoding
payload_b64 += "=" * (4 - len(payload_b64) % 4)
payload = json.loads(base64.urlsafe_b64decode(payload_b64))
print("sub:", payload["sub"])
print("actAs:", payload.get("actAs"))   # absent — no canton_party yet
print("readAs:", payload.get("readAs")) # [medici-price-feed]
package main

import (
	"encoding/base64"
	"encoding/json"
	"fmt"
	"net/http"
	"net/url"
	"os"
	"strings"
)

func main() {
	kcURL := os.Getenv("KEYCLOAK_URL")
	if kcURL == "" {
		kcURL = "https://keycloak.dev.medici.loan"
	}

	resp, err := http.PostForm(
		kcURL+"/auth/realms/AppUser/protocol/openid-connect/token",
		url.Values{
			"grant_type": {"password"},
			"client_id":  {"medici-app"},
			"username":   {os.Getenv("USERNAME")},
			"password":   {os.Getenv("PASSWORD")},
		},
	)
	if err != nil { panic(err) }
	defer resp.Body.Close()

	var data struct {
		AccessToken string `json:"access_token"`
	}
	json.NewDecoder(resp.Body).Decode(&data)

	// Decode claims (middle segment)
	parts := strings.Split(data.AccessToken, ".")
	payloadB64 := parts[1]
	payloadJSON, _ := base64.RawURLEncoding.DecodeString(payloadB64)
	var claims map[string]interface{}
	json.Unmarshal(payloadJSON, &claims)

	fmt.Println("sub:", claims["sub"])
	fmt.Println("actAs:", claims["actAs"])   // absent — no canton_party yet
	fmt.Println("readAs:", claims["readAs"]) // [medici-price-feed]
}

First-token claims: the decoded payload will look roughly like this. Note that actAs is absent — the dynamic OIDC mapper has no canton_party attribute to emit yet. readAs contains only the shared price-feed party (medici-price-feed). This token can call /api/v1/onboard but cannot read or submit against your own party.

{
  "sub": "a1b2c3d4-...-e5f6g7h8",
  "iss": "https://keycloak.dev.medici.loan/auth/realms/AppUser",
  "aud": ["account"],
  "azp": "medici-app",
  "scope": "openid profile",
  "readAs": ["medici-price-feed::1220..."],
  "exp": 1719600000,
  "iat": 1719599700
}

3. Onboard

Call POST /api/v1/onboard with your bearer token. The Ledger Service extracts your sub, allocates a Canton party, creates a Canton user (keyed to your sub), grants CanActAs(own) + CanReadAs(own) + CanReadAs(price-feed), and writes your party ID back to Keycloak.

Idempotent. Repeat calls return the same party with "onboarded": false. Safe to call on every cold start.
curl -s -X POST \
  "${LEDGER_URL}/api/v1/onboard" \
  -H "Authorization: Bearer ${TOKEN}" \
  -H "Content-Type: application/json" \
  | jq

# Response 200:
# {
#   "partyId": "party-abc::12209f...",
#   "primaryParty": "party-abc::12209f...",
#   "userId": "a1b2c3d4-...-e5f6g7h8",
#   "onboarded": true
# }
const onboardRes = await fetch(`${ledgerURL}/api/v1/onboard`, {
  method: "POST",
  headers: {
    Authorization: `Bearer ${token}`,
    "Content-Type": "application/json",
  },
});
const onboardData = await onboardRes.json();
console.log("partyId:", onboardData.partyId);
console.log("onboarded:", onboardData.onboarded); // false if already provisioned
ledger_url = os.getenv("LEDGER_URL", "https://canton.dev.medici.loan")

resp = requests.post(
    f"{ledger_url}/api/v1/onboard",
    headers={
        "Authorization": f"Bearer {token}",
        "Content-Type": "application/json",
    },
)
resp.raise_for_status()
data = resp.json()
print("partyId:", data["partyId"])
print("onboarded:", data["onboarded"])  # false if already provisioned
ledgerURL := os.Getenv("LEDGER_URL")
if ledgerURL == "" {
	ledgerURL = "https://canton.dev.medici.loan"
}

req, _ := http.NewRequest("POST", ledgerURL+"/api/v1/onboard", nil)
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")

resp, err := http.DefaultClient.Do(req)
if err != nil { panic(err) }
defer resp.Body.Close()

var onboardData struct {
	PartyId      string `json:"partyId"`
	PrimaryParty string `json:"primaryParty"`
	UserId       string `json:"userId"`
	Onboarded    bool   `json:"onboarded"`
}
json.NewDecoder(resp.Body).Decode(&onboardData)
fmt.Println("partyId:", onboardData.PartyId)
fmt.Println("onboarded:", onboardData.Onboarded) // false if already provisioned

You can also check your status without triggering a new onboard:

curl -s \
  -H "Authorization: Bearer ${TOKEN}" \
  "${LEDGER_URL}/api/v1/user-status" | jq

# Response:
# {
#   "party_id": "party-abc::12209f...",
#   "user_onboarded": true
# }

4. Re-mint your token

After onboarding, you must request a fresh token. The dynamic OIDC mapper reads your canton_party attribute at mint time. A token minted before onboarding will never acquire the actAs claim.

Why two tokens?

The Medici auth model has two independent legs:

  1. Canton user rights (authorization leg): Canton resolves rights from the Canton user whose id equals the token's sub claim. The actAs/readAs claims in the JWT are ignored by Canton for authorization. This leg is set up by the onboard call. It determines what you are allowed to do.
  2. Token claims (addressing leg): The Ledger Service copies the token's actAs claim into each command submit body's actAs field. This tells Canton which party to act as. Without a party in the claim, the submit body has an empty actAs and Canton rejects it.

Both legs must work. The onboard call sets up leg 1; re-minting provides leg 2.

Re-mint is identical to login. Use the same password grant call from step 2. The only difference: the payload now includes actAs and your party in readAs.
TOKEN_RESP=$(curl -s -X POST \
  "${KEYCLOAK_URL}/auth/realms/AppUser/protocol/openid-connect/token" \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -d "grant_type=password" \
  -d "client_id=medici-app" \
  -d "username=${USERNAME}" \
  -d "password=${PASSWORD}")

TOKEN=$(echo "$TOKEN_RESP" | jq -r '.access_token')

# Now actAs is populated
echo "$TOKEN" | cut -d. -f2 | base64 -d 2>/dev/null | jq '{sub, actAs, readAs}'
// Same login call as step 2 — Keycloak now emits actAs
const tokenRes = await fetch(
  `${kcUrl}/auth/realms/AppUser/protocol/openid-connect/token`,
  {
    method: "POST",
    headers: { "Content-Type": "application/x-www-form-urlencoded" },
    body: new URLSearchParams({
      grant_type: "password",
      client_id: "medici-app",
      username,
      password,
    }),
  }
);
const data = await tokenRes.json();
const freshToken: string = data.access_token;

const claims = JSON.parse(
  Buffer.from(freshToken.split(".")[1], "base64url").toString()
);
console.log("actAs:", claims.actAs);   // ["party-abc::12209f..."]
console.log("readAs:", claims.readAs); // ["party-abc::12209f...", "medici-price-feed::..."]
resp = requests.post(
    f"{kc_url}/auth/realms/AppUser/protocol/openid-connect/token",
    data={
        "grant_type": "password",
        "client_id": "medici-app",
        "username": os.getenv("USERNAME"),
        "password": os.getenv("PASSWORD"),
    },
)
resp.raise_for_status()
fresh_token = resp.json()["access_token"]

payload_b64 = fresh_token.split(".")[1]
payload_b64 += "=" * (4 - len(payload_b64) % 4)
claims = json.loads(base64.urlsafe_b64decode(payload_b64))
print("actAs:", claims["actAs"])   # ["party-abc::12209f..."]
print("readAs:", claims["readAs"]) # ["party-abc::12209f...", "medici-price-feed::..."]
// Same login call — Keycloak now emits actAs
resp, err := http.PostForm(
	kcURL+"/auth/realms/AppUser/protocol/openid-connect/token",
	url.Values{
		"grant_type": {"password"},
		"client_id":  {"medici-app"},
		"username":   {os.Getenv("USERNAME")},
		"password":   {os.Getenv("PASSWORD")},
	},
)
if err != nil { panic(err) }
defer resp.Body.Close()

var data struct { AccessToken string `json:"access_token"` }
json.NewDecoder(resp.Body).Decode(&data)

parts := strings.Split(data.AccessToken, ".")
claimsJSON, _ := base64.RawURLEncoding.DecodeString(parts[1])
var claims map[string]interface{}
json.Unmarshal(claimsJSON, &claims)

fmt.Println("actAs:", claims["actAs"])   // ["party-abc::12209f..."]
fmt.Println("readAs:", claims["readAs"]) // ["party-abc::12209f...", "medici-price-feed::..."]

After re-mint, the claims look like this:

{
  "sub": "a1b2c3d4-...-e5f6g7h8",
  "iss": "https://keycloak.dev.medici.loan/auth/realms/AppUser",
  "aud": ["account"],
  "azp": "medici-app",
  "scope": "openid profile",
  "actAs": ["party-abc::12209f..."],
  "readAs": ["party-abc::12209f...", "medici-price-feed::1220..."],
  "exp": 1719600300,
  "iat": 1719600000
}

5. First read + first submit

With your re-minted token, you can now read contracts and submit commands as your own party.

Read contracts

Query active contracts visible to your party. The readAs query parameter tells the Ledger Service which party's view to use (must be in your token's readAs or actAs claims).

# Read your own party's contracts (e.g. Vaults)
curl -s \
  -H "Authorization: Bearer ${TOKEN}" \
  "${LEDGER_URL}/api/v1/contracts/OptionIndex.Core/Vault?readAs=${PARTY_ID}" \
  | jq

# Read the public price feed (always works after onboard)
curl -s \
  -H "Authorization: Bearer ${TOKEN}" \
  "${LEDGER_URL}/api/v1/prices?ticker=ETH/USD" \
  | jq

# Response:
# {
#   "ticker": "ETH/USD",
#   "price": "4235.50",
#   "timestamp": 1719600000,
#   "observationId": "#01:5678",
#   "fetchedAt": "2026-07-02T12:00:00Z"
# }
// Read public price feed
const priceRes = await fetch(
  `${ledgerURL}/api/v1/prices?ticker=ETH/USD`,
  { headers: { Authorization: `Bearer ${token}` } }
);
const priceData = await priceRes.json();
console.log(`${priceData.ticker}: ${priceData.price}`);

// Read your own vaults
const vaultsRes = await fetch(
  `${ledgerURL}/api/v1/contracts/OptionIndex.Core/Vault?readAs=${partyId}`,
  { headers: { Authorization: `Bearer ${token}` } }
);
const vaultsData = await vaultsRes.json();
console.log(`Found ${vaultsData.contracts.length} vault(s)`);
# Read public price feed
resp = requests.get(
    f"{ledger_url}/api/v1/prices",
    params={"ticker": "ETH/USD"},
    headers={"Authorization": f"Bearer {token}"},
)
resp.raise_for_status()
price = resp.json()
print(f"{price['ticker']}: {price['price']}")

# Read your own vaults
resp = requests.get(
    f"{ledger_url}/api/v1/contracts/OptionIndex.Core/Vault",
    params={"readAs": party_id},
    headers={"Authorization": f"Bearer {token}"},
)
resp.raise_for_status()
vaults = resp.json()
print(f"Found {len(vaults['contracts'])} vault(s)")
// Read public price feed
req, _ := http.NewRequest("GET",
	ledgerURL+"/api/v1/prices?ticker=ETH/USD", nil)
req.Header.Set("Authorization", "Bearer "+token)
resp, _ := http.DefaultClient.Do(req)
defer resp.Body.Close()
var priceData struct {
	Ticker    string  `json:"ticker"`
	Price     *string `json:"price"`
	Timestamp *int64  `json:"timestamp"`
}
json.NewDecoder(resp.Body).Decode(&priceData)
fmt.Printf("%s: %v\n", priceData.Ticker, *priceData.Price)

Submit a command

Submit a minimal command to create a SplitRequest — the entry point for splitting collateral into P and N tokens. This requires a funded party and the correct template arguments.

curl -s -X POST \
  "${LEDGER_URL}/api/v1/commands/submit" \
  -H "Authorization: Bearer ${TOKEN}" \
  -H "Content-Type: application/json" \
  -d '{
    "templateId": "#option-index-tracker-v10:OptionIndex.Core:SplitRequest",
    "arguments": {
      "requestId": "req-001",
      "depositor": "'"${PARTY_ID}"'",
      "collateralAmount": "100.00",
      "strike": "50.0",
      "maturity": "2027-01-01T00:00:00Z",
      "ticker": "ETH/USD",
      "oracle": "",
      "operator": ""
    }
  }' | jq

# Response 200:
# {
#   "commandId": "cmd-01j...",
#   "status": "succeeded",
#   "events": [...],
#   "contractId": "#01:1234"
# }
const submitRes = await fetch(`${ledgerURL}/api/v1/commands/submit`, {
  method: "POST",
  headers: {
    Authorization: `Bearer ${token}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    templateId: "#option-index-tracker-v10:OptionIndex.Core:SplitRequest",
    arguments: {
      requestId: "req-001",
      depositor: partyId,
      collateralAmount: "100.00",
      strike: "50.0",
      maturity: "2027-01-01T00:00:00Z",
      ticker: "ETH/USD",
      oracle: oraclePartyId,
      operator: operatorPartyId,
    },
  }),
});
const submitData = await submitRes.json();
console.log("Command status:", submitData.status);
console.log("Contract ID:", submitData.contractId);
resp = requests.post(
    f"{ledger_url}/api/v1/commands/submit",
    headers={
        "Authorization": f"Bearer {token}",
        "Content-Type": "application/json",
    },
    json={
        "templateId": "#option-index-tracker-v10:OptionIndex.Core:SplitRequest",
        "arguments": {
            "requestId": "req-001",
            "depositor": party_id,
            "collateralAmount": "100.00",
            "strike": "50.0",
            "maturity": "2027-01-01T00:00:00Z",
            "ticker": "ETH/USD",
            "oracle": oracle_party_id,
            "operator": operator_party_id,
        },
    },
)
resp.raise_for_status()
result = resp.json()
print("Command status:", result["status"])
print("Contract ID:", result.get("contractId"))
submitBody, _ := json.Marshal(map[string]interface{}{
	"templateId": "#option-index-tracker-v10:OptionIndex.Core:SplitRequest",
	"arguments": map[string]interface{}{
		"requestId":        "req-001",
		"depositor":        partyId,
		"collateralAmount": "100.00",
		"strike":           "50.0",
		"maturity":         "2027-01-01T00:00:00Z",
		"ticker":           "ETH/USD",
		"oracle":           oraclePartyId,
		"operator":         operatorPartyId,
	},
})

req, _ := http.NewRequest("POST",
	ledgerURL+"/api/v1/commands/submit",
	strings.NewReader(string(submitBody)))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")

resp, err := http.DefaultClient.Do(req)
if err != nil { panic(err) }
defer resp.Body.Close()

var submitData struct {
	CommandId  string `json:"commandId"`
	Status     string `json:"status"`
	ContractId string `json:"contractId,omitempty"`
}
json.NewDecoder(resp.Body).Decode(&submitData)
fmt.Println("Status:", submitData.Status)
fmt.Println("Contract ID:", submitData.ContractId)

6. Non-custodial variant

If you prefer to hold your own signing keys — so the participant hosts your party as a confirming/observing node and cannot sign unilaterally on your behalf — use the non-custodial (Path C) flow instead:

  1. Keygen: generate an Ed25519 keypair (or use a CIP-103 wallet). The dev wallet at tools/dev-wallet/index.ts provides deterministic keys for testing.
  2. External onboard: POST /api/v1/external/onboard with your public key and a party hint → sign the returned multiHashPOST /api/v1/external/onboard/complete with the signature.
  3. Prepare: POST /api/v1/commands/prepare returns an unsigned preparedTransaction and preparedTransactionHash.
  4. Sign: base64-decode preparedTransactionHash → Ed25519 sign → base64-encode the signature.
  5. Execute: POST /api/v1/commands/execute with the prepared transaction + signature.
When to use Path C vs custodial: the custodial flow (steps 1–5 above) is simpler and suitable for automated agents, examples, and integration testing. Use Path C when regulatory or custody requirements demand that you — not the participant — hold the signing key, or when integrating a CIP-103 wallet.

Full walkthrough (6 steps + language-specific code): see the Examples page and the CIP-103 Flow example in the medici-examples repo (ts/cip103-flow, python/cip103-flow, go/cip103-flow).

7. Compliant vaults

Regular vaults need no authorization. After onboarding, you have CanActAs your own party and CanReadAs your own party plus the public price feed. That is all you need to create, deposit into, recombine, and settle regular on-chain vaults.

Compliant-regime vaults (flagged in the on-chain VaultComplianceRegistry) represent regulated instruments and require an explicit authorization workflow before you can operate on them. This workflow is defined in Brief 30 and documented in docs/compliance.md.

TierScopeApproval required
Default CanActAs own party, CanReadAs own party + price-feed. Access to all regular vaults. None — automatic on onboard
Compliant vault Operate on vaults flagged in VaultComplianceRegistry Explicit authorization workflow (Brief 30)
Elevated CanActAs additional parties, CanReadAsAnyParty Multi-sig via Governance propose/accept

8. Troubleshooting

Error Likely cause Fix
401 UNAUTHENTICATED Token iss does not match the Canton IdP config's issuer. You minted from the in-cluster Keycloak URL (http://keycloak....svc.cluster.local) instead of the public URL. Mint your token from the public Keycloak URL: https://keycloak.<env>.medici.loan. See auth docs Rule 1.
Account is not fully set up
(from Keycloak token endpoint)
Keycloak 26 requires firstName, lastName, and email to be set on the user profile. If any are missing, the password grant fails. Set the missing attributes on your Keycloak user (admin console or ask the Medici team). Verify with: curl -s "${KEYCLOAK_URL}/auth/admin/realms/AppUser/users/<id>" -H "Authorization: Bearer $ADMIN_TOKEN" | jq '{firstName, lastName, email}'
Security-sensitive error
(on contract read or submit)
Your Canton user (keyed by token sub) lacks the required CanReadAs or CanActAs rights. This happens if onboard partially failed (pre-Brief 25, issue #23) or if you are using a token minted before onboarding. Re-run POST /api/v1/onboard (idempotent — it will re-grant rights if missing). Then re-mint your token. If the error persists, verify your Canton rights via the re-grant response (the idempotent re-grant returns "newlyGrantedRights": [] when rights are already present).
Party X not in token actAs
(from Ledger Service submit)
You forgot to re-mint your token after onboarding. The old token's actAs claim is absent. The Ledger Service copies the actAs claim into the submit body — with no party, the body is empty and Canton rejects it. Re-mint your token (step 4). Verify with: echo "$TOKEN" | cut -d. -f2 | base64 -d 2>/dev/null | jq '.actAs'. If null, the token was minted before onboarding.
Token expiry: RS256 tokens expire after 5 minutes. If a token that was working suddenly stops, check its exp claim and mint a fresh one. The Ledger Service caches and auto-refreshes tokens internally; direct Canton callers must handle this themselves.

Next Steps

TopicPage
Understand authentication in depthAuthentication
Full API reference (all 22 endpoints)Ledger Service API
Production examples in 3 languagesExamples
Strategy intents and templatesStrategy Engine
DAML template referenceDAML Templates
First API call in 5 minutesQuickstart