Complete reference for the Stellar Portfolio Rebalancer HTTP API. All endpoints return JSON and are relative to the base URL.
| Environment | Base URL |
|---|---|
| Development | http://localhost:3001 |
| Production | Your deployed backend URL |
All paths below are relative to the base URL.
| Namespace | Purpose |
|---|---|
/api/v1/* |
Current stable version. New clients should use this namespace. Responses do not include deprecation headers. |
/api/* |
Legacy compatibility. May include Deprecation, Sunset, and Link headers per RFC 8594. Migrate to /api/v1/*. |
/api/auth/* |
Authentication endpoints (not versioned). See Authentication. |
- Frontend uses
/api/v1by default viaVITE_API_VERSIONinfrontend/src/config/api.ts. - Set
VITE_USE_LEGACY_API=trueonly for emergency rollback.
- Stable:
/api/v1/*is the current stable API. - Deprecation: Legacy
/api/*routes may be deprecated. When deprecated, responses includeSunsetheader with the retirement date. - Migration: New features are added to
/api/v1/*first. Backward-compatible fixes may appear in both namespaces until legacy is retired.
JWT authentication is optional and disabled by default. Enable it by setting JWT_SECRET in the backend environment.
-
Request challenge:
POST /api/auth/challenge { "address": "GALPHABET..." } -
Sign challenge with your Stellar wallet private key (Ed25519).
-
Login:
POST /api/auth/login { "address": "GALPHABET...", "signature": "base64-encoded-signature" } -
Use access token in subsequent requests:
Authorization: Bearer <access_token>
-
Refresh token when access token expires:
POST /api/auth/refresh { "refreshToken": "<refresh_token>" } -
Logout:
POST /api/auth/logout Authorization: Bearer <access_token> { "refreshToken": "<refresh_token>" }
Most endpoints are public. JWT is required for:
/api/auth/*endpoints- Managing other users' data
- Admin endpoints (
/api/admin/*,/api/debug/*) - Some portfolio operations when auth is enabled and
ALLOW_PUBLIC_USER_PORTFOLIOS_IN_DEMOis false
When DEMO_MODE=true, the API operates in a read-only/simulated mode suitable for testing without real Stellar transactions.
All errors follow this structure:
{
"success": false,
"data": null,
"error": {
"code": "ERROR_CODE",
"message": "Human-readable description",
"details": {}
},
"timestamp": "2025-01-01T00:00:00.000Z"
}| Code | HTTP Status | Description | Remediation |
|---|---|---|---|
VALIDATION_ERROR |
400 | Invalid request body or parameters | Check request schema |
UNAUTHORIZED |
401 | Missing or invalid JWT | Obtain valid token |
FORBIDDEN |
403 | Insufficient permissions | Verify user role/consent |
NOT_FOUND |
404 | Resource doesn't exist | Verify ID/path params |
CONFLICT |
409 | Idempotency conflict or state conflict | Retry with same key or resolve state |
RATE_LIMITED |
429 | Rate limit exceeded | Implement exponential backoff |
SERVICE_UNAVAILABLE |
503 | Downstream service unavailable | Retry later |
INTERNAL_ERROR |
500 | Unexpected error | Contact support |
| Tier | Limit | Window |
|---|---|---|
| Public reads | 100 req | 1 minute |
| Authenticated | 200 req | 1 minute |
| Admin writes | 50 req | 1 minute |
Rate limited responses include Retry-After header (seconds).
Write endpoints support Idempotency-Key header (1–255 chars, e.g., UUID). The server caches the first successful response for 24 hours.
- Same key + same body: Returns cached response with
Idempotency-Replayed: true - Same key + different body: Returns
409 CONFLICT - Empty/invalid key: Returns
400 VALIDATION_ERROR
Supported endpoints: POST /api/portfolio, POST /api/portfolio/:id/rebalance, POST /api/rebalance/history, POST /api/notifications/subscribe, POST /api/consent, POST /api/admin/assets, PATCH /api/admin/assets/:symbol
Success responses:
{
"success": true,
"data": { /* response payload */ },
"error": null,
"timestamp": "2025-01-01T00:00:00.000Z",
"meta": { /* pagination, counts, etc. */ }
}All cURL examples below use the v1 API. Replace http://localhost:3001 with your testnet backend URL. For demo mode, use any valid Stellar testnet address (e.g., from Stellar Laboratory).
GET /api/v1/healthResponse:
{
"status": "healthy",
"timestamp": "2025-01-01T00:00:00.000Z"
}GET /api/v1/system/statusResponse:
{
"system": {
"status": "operational",
"uptime": 12345,
"timestamp": "2025-01-01T00:00:00.000Z",
"version": "1.0.0"
},
"portfolios": { "total": 42, "active": 42 },
"rebalanceHistory": { "total": 128 },
"riskManagement": { "circuitBreakers": {}, "enabled": true },
"autoRebalancer": { "status": { "isRunning": true } },
"services": { "priceFeeds": true, "riskManagement": true },
"featureFlags": { "demoMode": false }
}GET /api/v1/strategiesResponse:
{
"strategies": [
{ "id": "threshold", "name": "Threshold", "description": "..." },
{ "id": "periodic", "name": "Periodic", "description": "..." }
]
}POST /api/v1/portfolio
Content-Type: application/json
Idempotency-Key: <uuid>
{
"userAddress": "GALPHABET...",
"allocations": { "XLM": 40, "BTC": 30, "USDC": 30 },
"threshold": 5,
"slippageTolerance": 1,
"strategy": "threshold"
}Response (201):
{
"success": true,
"data": {
"portfolioId": "portfolio-abc123",
"status": "created",
"mode": "onchain"
}
}Validation:
allocationsmust sum to 100%threshold: 1–50%slippageTolerance: 0.1–5% (optional, default: 1)strategy:threshold|periodic|volatility|custom(optional, default:threshold)
Create a new portfolio by uploading allocations from a CSV or JSON file. This is the primary endpoint consumed by the frontend BulkPortfolioImport.tsx component.
POST /api/v1/portfolio/import
Content-Type: application/json | text/csvJSON — wrapped object:
{
"allocations": [
{ "asset": "XLM", "allocation_pct": 40 },
{ "asset": "USDC", "allocation_pct": 35 },
{ "asset": "BTC", "allocation_pct": 25 }
],
"userAddress": "GALPHABET...",
"name": "My Portfolio",
"description": "Optional description"
}JSON — bare array:
[
{ "asset": "XLM", "allocation_pct": 40 },
{ "asset": "USDC", "allocation_pct": 35 },
{ "asset": "BTC", "allocation_pct": 25 }
]When using a bare array,
userAddressmust be included as a top-level field of the request or authenticated via JWT.
CSV — raw text with required headers:
asset,allocation_pct
XLM,40
USDC,35
BTC,25Send with
Content-Type: text/csv. Headers must includeassetandallocation_pct.
| Field | Type | Required | Notes |
|---|---|---|---|
allocations |
AllocationInputRow[] |
Yes | Array of { asset, allocation_pct }. May also be the top-level body for JSON. |
userAddress |
string |
Yes | Stellar public key. Required in body or via JWT auth. |
name |
string |
No | Portfolio display name. |
description |
string |
No | Portfolio description. |
Each AllocationInputRow:
| Field | Type | Constraints |
|---|---|---|
asset |
string |
Required, normalized to uppercase. Must exist in the asset registry, be enabled, and not quarantined. |
allocation_pct |
number |
Required, finite, 0–100. Duplicate assets are merged by summing percentages. |
- Allocations must sum to 100% (tolerance: 0.01%).
- Maximum 10 distinct assets.
- Maximum 5 000 rows.
allocation_pctmust be a finite number between 0 and 100 inclusive.- Asset codes are validated against the internal asset registry; unknown, disabled, or quarantined assets are rejected.
- Duplicate asset rows are merged (percentages summed) before validation.
curl -X POST http://localhost:3001/api/v1/portfolio/import \
-H "Content-Type: application/json" \
-d '{
"allocations": [
{ "asset": "XLM", "allocation_pct": 60 },
{ "asset": "USDC", "allocation_pct": 40 }
],
"userAddress": "GALPHABET...",
"name": "Imported Portfolio"
}'curl -X POST http://localhost:3001/api/v1/portfolio/import \
-H "Content-Type: text/csv" \
--data-binary "asset,allocation_pct
XLM,60
USDC,40"{
"success": true,
"data": {
"portfolioId": "portfolio-abc123",
"status": "created"
},
"error": null,
"timestamp": "2025-01-01T00:00:00.000Z"
}Returned when the payload fails validation. The errors array contains per-row details; meta summarises totals.
{
"error": "VALIDATION_ERROR",
"message": "Bulk import validation failed",
"code": "VALIDATION_ERROR",
"errors": [
{ "row": 2, "field": "asset", "message": "Invalid or unknown asset code: FAKE" },
{ "row": 3, "field": "allocation_pct", "message": "allocation_pct must be a number" },
{ "row": 0, "field": "allocation_pct", "message": "Allocations must sum to 100% (received 85%)" }
],
"meta": {
"totalRows": 3,
"validRows": 1
}
}Each error object:
| Field | Type | Description |
|---|---|---|
row |
number |
1-based row index (header = 1 for CSV). 0 indicates a payload-level error (e.g., sum check). |
field |
string |
Field that failed (asset, allocation_pct, header, csv_or_json, rows, json). |
message |
string |
Human-readable description of the failure. |
meta fields:
| Field | Type | Description |
|---|---|---|
totalRows |
number |
Total data rows received. |
validRows |
number |
Rows that passed all validation checks. |
| HTTP Status | Code | Condition |
|---|---|---|
| 400 | VALIDATION_ERROR |
Missing userAddress in body or JWT. |
| 500 | INTERNAL_ERROR |
Unexpected server error during portfolio creation. |
GET /api/v1/portfolio/{portfolioId}Response:
{
"portfolio": {
"id": "portfolio-abc123",
"userAddress": "GALPHABET...",
"totalValue": 10000.00,
"allocations": [
{ "asset": "XLM", "target": 40, "current": 38.5, "amount": 3500, "balance": 9752, "price": 0.3589 }
],
"needsRebalance": false,
"lastRebalance": "2025-01-01T00:00:00.000Z",
"threshold": 5,
"slippageTolerancePercent": 1,
"dayChange": 1.2
},
"riskHeatmap": { /* risk metrics per asset */ }
}GET /api/v1/user/{address}/portfoliosResponse:
{
"portfolios": [ /* array of portfolio objects */ ]
}Summarises every portfolio for one address in a single request, so a dashboard listing N portfolios costs one call rather than N. Prices are resolved once from the oracle cache and shared across the whole response.
GET /api/v1/portfolios/summary?userAddress={address}Response:
{
"success": true,
"data": {
"portfolios": [
{
"id": "portfolio-abc123",
"name": "Core holdings",
"total_value_usd": 10000,
"drift_status": "warning",
"last_rebalanced": "2026-01-02T00:00:00.000Z"
}
]
},
"error": null,
"timestamp": "2026-01-02T12:00:00.000Z"
}data.portfolios is an empty array when the address has no portfolios.
drift_status compares the largest allocation drift against that portfolio's own
threshold, so each portfolio is judged against the tolerance its owner chose:
| Status | Condition |
|---|---|
critical |
Largest drift is past the threshold. This is the same comparison the auto-rebalancer uses to decide a portfolio has drifted. |
warning |
Largest drift is at or above half the threshold, but not past it. |
ok |
Anything below that, including a portfolio holding nothing. |
name is null for a portfolio that was never named, and an asset with no
available price contributes zero to total_value_usd rather than being assumed.
- POST /api/portfolio — Create portfolio (
userAddress,allocations,threshold, optionalslippageTolerance). Allocations must sum to 100%; threshold 1–50%. SupportsIdempotency-Key. - GET /api/portfolio/{id} — Get portfolio by ID.
- GET /api/portfolios — List all portfolios (optional query parameter:
userAddress). - POST /api/portfolio/{id}/clone — Clone an existing portfolio (optional body:
{ name }). SupportsIdempotency-Key. - GET /api/user/{address}/portfolios — List portfolios for a Stellar address. When JWT auth is enabled, the token subject must match
:address(otherwise403). In demo mode, public-by-address listing is allowed only whenALLOW_PUBLIC_USER_PORTFOLIOS_IN_DEMOis enabled. - GET /api/portfolios/summary — Dashboard summary of every portfolio for one address in a single request (query:
userAddress, required). Returnsid,name,total_value_usd,drift_status(ok/warning/critical), andlast_rebalancedper portfolio; empty array for an unknown address. Prices are read once from the oracle cache for the whole response. Same ownership rules asGET /api/user/{address}/portfolios. - GET /api/portfolio/{id}/rebalance-plan — Get full read-only rebalance plan (per-asset buy/sell amounts, estimated fees, estimated slippage, projected allocations, prices).
- POST /api/portfolio/{id}/rebalance/dry-run — Dry-run rebalance; returns the same response schema as
rebalance-planwithout DB writes, contract calls, or trade execution. - POST /api/portfolio/{id}/rebalance — Execute rebalance (body optional:
{ options: { simulateOnly, ignoreSafetyChecks, slippageOverrides } }). SupportsIdempotency-Key. - GET /api/portfolio/{id}/analytics — Analytics time series (query:
days, default 30). - GET /api/portfolio/{id}/performance-summary — Performance summary.
- GET /api/portfolio/tax-report — Realized gain/loss tax report computed with FIFO cost basis (query:
yearoptional, defaults to current year;formatjson(default) orcsv).
Response:
{
"portfolioId": "portfolio-abc123",
"totalValue": 10000.00,
"maxSlippagePercent": 1,
"estimatedSlippageBps": 100,
"prices": { "XLM": { "price": 0.3589, "change": -0.5 } },
"priceFeedMeta": { /* feed metadata */ }
}POST /api/v1/portfolio/{portfolioId}/rebalance
Content-Type: application/json
Idempotency-Key: <uuid>
{
"options": {
"simulateOnly": false,
"ignoreSafetyChecks": false,
"slippageOverrides": { "XLM": 0.5 }
}
}Response:
{
"result": {
"status": "completed",
"txHash": "abc123...",
"trades": 3,
"gasUsed": "50000"
}
}GET /api/v1/portfolio/{portfolioId}/rebalance-estimateResponse:
{
"estimatedGas": "55000",
"estimatedCost": "0.05",
"canExecute": true
}POST /api/v1/portfolio/draft
Content-Type: application/json
Idempotency-Key: <uuid>
{
"userAddress": "GALPHABET...",
"allocations": { "XLM": 50, "USDC": 50 },
"threshold": 3,
"label": "My conservative draft"
}Response (201):
{
"draftId": "draft-xyz789",
"status": "draft_created"
}GET /api/v1/portfolio/draft/{draftId}PATCH /api/v1/portfolio/draft/{draftId}
Content-Type: application/json
Idempotency-Key: <uuid>
{
"allocations": { "XLM": 60, "USDC": 40 },
"threshold": 4
}POST /api/v1/portfolio/draft/{draftId}/publish
Idempotency-Key: <uuid>Response (201):
{
"portfolioId": "portfolio-abc123",
"status": "published"
}DELETE /api/v1/portfolio/draft/{draftId}GET /api/v1/user/{address}/draftsGET /api/v1/portfolio/{portfolioId}/export?format=json
# or format=csv, format=pdfResponse (202):
{
"jobId": "job-123456",
"status": "processing"
}GET /api/v1/portfolio/{portfolioId}/export/status/{jobId}Response (processing):
{
"status": "processing",
"state": "waiting"
}Response (completed):
Returns the file directly with appropriate Content-Type and Content-Disposition headers.
Returns daily portfolio values and key performance metrics computed from stored price snapshots and rebalance history.
GET /api/v1/portfolio/{portfolioId}/analytics?days=30
GET /api/v1/portfolio/{portfolioId}/analytics?from=2025-01-01T00:00:00Z&to=2025-06-01T00:00:00ZQuery params:
days(optional): Number of days to look back. Default: 30. Ignored iffrom/toare provided.from(optional): ISO 8601 start date. Must be beforeto. Future dates rejected.to(optional): ISO 8601 end date. Future dates rejected.
Response:
{
"portfolioId": "portfolio-abc123",
"dailyValues": [
{ "timestamp": "2025-01-01T00:00:00.000Z", "totalValue": 10000, "allocations": { "XLM": 60, "USDC": 40 } }
],
"metrics": {
"totalReturnPercent": 5.2,
"maxDrawdownPercent": 3.5,
"sharpeRatio": 1.8
},
"dataPoints": 30
}Returns empty dailyValues: [] and zeroed metrics for portfolios with no history.
GET /api/v1/portfolio/{portfolioId}/performance-summaryResponse:
{
"portfolioId": "portfolio-abc123",
"totalReturn": 5.2,
"annualizedReturn": 12.5,
"sharpeRatio": 1.8,
"maxDrawdown": -3.5,
"volatility": 8.2
}GET /api/v1/portfolio/{portfolioId}/risk-diagnosticsResponse:
{
"riskHeatmap": { /* per-asset risk scores */ }
}GET /api/v1/rebalance/history?portfolioId=portfolio-abc123&limit=50&source=onchainQuery params:
portfolioId(optional): Filter by portfoliolimit(optional): 1–500, default: 50offset(optional): Pagination offsetsource(optional):offchain|simulated|onchainstartTimestamp,endTimestamp(optional): ISO 8601syncOnChain(optional):trueto sync on-chain first
Response:
{
"history": [
{
"id": "event-1",
"portfolioId": "portfolio-abc123",
"timestamp": "2025-01-01T00:00:00.000Z",
"status": "completed",
"trigger": "manual",
"trades": 3,
"gasUsed": "50000"
}
],
"pagination": { "limit": 50, "offset": 0, "count": 1 }
}POST /api/v1/rebalance/history
Content-Type: application/json
Idempotency-Key: <uuid>
{
"portfolioId": "portfolio-abc123",
"trigger": "auto",
"trades": 2,
"gasUsed": "45000",
"status": "completed",
"isAutomatic": true
}POST /api/v1/rebalance/history/sync-onchainGET /api/v1/rebalance/summary/{portfolioId}Response:
{
"portfolioId": "portfolio-abc123",
"readiness": {
"systemReady": true,
"canExecute": true,
"checks": { "database": "ready", "queue": "ready", "workers": "ready" }
},
"drift": { "needsRebalance": true, "maxDriftPercent": 6.5, "exceedsThreshold": true },
"slippage": { "maxSlippagePercent": 1, "estimatedSlippageBps": 100 },
"risk": { "allowed": true, "overallRiskLevel": "low", "alerts": [] },
"dataFreshness": { "ageSeconds": 12, "isStale": false }
}GET /api/v1/auto-rebalancer/statusResponse:
{
"status": { "isRunning": true, "initialized": true },
"statistics": { "totalRebalances": 42, "successRate": 0.95 }
}POST /api/v1/auto-rebalancer/start
POST /api/v1/auto-rebalancer/stopPOST /api/v1/auto-rebalancer/force-checkPOST /api/v1/auto-rebalancer/dry-run/{portfolioId}GET /api/v1/auto-rebalancer/history?portfolioId=portfolio-abc123&limit=20GET /api/v1/risk/metrics/{portfolioId}Response:
{
"portfolioId": "portfolio-abc123",
"riskMetrics": {
"volatility": 0.15,
"concentrationRisk": 0.32,
"liquidityRisk": 0.08,
"var95": -0.025
},
"recommendations": [ "Reduce concentration in XLM" ],
"circuitBreakers": { "volatility": { "isTriggered": false } }
}GET /api/v1/risk/check/{portfolioId}Response:
{
"portfolioId": "portfolio-abc123",
"allowed": true,
"reason": null,
"riskMetrics": { /* ... */ }
}GET /api/v1/pricesResponse:
{
"prices": {
"XLM": { "price": 0.3589, "change": -0.5, "timestamp": 1706880000, "source": "coingecko" }
},
"feedMeta": { "degraded": false, "sources": ["coingecko"] }
}GET /api/v1/prices/enhancedResponse:
{
"prices": {
"XLM": { "price": 0.3589, "change": -0.5, "riskAlerts": [], "volatilityLevel": "low" }
},
"riskAlerts": [],
"feedMeta": { /* ... */ }
}GET /api/v1/market/{asset}/detailsGET /api/v1/market/{asset}/chart?days=7Response:
{
"asset": "XLM",
"data": [ { "timestamp": 1706880000, "price": 0.35 } ],
"timeframe": "7d",
"dataPoints": 168
}GET /api/v1/assets?enabledOnly=true&page=1&limit=20&sortBy=symbol&order=ascQuery params:
enabledOnly(optional):trueto show only enabled assetscode/search/q: Search by symbol/nameissuer: Filter by issuer addresssortBy:symbol|name|enabledorder:asc|descpage,limit: Pagination (max 100)
Response:
{
"assets": [
{
"symbol": "XLM",
"name": "Stellar Lumens",
"enabled": true,
"contractAddress": null,
"issuerAccount": null,
"coingeckoId": "stellar"
}
],
"pagination": { "page": 1, "limit": 20, "total": 50 }
}GET /api/v1/assets/{symbol}- GET /api/notifications/alerts/thresholds — Get per-asset price alert threshold overrides and the global default for a user (query:
userId, required). - PUT /api/notifications/alerts/thresholds — Merge per-asset price alert threshold overrides for a user (body:
userId,thresholds). - DELETE /api/notifications/alerts/thresholds — Remove a single per-asset price alert threshold override (query:
userId,asset, both required).
POST /api/v1/notifications/subscribe
Content-Type: application/json
Idempotency-Key: <uuid>
{
"userId": "GALPHABET...",
"emailEnabled": true,
"emailAddress": "user@example.com",
"webhookEnabled": false,
"webhookUrl": null,
"digestMode": false,
"events": ["rebalance", "priceMovement"]
}GET /api/v1/notifications/preferences?userId=GALPHABET...DELETE /api/v1/notifications/unsubscribe?userId=GALPHABET...&reason=no-longer-neededGET /api/v1/notifications/logs?userId=GALPHABET...GET /api/v1/notifications/alerts/thresholds?userId=GALPHABET...Returns per-asset price alert threshold overrides and the user's global default:
{
"success": true,
"data": {
"thresholds": { "XLM": 7, "BTC": 3 },
"defaultThreshold": 5
},
"timestamp": "2025-01-01T00:00:00.000Z"
}PUT /api/v1/notifications/alerts/thresholds
Content-Type: application/json
{
"userId": "GALPHABET...",
"thresholds": { "XLM": 7, "BTC": 3 }
}Merges the provided per-asset overrides into the user's existing overrides.
DELETE /api/v1/notifications/alerts/thresholds?userId=GALPHABET...&asset=XLMRemoves the per-asset override for the given asset so alert evaluation falls back to the user's global default.
GET /api/v1/consent/status?userId=GALPHABET...Response:
{
"accepted": true,
"termsAcceptedAt": "2025-01-01T00:00:00.000Z",
"privacyAcceptedAt": "2025-01-01T00:00:00.000Z",
"active": true
}POST /api/v1/consent/grant
Content-Type: application/json
Idempotency-Key: <uuid>
{
"userId": "GALPHABET...",
"terms": true,
"privacy": true,
"cookies": true,
"documentText": "v2025.01"
}POST /api/v1/consent/revoke
Content-Type: application/json
Idempotency-Key: <uuid>
{
"userId": "GALPHABET..."
}GET /api/v1/consent/audit?userId=GALPHABET...DELETE /api/v1/user/{address}/data
Authorization: Bearer <access_token>Admin routes require
Authorization: Bearer <admin_token>andADMIN_PUBLIC_KEYSto include your address.
GET /api/v1/admin/assets
Authorization: Bearer <admin_token>POST /api/v1/admin/assets
Authorization: Bearer <admin_token>
Content-Type: application/json
{
"symbol": "NEW",
"name": "New Asset",
"contractAddress": "C...",
"issuerAccount": "G...",
"coingeckoId": "new-asset"
}PATCH /api/v1/admin/assets/{symbol}
Authorization: Bearer <admin_token>
Content-Type: application/json
{
"enabled": true,
"quarantined": false
}DELETE /api/v1/admin/assets/{symbol}
Authorization: Bearer <admin_token>Debug routes are disabled in production (
NODE_ENV=production).
POST /api/v1/debug/notifications/test
Authorization: Bearer <admin_token>
Content-Type: application/json
{
"userId": "GALPHABET...",
"eventType": "rebalance"
}GET /api/v1/debug/force-fresh-prices
Authorization: Bearer <admin_token>GET /api/v1/debug/env
Authorization: Bearer <admin_token>- Swagger UI:
http://localhost:3001/api-docs - OpenAPI JSON:
http://localhost:3001/api-docs.json - Postman: Import from URL above or
backend/openapi.jsonafter runningnpm run openapi:export
- Authoritative spec:
backend/src/openapi/spec.ts - Generated artifacts:
backend/openapi.json - Validate sync:
cd backend && npm run api:validate - Export:
cd backend && npm run openapi:export
CI fails if docs are out of sync.