# Appendix B · API

Complete Vivra server API contract: routes, inputs, outputs, and errors. It supplements the route list on the Project analysis page. All figures in JSON responses below are examples, not live data.

## General conventions

- Base: `https://api.<vivra-domain>/v1`. UTF-8 JSON throughout.
- On-chain amounts returned as strings (raw units); USD amounts as numbers with 2 decimals in this specification; dates in ISO 8601 UTC.
- Cursor pagination: `?limit=50&cursor=<opaque>`, response `{ "items": [...], "next_cursor": "..." | null }`. Maximum `limit`: 100.
- Voter authentication: `vivra_session` cookie (HttpOnly, Secure, SameSite=Lax), value = session ID. No token in browser storage.
- Back-office authentication: message signed by a wallet in `team_wallets` (SIWE, EIP-4361), then a 12 h `vivra_admin` cookie.
- CORS: dapp and back-office domains only.

### Error format

```json
{ "error": { "code": "vote_closed", "message": "Voting for cycle 4 closed at 2026-11-12T18:00:00Z." } }
```

| HTTP | `code` | When |
| --- | --- | --- |
| 400 | `invalid_input` | missing or malformed field (details in `message`) |
| 401 | `unauthenticated` | no valid session |
| 403 | `forbidden` | team account, blocked account, wallet not allowlisted |
| 403 | `account_too_new` | account younger than `min_account_age_hours` |
| 404 | `not_found` | unknown resource |
| 409 | `vote_closed` / `vote_not_open` | outside voting window |
| 422 | `disposable_email` | disposable email domain |
| 422 | `invalid_code` | incorrect or expired code |
| 429 | `rate_limited` | too many requests; `Retry-After` header |
| 500 | `internal` | server error with `request_id` |

### Rate limits

| Route | Limit |
| --- | --- |
| `POST /auth/email` | 3 per email per hour, 10 per IP per hour |
| `POST /auth/verify` | 5 attempts per code, then the code is invalidated |
| `POST /votes`, `POST /supports` | 20 per session per minute |
| Public read routes | 120 per IP per minute |

## Public routes (no session required)

### `GET /stats`

```json
{ "takeovers": 4, "revived": 2, "verdicts": 3,
  "vivra_burned": "1250000000000000000000000", "cto_fees_received_usd": 4210.55 }
```

### `GET /tokens/:address`

Only for a published token (candidate in a published cycle or a token taken over); otherwise 404. Returns the token header, latest snapshot, contract checks, socials, latest recovery score and breakdown, and 90 days of daily history (`history: [{ "date", "price_usd", "volume_usd", "holders" }]`).

### `GET /cycles/current`

```json
{ "id": 4, "number": 4, "status": "voting",
  "vote_opens_at": "2026-11-10T18:00:00Z", "vote_closes_at": "2026-11-12T18:00:00Z",
  "voters": 3812,
  "candidates": [ {
    "id": 17, "position": 1, "token": "0x…", "ticker": "GRANDPA",
    "tagline": "…", "thesis_short": "…", "votes": 1290, "share_pct": 33.8,
    "supports": 412, "recovery_score": 68 } ],
  "my_vote": 17 }
```

`my_vote` is `null` without a session or vote.

### `GET /candidates/:id`

The complete dossier: all blocks from Candidate dossier contents, plus `recovery_score` (score frozen at publication and its breakdown) and `recovery_score_live`.

### `GET /cycles` and `GET /cycles/:id`

Cycle history with the winner and final results.

### `GET /operations/:id`

```json
{ "id": 4, "token": "0x…", "ticker": "GRANDPA", "status": "running", "j0": "…",
  "steps": [ { "key": "designer.directions", "agent": "designer", "due_at": "…", "status": "done" } ],
  "spent_usd": 38.12,
  "buyback": { "envelope_wei": "…", "cap_wei": "…", "slices_done": 9, "slices_total": 23, "burned": "…" },
  "links": { "site": "https://grandpa.<vivra-domain>", "x": "https://x.com/…", "telegram": "https://t.me/…" },
  "cto_request": { "status": "submitted" },
  "verdict": null }
```

### `GET /operations/:id/log`

Cursor pagination. Each row: `{ "id", "at", "agent", "kind", "summary", "cost_usd" }`. Never `payload`.

### `GET /ledger`

Parameters: `type`, `from`, `to`, `limit`, `cursor`. Each row: type, token, raw and USD amounts, source and destination wallets, hash, date.

### `GET /wallets`

Public addresses of all wallets in `wallets`, with current balances.

## Sign-in

### `POST /auth/email`

Input: `{ "email": "a@b.com", "captcha_token": "…" }`. Normalizes the email (lowercase, no surrounding spaces), rejects disposable domains, creates or retrieves the user, and emails a six-digit code plus magic link (`/auth/magic?token=…`). Always responds `204`, whether the email exists or not.

### `POST /auth/verify`

Input: `{ "email": "a@b.com", "code": "482913" }` or `{ "magic_token": "…" }`. Creates a 30-day session and sets the cookie. Response: `{ "user": { "id": 812, "created_at": "…", "can_vote_at": "…" } }`.

### `POST /auth/logout`

Revokes the current session. `204`.

### `GET /me`

`{ "id", "created_at", "can_vote_at", "votes": [{ "cycle_id", "candidate_id" }], "supports": [candidate_id] }`.

## Voting and support (session required)

### `POST /votes`

Input: `{ "candidate_id": 17 }`. Creates or replaces the user’s vote for that candidate’s cycle. Errors: `vote_not_open`, `vote_closed`, `account_too_new`, `forbidden`. Response: the updated `GET /cycles/current` object.

### `POST /supports` and `DELETE /supports/:candidate_id`

Input: `{ "candidate_id": 17 }`. Idempotent. Response: `{ "supports": 413 }`.

## Real time (WebSocket)

Connect to `wss://api.<vivra-domain>/v1/ws`. The client sends `{ "subscribe": "cycle:4" }` or `{ "subscribe": "operation:4" }`. Incoming messages:

| `type` | Channel | Content |
| --- | --- | --- |
| `vote.tally` | `cycle:*` | `{ candidates: [{ id, votes, share_pct }], voters }`, at most 1 per second |
| `step.updated` | `operation:*` | `{ key, status, at }` |
| `log.appended` | `operation:*` | one public log row |
| `slice.executed` | `operation:*` | `{ index, amount_in_wei, amount_burned, tx_hash, at }` |
| `trade` | `operation:*` | `{ price_usd, amount_usd, side, at }` for the live chart |
| `verdict` | `operation:*` | `{ verdict, at }` |

Ping every 25 s; automatic client reconnection resumes from the last received `id` (`{ "subscribe": "operation:4", "since": 1532 }`).

## Back-office (`/admin` prefix, admin session required)

| Method and route | Purpose |
| --- | --- |
| `POST /admin/auth/nonce`, `POST /admin/auth/verify` | team-wallet SIWE sign-in |
| `POST /admin/projects` | submit an address and optional links; start Scanner analysis |
| `GET /admin/projects?status=analyzed` | analyzed projects with target, safety, and recovery score |
| `POST /admin/projects/:address/review` | `{ "decision": "validate" \| "reject" \| "rerun", "reason": "…" }` |
| `POST /admin/cycles` | create a cycle (number, times, minimum account age) |
| `PUT /admin/cycles/:id/candidates` | set the 5 candidates and their copy; rejected after publication |
| `POST /admin/candidates/:id/prereport` | start Scanner pre-report |
| `POST /admin/cycles/:id/publish` | publish all 5 dossiers together; freeze scores |
| `GET /admin/validations?status=pending` | approval queue |
| `POST /admin/validations/:id` | `{ "decision": "approve" \| "reject", "reason": "…" }` |
| `POST /admin/operations/:id/pause`, `/resume`, `/stop` | operation controls and emergency stop |
| `POST /admin/buyback/stop` | emergency stop for all slices |
| `PUT /admin/cto-requests/:id` | update Pons application status |
| `GET /admin/settings`, `PUT /admin/settings/:key` | thresholds and weights, with audit |
| `GET /admin/audit` | team action audit log |

Every write to `/admin` adds a row to `admin_audit`.
