Medici Protocol

Intent API Reference

Complete reference for the 9 Strategy Intent REST endpoints exposed by the Ledger Service. All endpoints live under /api/v1/.

Base URL: https://canton.int.medici.loan (int environment). All requests require a bearer token obtained from Keycloak unless marked "no auth". The token's sub claim is used as the intent owner for authorization checks.

POST /api/v1/intents

Create a new strategy intent. Returns a fully-populated StrategyIntent with auto-generated id, status: "active", and owner set from the JWT sub claim.

Request body

FieldTypeRequiredConstraints
typestringyestreasury | hedge | leverage | yield | custom
tickerstringyes^[A-Z]{2,5}/[A-Z]{2,5}$ (e.g. "BTC/USDC")
collateral.assetstringyesUSDC | WETH | WBTC
collateral.amountstringyesDecimal string, e.g. "50000.00"
config.strikeRationumberyes0.01 – 1.0
config.maturityDaysintegeryes1 – 365
config.rebalanceThresholdnumberyes>= 1.0
config.autoRollbooleannoDefaults to template value if omitted
config.rollStrategystringyesstandard | shortDated | manual
constraints.maxAnnualDriftBpsintegerno0 – 10,000
constraints.maxSlippagePerRollBpsintegerno0 – 100
constraints.maxCapitalstringyesDecimal string
constraints.stopLossDriftBpsintegerno0 – 10,000
notifications.onRollbooleanno
notifications.onThresholdBreachbooleanno
notifications.onStopLossbooleanno
notifications.channelsstring[]yesAt least 1 from: email, push, inapp, webhook
Owner is ignored from the request body: The owner field, if present in the JSON payload, is silently discarded. The handler always sets it from the JWT sub claim. This prevents impersonation.

Response 201

{
  "intent": {
    "id": "intent-1719763200000-a1b2c3d4",
    "owner": "alice",
    "type": "treasury",
    "status": "active",
    "ticker": "BTC/USDC",
    "collateral": { "asset": "USDC", "amount": "50000.00" },
    "config": {
      "strikeRatio": 0.50,
      "maturityDays": 30,
      "rebalanceThreshold": 1.5,
      "autoRoll": true,
      "rollStrategy": "standard"
    },
    "constraints": {
      "maxAnnualDriftBps": 200,
      "maxSlippagePerRollBps": 10,
      "maxCapital": "50000.00",
      "stopLossDriftBps": 500
    },
    "notifications": {
      "onRoll": true,
      "onThresholdBreach": true,
      "onStopLoss": true,
      "channels": ["inapp", "webhook"]
    },
    "runtime": null,
    "createdAt": "2026-06-30T12:00:00Z",
    "updatedAt": "2026-06-30T12:00:00Z"
  },
  "fetchedAt": "2026-06-30T12:00:00Z"
}

Errors

StatuserrorWhen
400invalid_requestBody is not valid JSON
400validation_failedSchema validation or sanitization fails. message lists the specific failures.
401unauthorizedMissing or invalid bearer token
500creation_failedStore-level failure

cURL

curl -s -X POST "$BASE/api/v1/intents" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "type": "treasury",
    "ticker": "BTC/USDC",
    "collateral": {"asset": "USDC", "amount": "50000.00"},
    "config": {
      "strikeRatio": 0.50,
      "maturityDays": 30,
      "rebalanceThreshold": 1.5,
      "autoRoll": true,
      "rollStrategy": "standard"
    },
    "constraints": {
      "maxAnnualDriftBps": 200,
      "maxSlippagePerRollBps": 10,
      "maxCapital": "50000.00",
      "stopLossDriftBps": 500
    },
    "notifications": {
      "onRoll": true,
      "onThresholdBreach": true,
      "onStopLoss": true,
      "channels": ["inapp", "webhook"]
    }
  }' | jq .

GET /api/v1/intents

List all strategy intents owned by the authenticated user. Optional ?status= query parameter filters by lifecycle state.

Query parameters

ParameterTypeRequiredValues
statusstringnoactive, paused, completed, cancelled, error

Response 200

{
  "intents": [
    {
      "id": "intent-1719763200000-a1b2c3d4",
      "owner": "alice",
      "type": "treasury",
      "status": "active",
      "ticker": "BTC/USDC",
      "collateral": { "asset": "USDC", "amount": "50000.00" },
      "config": { ... },
      "constraints": { ... },
      "notifications": { ... },
      "runtime": {
        "vaultIds": ["vault-abc123"],
        "currentDriftBps": 150,
        "totalRolls": 3,
        "totalCostBps": 24,
        "lastRollAt": "2026-06-28T14:00:00Z",
        "lastRollCostBps": 8
      },
      "createdAt": "2026-06-30T12:00:00Z",
      "updatedAt": "2026-06-30T12:00:00Z"
    }
  ],
  "count": 1,
  "fetchedAt": "2026-06-30T12:00:00Z"
}

Errors

StatuserrorWhen
401unauthorizedMissing or invalid bearer token
500list_failedStore-level failure

cURL

# All intents
curl -s "$BASE/api/v1/intents" \
  -H "Authorization: Bearer $TOKEN" | jq .

# Active only
curl -s "$BASE/api/v1/intents?status=active" \
  -H "Authorization: Bearer $TOKEN" | jq .

# Cancelled
curl -s "$BASE/api/v1/intents?status=cancelled" \
  -H "Authorization: Bearer $TOKEN" | jq .

GET /api/v1/intents/:id

Get a single strategy intent by its ID. Returns the full intent including runtime state if populated.

Path parameters

ParameterTypeDescription
idstringIntent ID, format: intent-{unixms}-{8hex}

Response 200

{
  "intent": { /* full StrategyIntent — same shape as create response */ },
  "fetchedAt": "2026-06-30T12:00:00Z"
}

Errors

StatuserrorWhen
400missing_idEmpty :id parameter
403forbiddenCaller is not the intent owner
404not_foundNo intent with this ID exists

cURL

curl -s "$BASE/api/v1/intents/intent-1719763200000-a1b2c3d4" \
  -H "Authorization: Bearer $TOKEN" | jq .

PUT /api/v1/intents/:id

Update mutable fields on an intent. Only config, constraints, and notifications are recognized — all other top-level keys are silently ignored. Each field within a recognized key is only updated if present in the request.

Terminal intents are immutable: Intents with status completed or cancelled cannot be updated — the handler returns HTTP 500 with message "intent {id} is completed/cancelled and cannot be modified".

Request body

Any subset of these keys. Fields not present are left unchanged. Unknown top-level keys are silently ignored.

KeyTypeRecognized sub-fields
configobject (partial)strikeRatio, maturityDays, rebalanceThreshold, autoRoll, rollStrategy
constraintsobject (partial)maxAnnualDriftBps, maxSlippagePerRollBps, maxCapital, stopLossDriftBps
notificationsobject (partial)onRoll, onThresholdBreach, onStopLoss, channels

Response 200

Same shape as GET — full StrategyIntent after the update is applied.

Errors

StatuserrorWhen
400invalid_requestBody is not valid JSON
400missing_idEmpty :id parameter
401unauthorizedMissing or invalid bearer token
403update_failedCaller is not the intent owner
404not_foundNo intent with this ID exists
500update_failedStore-level failure, or intent is in a terminal state

cURL

curl -s -X PUT "$BASE/api/v1/intents/intent-1719763200000-a1b2c3d4" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "config": {
      "strikeRatio": 0.55,
      "maturityDays": 45
    },
    "constraints": {
      "maxAnnualDriftBps": 300
    }
  }' | jq .

POST /api/v1/intents/:id/pause

Pause an active intent. Execution is suspended — vaults remain in place but no rolls or rebalances occur. Only intents in active status can be paused.

Response 200

{
  "intent": {
    "id": "intent-1719763200000-a1b2c3d4",
    "status": "paused",
    ...
  },
  "fetchedAt": "2026-06-30T12:00:00Z"
}

Errors

StatuserrorWhen
403pause_failedCaller is not the intent owner
404pause_failedIntent not found
409pause_failedIntent status is not active (e.g. already paused, completed, cancelled)
500pause_failedStore-level failure

cURL

curl -s -X POST "$BASE/api/v1/intents/intent-1719763200000-a1b2c3d4/pause" \
  -H "Authorization: Bearer $TOKEN" | jq .

POST /api/v1/intents/:id/resume

Resume a paused intent. Execution restarts from where it left off. Only intents in paused status can be resumed.

Response 200

{
  "intent": {
    "id": "intent-1719763200000-a1b2c3d4",
    "status": "active",
    ...
  },
  "fetchedAt": "2026-06-30T12:00:00Z"
}

Errors

StatuserrorWhen
403resume_failedCaller is not the intent owner
404resume_failedIntent not found
409resume_failedIntent status is not paused
500resume_failedStore-level failure

cURL

curl -s -X POST "$BASE/api/v1/intents/intent-1719763200000-a1b2c3d4/resume" \
  -H "Authorization: Bearer $TOKEN" | jq .

DELETE /api/v1/intents/:id

Cancel an intent (soft-delete). Sets the status to cancelled with statusReason: "User cancelled". Only intents in active or paused status can be cancelled. This is a terminal operation — cancelled intents cannot be resumed or modified.

Response 200

{
  "status": "cancelled",
  "message": "intent intent-1719763200000-a1b2c3d4 cancelled"
}

Errors

StatuserrorWhen
403cancel_failedCaller is not the intent owner, or intent is in a non-cancellable state (completed, error)
404cancel_failedIntent not found
500cancel_failedStore-level failure

cURL

curl -s -X DELETE "$BASE/api/v1/intents/intent-1719763200000-a1b2c3d4" \
  -H "Authorization: Bearer $TOKEN" | jq .

GET /api/v1/strategies

List all available strategy templates. No authentication required. Returns hardcoded template metadata — for full default parameter values, see the Strategy Engine templates table.

Response 200

{
  "strategies": [
    {
      "type": "treasury",
      "label": "Treasury Management",
      "description": "Conservative index-tracking with minimal drift. Low-cost, capital-preservation focus. Designed for DAO treasuries and institutional allocators.",
      "icon": "shield"
    },
    {
      "type": "hedge",
      "label": "Portfolio Hedge",
      "description": "Protect against downside risk. Lower strike = cheaper protection. Short-dated rolls for active portfolio managers.",
      "icon": "umbrella"
    },
    {
      "type": "leverage",
      "label": "Leveraged Exposure",
      "description": "Amplified index exposure with higher drift tolerance. High strike for maximum delta per unit capital. For sophisticated traders.",
      "icon": "trending-up"
    },
    {
      "type": "yield",
      "label": "Yield Generation",
      "description": "Passive income via options premium. Longest maturity for lowest roll frequency. Designed for depositors seeking steady returns.",
      "icon": "piggy-bank"
    },
    {
      "type": "custom",
      "label": "Custom Strategy",
      "description": "Full manual control. No defaults enforced — all parameters must be explicitly configured. For advanced users with specific requirements.",
      "icon": "settings"
    }
  ],
  "fetchedAt": "2026-06-30T12:00:00Z"
}

cURL

# No auth required
curl -s "$BASE/api/v1/strategies" | jq .

GET /api/v1/portfolio

Aggregated portfolio view for the authenticated user. Returns all intents with a summary section showing counts and averages. Auth is optional — without a token, returns an empty portfolio with owner: "".

Response 200

{
  "owner": "alice",
  "intents": [
    {
      "id": "intent-1719763200000-a1b2c3d4",
      "type": "treasury",
      "ticker": "BTC/USDC",
      "status": "active",
      "collateralAsset": "USDC",
      "collateralAmount": "50000.00",
      "strikeRatio": 0.50,
      "currentDriftBps": 150,
      "totalRolls": 3
    },
    {
      "id": "intent-1719763300000-b2c3d4e5",
      "type": "yield",
      "ticker": "BTC/USDC",
      "status": "paused",
      "collateralAsset": "USDC",
      "collateralAmount": "25000.00",
      "strikeRatio": 0.50
    }
  ],
  "summary": {
    "totalIntents": 3,
    "activeIntents": 2,
    "pausedIntents": 1,
    "totalCapital": "0",
    "avgStrikeRatio": 0.55
  },
  "fetchedAt": "2026-06-30T12:00:00Z"
}
Notes: currentDriftBps and totalRolls are omitempty — they only appear when the intent has populated runtime state. totalCapital is hardcoded to "0" in the current implementation (requires an on-chain query to compute accurately — planned for a follow-up release).

Errors

StatuserrorWhen
401unauthorizedMissing bearer token (if auth is required)
500portfolio_failedStore-level error listing intents

cURL

# Authenticated portfolio
curl -s "$BASE/api/v1/portfolio" \
  -H "Authorization: Bearer $TOKEN" | jq .

# Unauthenticated (returns empty portfolio)
curl -s "$BASE/api/v1/portfolio" | jq .

Common patterns

Authentication

All mutating endpoints (POST, PUT, DELETE) and most read endpoints require a bearer token. The token's sub claim maps to the Canton user ID, which is used as the intent owner. Obtain tokens from Keycloak:

TOKEN="$(curl -s -X POST https://keycloak.int.medici.loan/realms/medici/protocol/openid-connect/token \
  -d grant_type=password \
  -d client_id=app-user-validator \
  -d username="$USERNAME" \
  -d password="$PASSWORD" \
  -d client_secret="$CLIENT_SECRET" | jq -r '.access_token')"

Error response shape

All errors follow the same JSON structure:

{
  "error": "error_code_string",
  "message": "human-readable explanation"
}

Status transition diagram

  ┌──────────┐     pause      ┌──────────┐
  │  ACTIVE  │ ──────────────> │  PAUSED  │
  │          │ <────────────── │          │
  └────┬──┬──┘    resume      └────┬─────┘
       │  │                        │
  cancel  │ complete          cancel
       │  │                        │
       v  v                        v
  ┌──────────┐              ┌──────────┐
  │CANCELLED │              │COMPLETED │   ── terminal (immutable)
  └──────────┘              └──────────┘

  ┌──────────┐
  │  ERROR   │  ── terminal (immutable, agent-triggered)
  └──────────┘

Id generation

Intent IDs use the format intent-{unixMilliseconds}-{8randomHexChars}, e.g. intent-1719763200000-a1b2c3d4. These are generated server-side on creation and are guaranteed unique by the store.

← Strategy Engine Guide