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.
1. Prerequisites
| What | Requirement |
|---|---|
| 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.
"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:
- Canton user rights (authorization leg): Canton resolves rights from
the Canton user whose
idequals the token'ssubclaim. TheactAs/readAsclaims 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. - Token claims (addressing leg): The Ledger Service copies the
token's
actAsclaim into each command submit body'sactAsfield. This tells Canton which party to act as. Without a party in the claim, the submit body has an emptyactAsand Canton rejects it.
Both legs must work. The onboard call sets up leg 1; re-minting provides leg 2.
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:
- Keygen: generate an Ed25519 keypair (or use a CIP-103 wallet).
The dev wallet at
tools/dev-wallet/index.tsprovides deterministic keys for testing. - External onboard:
POST /api/v1/external/onboardwith your public key and a party hint → sign the returnedmultiHash→POST /api/v1/external/onboard/completewith the signature. - Prepare:
POST /api/v1/commands/preparereturns an unsignedpreparedTransactionandpreparedTransactionHash. - Sign: base64-decode
preparedTransactionHash→ Ed25519 sign → base64-encode the signature. - Execute:
POST /api/v1/commands/executewith the prepared transaction + signature.
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.
| Tier | Scope | Approval 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. |
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
| Topic | Page |
|---|---|
| Understand authentication in depth | Authentication |
| Full API reference (all 22 endpoints) | Ledger Service API |
| Production examples in 3 languages | Examples |
| Strategy intents and templates | Strategy Engine |
| DAML template reference | DAML Templates |
| First API call in 5 minutes | Quickstart |