# Contacts API v1

All endpoints use the same origin as the frontend. JSON errors have `error.code` and `error.message`; extractor errors for malformed JSON, UUID paths or multipart uploads may use HTTP plain text. Authentication, permission, policy and application errors use JSON. Times are Unix seconds. IDs identifying owners/peers are permanent Accounts UUIDs; current `c:id`/`si:id` values are display/lookup names.

## Authentication and OBO

Browser sessions use an opaque HttpOnly, SameSite=Lax cookie, Secure on HTTPS. Browser mutations require the configured same-origin Origin header. CLI sessions use `Authorization: Bearer SESSION_TOKEN`, returned by `POST /v1/auth/slt` with `{"slt":"…"}`. This token is private and distinct from Accounts tokens. The server encrypts Accounts access/refresh credentials at rest.

Another app obtains a Silicon Accounts **user-verification** proof with `receiving_app: silicon-contacts` and the scopes needed below, after the user's consent. Send `Authorization: Proof SAP_PROOF`. Contacts verifies the proof against Accounts on every request, checks proof kind, receiving app and scope, and uses only its represented user's UUID. App-verification proofs and proofs for other recipients are refused. Knowing a public ID does not authorize access to its owner's address book.

| Endpoint | Method | OBO scope |
| --- | --- | --- |
| `/v1/me`, `/v1/lookup?q=c:id` | GET | `contacts.read` |
| `/v1/contacts`, `/v1/contacts/UUID` | GET | `contacts.read` |
| `/v1/contacts` | POST | `contacts.write` |
| `/v1/contacts/UUID` | PUT, DELETE | `contacts.write` |
| `/v1/relationships` | GET | `contacts.read` |
| `/v1/relationships/ID_OR_UUID` | PUT | `contacts.write` |
| `/v1/settings` | GET | `settings.read` |
| `/v1/settings` | PATCH | `settings.write` |
| `/v1/photos` | POST multipart | `contacts.write` |
| `/v1/photos/UUID` | GET PNG | `contacts.read` |
| `/v1/messages` | GET | `messages.read` |
| `/v1/messages` | POST | `messages.write` |
| `/v1/messages/read` | POST | `messages.write` |
| `/v1/messages/UUID` | DELETE | `messages.write` |

`/health` is unauthenticated database readiness. Browser OAuth uses `/auth/start` and `/auth/callback`; logout is `POST /v1/auth/logout` for the local session. OBO callers manage their consent through Accounts.

## Contacts

Lists accept `limit` (1–200, default 100), `offset` (default 0), and `q` for case-insensitive name/current-ID matching. Responses contain `items`, `total`, `offset`, `limit`.

POST and PUT replace the complete saved-details snapshot. Preserve fields you want to retain when updating; omitted optional details are cleared/defaulted. Example:

```json
{
  "id": "si:assistant",
  "name": "Research partner",
  "note": "Helps with papers",
  "phone": "",
  "email": "",
  "photo_id": null,
  "birthday_month": 2,
  "birthday_day": 29,
  "birthday_year": null,
  "trust": 2
}
```

`id` is required for POST and may be an Accounts UUID. PUT resolves its path. `name` is required, up to 200 UTF-8 bytes; note ≤10,000 bytes, phone ≤100, email ≤320. Trust defaults to 1; 0 represents unsaved and must be expressed by removal. A month/day pair is required when any birthday field is present. Yearless February 29 is accepted; dated birthdays must be valid. A contact cannot be the caller itself.

Upload a PNG/JPEG/WebP as the first multipart field to `/v1/photos`. Input is limited to 5 MB and dimensions to 8192×8192; the server decodes and re-encodes a thumbnail ≤512×512 as PNG, discarding metadata. Only the owner or their scoped app can read/assign that photo. Contact deletion leaves mute/block rules and all message history intact.

Account objects contain public identifiers plus the display name and picture that Accounts permits Contacts to read. Basic app-level identity lookups omit names/photos, so Contacts reads these two fields from its Accounts user-base entry only while that membership is active with the profile grant. Missing membership, removed access or a missing profile grant clears the Accounts picture. Email, phone, timezone and other private membership fields are never copied to another person's address book. Saved contact details are always the owner's own fields. A saved custom photo takes precedence; if it fails to load, the UI tries the permitted Accounts photo before initials.

## Messages and policy

POST `/v1/messages` with `{"to":"c:recipient","body":"Hello"}` and a required `Idempotency-Key` header. Bodies contain 1–10,000 characters. Reuse the same key with the same recipient UUID and exact body to recover an ambiguous send; using the key for a different message yields 409. The recipient is resolved to UUID before the operation fingerprint is calculated. Concurrent retries create one durable message and at most one outbox event.

GET lists accept pagination plus `box=all|inbox|sent` and `peer=UUID`. They return newest accepted messages first, including deterministic ordering within one second, and an account-wide `unread` count. Read requests never mutate read state. POST `/v1/messages/read` with `{"ids":["UUID"]}` (up to 1,000) or `{"all":true}`. Only the represented recipient can mark their messages read. DELETE hides the caller's copy; it does not delete the other participant's copy.

PUT `/v1/relationships/ID_OR_UUID` accepts optional `muted` and `blocked` booleans. PATCH `/v1/settings` accepts optional `mute_unknown` and `block_unknown`. Both settings default false. A specific block takes precedence, then noncontact refusal, then per-ID or noncontact mute. Muting stores the message without an individual Ting. A mute cannot override refusal. Saved and unsaved IDs both support independent mute/block state.

When noncontacts are disallowed, HTTP 403 includes: `This carbon/silicon doesn't accept messages from non contacts. Ask them to add you to contact first.` A specific block returns a distinct `contact_blocked` error. The inbox preserves existing history and marks blocked names/IDs with `(blocked)` at 50% opacity.

## Notification lifecycle

Accepted messages and their outbox events commit in one PostgreSQL transaction. Ting failures do not lose inbox messages. Delivery retries use a stable event key and frozen payload. A later mute, block or recipient deletion suppresses an event that has not finished delivery. Ting handles its own onward transport and delivery history.

Ting requires the recipient's consented subscription. Contacts registers it using Accounts user verification after the recipient signs in. Messages to an account that has not signed in are still stored; notification delivery waits for a valid subscription. Signing out does not destroy history. Revoked Accounts authority cannot be used to register a new subscription.

The scheduler checks every 30 seconds. Birthdays are due at local midnight, daily unread totals at 20:00, with one event key per owner/date (and birthday contact). UTC is used when timezone is absent/invalid. After downtime, due events for the current local day catch up; previous dates are not backfilled. February 29 birthdays run on February 29 in leap years. A birth year adds age in brackets. No unread reminder is created for a zero count.
