API Reference
Base URL
/api/v1
Authentication
Auth endpoints (/login, /register, /logout, /password-reset/*) are public. All other endpoints require authentication.
Admin endpoints (/admin/*) additionally require the user to have the admin flag set.
Login sets an httpOnly JWT cookie. Subsequent requests must include it (i.e. send with credentials: 'include'). The cookie name and domain are configured via COOKIE_DOMAIN and COOKIE_SECURE.
Alternatively, API tokens can be used in the Authorization header:
Authorization: Bearer mycorrhizal_<token>
App passwords / API tokens are created and managed by each user under Settings → API tokens (/api/v1/api-tokens); a token is limited to its explicit scopes (carddav, caldav, api:read, api:write, mcp), never has admin rights, and cannot manage credentials or enroll a second factor. Credential and identity management require an interactive session. The plaintext token is only returned once at creation time.
Error Responses
All errors follow the same structure:
{
"error": {
"code": "NOT_FOUND",
"message": "Contact not found",
"details": {}
},
"request_id": "…",
"timestamp": "2026-01-01T00:00:00Z"
}
Common error codes:
| Code | HTTP status |
|---|---|
UNAUTHORIZED | 401 |
INVALID_CREDENTIALS | 401 |
TOKEN_EXPIRED | 401 |
FORBIDDEN | 403 |
NOT_FOUND | 404 |
VALIDATION_ERROR | 400 |
INVALID_INPUT | 400 |
ALREADY_EXISTS | 409 |
PAYLOAD_TOO_LARGE | 413 |
RATE_LIMIT_EXCEEDED | 429 |
INTERNAL_ERROR | 500 |
Request IDs
Every request and response carries an X-Request-ID header created by the middleware.
Endpoints
Auth
| Method | Path | Description |
|---|---|---|
POST | /register | Create a new user account |
POST | /login | Authenticate and set session cookie |
POST | /logout | Clear session cookie |
POST | /check-password-strength | Validate a password without registering |
POST | /password-reset/request | Send a password reset email |
POST | /password-reset/confirm | Apply a password reset token |
POST | /login/2fa | Complete a two-step login with a TOTP or recovery code |
POST | /webauthn/login/begin | Start a passkey assertion for a pending two-step login (public, rate-limited) |
POST | /webauthn/login/finish | Complete a two-step login with a passkey assertion and set the session cookie |
POST | /webauthn/register/begin, /webauthn/register/finish | Enroll a passkey (needs a live second-factor proof once one is already enrolled) |
POST | /webauthn/assert/begin | Start a passkey assertion used as the proof for removing a passkey or adding a factor |
GET | /webauthn/credentials | List the caller’s passkeys |
PATCH | /webauthn/credentials/:id | Rename one of the caller’s passkeys ({"name": "…"}, 1–100 characters; no second-factor proof — a label is not a security change). An unnamed passkey is labelled after the enrolling browser and platform, e.g. “Chrome 153 on Linux” |
DELETE | /webauthn/credentials/:id | Remove a passkey (needs a live second-factor proof) |
These routes, and everything else under /webauthn/*, are absent from an embedded (Android local-profile) deployment.
Users
| Method | Path | Description |
|---|---|---|
GET | /users/me | Get the current user |
POST | /users/change-password | Change password |
PATCH | /users/language | Update UI language preference |
PATCH | /users/date-format | Update date format preference |
PATCH | /users/time-format | Update time format (auto/12h/24h) preference |
GET | /users/custom-fields | Get custom field names |
PATCH | /users/custom-fields | Update custom field names |
Contacts
| Method | Path | Description |
|---|---|---|
GET | /contacts | List contacts (supports search and circle filter) |
POST | /contacts | Create a contact |
GET | /contacts/:id | Get a contact (supports filtering the returned fields) |
PUT | /contacts/:id | Update a contact |
DELETE | /contacts/:id | Delete a contact |
POST | /contacts/:id/archive | Archive a contact |
POST | /contacts/:id/unarchive | Unarchive a contact |
GET | /contacts/:id/score | Relationship health score breakdown. Each facet (and target) carries an English reason plus a stable reason_code and reason_params for clients to render it in the user’s language |
PUT | /contacts/:id/target-closeness | Set the contact’s target closeness ({"tier": "immediate"\|"close"\|"casual"\|"distant"}). The response includes a cadence_policy_offer when the contact has no cadence policy |
DELETE | /contacts/:id/target-closeness | Clear the target and dismiss its pending “behind target” suggestions |
GET | /contacts/circles | List all circles in use |
GET | /contacts/random | Get five random contacts |
GET | /contacts/birthdays | Get upcoming birthdays |
POST | /contacts/:id/profile_picture | Upload a profile picture (multipart) |
GET | /contacts/:id/profile_picture | Get a contact’s profile picture |
GET | /proxy/image | Proxy an external image URL for upload preview |
Relationships
| Method | Path | Description |
|---|---|---|
GET | /contacts/:id/relationships | List outgoing relationships |
GET | /contacts/:id/incoming-relationships | List incoming relationships |
POST | /contacts/:id/relationships | Create a relationship |
PUT | /contacts/:id/relationships/:rid | Update a relationship |
DELETE | /contacts/:id/relationships/:rid | Delete a relationship |
Notes
| Method | Path | Description |
|---|---|---|
GET | /contacts/:id/notes | List notes for a contact |
POST | /contacts/:id/notes | Create a note for a contact |
GET | /notes | List unassigned notes |
POST | /notes | Create an unassigned note |
GET | /notes/:id | Get a note |
PUT | /notes/:id | Update a note |
DELETE | /notes/:id | Delete a note |
Activities
| Method | Path | Description |
|---|---|---|
GET | /activities | List all activities |
POST | /activities | Create an activity |
GET | /activities/:id | Get an activity |
PUT | /activities/:id | Update an activity |
DELETE | /activities/:id | Delete an activity |
GET | /contacts/:id/activities | List activities for a contact |
Reminders
| Method | Path | Description |
|---|---|---|
GET | /reminders | List all reminders |
GET | /reminders/upcoming | List upcoming reminders (used by dashboard) |
GET | /reminders/:id | Get a reminder |
PUT | /reminders/:id | Update a reminder |
DELETE | /reminders/:id | Delete a reminder |
POST | /reminders/:id/complete | Mark a reminder complete (creates timeline entry) |
GET | /contacts/:id/reminders | List reminders for a contact |
POST | /contacts/:id/reminders | Create a reminder for a contact |
GET | /contacts/:id/reminder-completions | List completion history for a contact (timeline entries) |
DELETE | /reminder-completions/:id | Delete a completion entry |
Import
| Method | Path | Description |
|---|---|---|
POST | /contacts/import/upload | Upload a CSV file, returns parsed preview data |
POST | /contacts/import/preview | Apply column mapping, returns contacts with duplicate detection |
POST | /contacts/import/confirm | Execute the import with per-row decisions |
POST | /contacts/import/vcf/upload | Upload a VCF file, returns contacts with duplicate detection |
POST | /contacts/import/vcf/confirm | Execute the VCF import |
POST | /contacts/import/jscontact/upload | Upload a JSContact (RFC 9553) JSON file, returns contacts with duplicate detection |
POST | /contacts/import/records | Start an import from a JSON batch of neutral Card/CRM records (bulk-import API for external clients) |
Bulk import contract (external clients)
The records endpoint is the repeatable bulk-import contract for external clients (e.g. the Android app): it serves both a first-run “import your contacts” prompt and a standing “Import from contacts” entry point with the same flow.
Session lifecycle. An import is a three-step, server-side-session flow:
POST /contacts/import/records(JSON batch, 1–500 records) — or an upload endpoint for a file — returns anImportPreviewResponsewith asession_id, per-row duplicate detection (duplicate_match+merge_diff), and within-batch detection (batch_duplicate_of).- (CSV only)
POST /contacts/import/previewapplies column mappings to the session. POST /contacts/import/vcf/confirm(VCF/JSContact/records) or/contacts/import/confirm(CSV) executes the import with one action per row:add,update(merge into the matched existing contact), orskip. The response is anImportResultsummary.
Idempotent confirm. Confirming a session consumes it. A retry of the same session_id within the 15-minute session window returns the original result as a no-op — it does not re-apply the import. This makes a dropped confirm response safe to retry: a client that times out mid-request can simply re-send the same confirm instead of re-uploading (which would create duplicates). To change a decision, start a new upload (new session_id).
Retry guidance. If a confirm is retried and the session has aged out (>15 min, or the server restarted), it returns 404 NOT_FOUND; the client must re-upload. The confirm endpoints are type-scoped: /contacts/import/confirm rejects records-sourced sessions and /contacts/import/vcf/confirm rejects CSV-sourced ones — a retried confirm must target the same endpoint the preview came from.
Guarantees. Sessions are scoped to the owning user; a foreign or expired session_id is 404. Sessions are held in memory only and lost on server restart. Batch limit is 500 records; the import commits per-row, so one failing row does not abort the rest. Ownership scoping, per-row decisions, and the 15-minute expiry are enforced server-side.
Export
| Method | Path | Description |
|---|---|---|
GET | /export | Download all data as CSV |
GET | /export/vcf | Download all contacts as VCF (includes photos) |
GET | /export/account | Download the full-fidelity, re-importable account bundle (JSON; every sensitivity level and suggested rows included; size limit in the X-Mycorrhizal-Bundle-Max-Bytes header) |
POST | /import/mycorrhizal/upload | Upload an account bundle; also /import/mycorrhizal/fetch, /status, /preview, /confirm, /cancel for the source-import flow |
Feeds
Private Atom feeds (ADR 0030). Feed management requires authentication; the feed itself is served to a feed reader by an unguessable token, not a session.
| Method | Path | Description |
|---|---|---|
GET | /feeds | List the caller’s active feeds (never returns a token) |
POST | /feeds | Create a per-contact or aggregate feed; the feed URL, which carries the token, is returned once |
POST | /feeds/:id/rotate | Replace a feed’s token and revoke the old URL |
DELETE | /feeds/:id | Revoke a feed |
POST | /feeds/revoke-all | Revoke every feed the caller owns |
GET | /feeds/atom?token=… | Public, rate-limited Atom document; every miss is an identical empty 404. Sensitivity-filtered |
Network
| Method | Path | Description |
|---|---|---|
GET | /graph | Contact network graph. By default the ego network (depth 2) of your self contact; ?from=<vCard UID> re-centres it on another (non-archived) contact, ?depth=1..5 sets the radius, and ?full=true returns the whole account. Accounts without a self contact get the full graph. The response’s scoped, from_vcard_uid and depth say which view it is |
GET | /graph/connections | Multi-hop relationship chains from ?from=<vCard UID> (?depth up to 5, optional ?relation) |
Dashboard
| Method | Path | Description |
|---|---|---|
GET | /dashboard | The dashboard composite. Each list block (birthdays, upcoming_reminders, overdue, reach_out_suggestions, contact_sync_conflicts, data_decay_overdue) holds at most 25 rows, most urgent first; the matching *_total field carries the uncapped count |
App passwords / API tokens
| Method | Path | Description |
|---|---|---|
GET | /api-tokens | List all tokens for the current user |
POST | /api-tokens | Create a token — returns the plaintext token once |
POST | /api-tokens/:id/rotate | Revoke and reissue with the same scopes |
POST | /api-tokens/revoke-all | Revoke every active token for the caller |
DELETE | /api-tokens/:id | Revoke one token |
POST /api-tokens body:
{ "name": "Thunderbird CardDAV", "expires_in_days": 90, "scopes": ["carddav"] }
scopes is an explicit set of carddav, caldav, api:read, api:write, mcp. The legacy scope: "full" | "carddav" field is still accepted when scopes is absent. A DAV-only scope set may send never_expires: true instead of expires_in_days; API/MCP tokens must expire. Response includes token (the mycorrhizal_… plaintext value) only on creation. Subsequent list responses omit it.
CardDAV/CalDAV account password
PATCH /users/dav-password-policy with { "allow": true | false } controls whether the account password may authenticate DAV clients when no second factor is enrolled. It is always rejected once TOTP or a passkey is enrolled; use an app password with the carddav and/or caldav scope.
Admin
| Method | Path | Description |
|---|---|---|
GET | /admin/users | List all users |
GET | /admin/users/:id | Get a user |
PATCH | /admin/users/:id | Update a user (e.g. set admin flag) |
DELETE | /admin/users/:id | Delete a user |
Health
All three are unauth, unversioned, and accessible directly (without prepending the base URL). See deployment.md for which probe each consumer should use.
| Method | Path | Description |
|---|---|---|
GET | /health/live | Liveness — is the process running? Returns immediately, no I/O. For restart policies. |
GET | /health/ready | Readiness — can this instance serve? DB + migrations + filesystem. 503 when not. For traffic gating. |
GET | /health | Deep health — the healthy/degraded/unhealthy roll-up, plus the build version and client-compat fields. Only 503s on a DB read failure. The per-facet breakdown (job names, integrity/restore-drill state, integration reachability) is admin-only at /admin/system-status (issue #864). |