# HTTP API reference

> Routes, methods, request and response shapes, authentication, and error responses.

- Repository: egoist/lorca
- GitHub: https://github.com/egoist/lorca
- Human docs: https://grok-wiki.com/public/docs/egoist-lorca-2cf67495e5e6
- Complete Markdown: https://grok-wiki.com/public/docs/egoist-lorca-2cf67495e5e6/llms-full.txt

## Source Files

- `crates/cli/src/api.rs`
- `crates/relay/src/routes.rs`

---

---
title: "HTTP API reference"
description: "Routes, methods, request and response shapes, authentication, and error responses."
---

`lorca-relay` exposes an Axum HTTP API under `/v1/*` (default bind `127.0.0.1:8787`, protocol `1`). Clients send ciphertext and public keys only; the relay stores and forwards opaque blobs between an identity’s machines. A second, localhost surface exists on `lorca serve` (`GET /`, `GET /ws`).

<Note>
Send `Lorca-Protocol: 1` on every `/v1/*` call except `/v1/health`. Clients below `--min-protocol` get `426 Upgrade Required`.
</Note>

## Surfaces

| Surface | Bind | Auth | Role |
| --- | --- | --- | --- |
| Relay REST + sync WS | `LORCA_RELAY_BIND` / `127.0.0.1:8787` | Bearer HMAC token (or signed identity request) | Cross-device mailbox |
| Local app server | `127.0.0.1:$LORCA_PORT` (default `4862`) | Loopback only | Desktop/mobile JSON methods over `/ws` |

## Conventions

| Rule | Value |
| --- | --- |
| Encoding | base64url without padding for keys, signatures, ciphertext in JSON |
| JSON body limit | 6 MiB (`MAX_BODY_BYTES`) |
| Non-file blob ciphertext | 1…4 MiB |
| File upload | raw body, `Content-Type: application/octet-stream`, up to 100 MiB + 40-byte envelope |
| Blob / group / slot / file ids | 1–64 chars of `[A-Za-z0-9._-]`, not `.` or `..` |
| Blob kinds | `roster`, `chat`, `job`, `job_cancel`, `job_result`, `request`, `response`, `machine`, `credentials`, `key`, `file` |
| Success with no body | `204 No Content` |
| Error body | `{"error":"<message>"}` (plus extras on protocol refusal) |

## Authentication

### Signed identity request

Used by `POST /v1/identities`.

```json
{
  "payload": "<base64url(JSON)>",
  "signature": "<base64url(ed25519(payload_bytes))>"
}
```

Decoded payload must include `identity_pubkey`, `ts` (unix seconds), and the route-specific fields. Signature is over the raw payload bytes with the identity Ed25519 key. Skew window: ±5 minutes (`SIGNED_REQUEST_SKEW`).

### Machine challenge → bearer token

1. `POST /v1/auth/challenge` with `{ "machine_pubkey": "..." }` → `{ "nonce", "expires_in": 120 }`
2. Sign the nonce bytes with the machine key; `POST /v1/auth/verify`
3. Use `Authorization: Bearer <token>` on authenticated routes

Token TTL: 1 hour. Token body is HMAC-SHA256 over `identity_pubkey|machine_pubkey|expires_at` with `LORCA_RELAY_SECRET`. A revoked machine returns `410 Gone` even if the token is still within TTL.

### Public vs authenticated routes

**Per-IP limited (no bearer):** `POST /v1/identities`, `POST /v1/auth/challenge`, `POST /v1/auth/verify`, `POST /v1/pair/{nonce}/request`, `GET /v1/pair/{nonce}/reply`

**Bearer required:** everything else under `/v1/` except health (and the two public pairing mailbox endpoints above)

**Unauthenticated probes:** `GET /`, `GET /v1/health`

**Metrics bearer:** `GET /metrics` uses `LORCA_RELAY_METRICS_TOKEN` (route is `404` when unset)

## Errors

| Status | When |
| --- | --- |
| `400` | Bad JSON, unknown kind, invalid id/key/size, wrong file content type path |
| `401` | Missing/bad bearer, bad signature, expired challenge/token |
| `403` | Pairing owned by another identity |
| `404` | Unknown blob/file/machine/pairing; metrics when token unset |
| `409` | Identity content-key mismatch; deleted group; pairing already has a request |
| `410` | Machine unpaired |
| `413` | Storage quota exceeded |
| `415` | File upload without `application/octet-stream` |
| `426` | Client protocol below `--min-protocol` |
| `429` | IP or identity rate limit; includes `Retry-After` |
| `503` | Large upload waited 30s for a slot; `Retry-After: 5` |
| `500` | Database/internal failure (`{"error":"Database error"}` etc.) |

Protocol refusal body:

```json
{
  "error": "This relay needs a newer Lorca",
  "min_protocol": 1,
  "protocol": 1
}
```

## Rate limits and quotas

| Limit | Default | Applies to |
| --- | --- | --- |
| IP | 60/min (`LORCA_RELAY_IP_PER_MINUTE`) | Public routes above |
| Identity | 50/s burst×10 (`LORCA_RELAY_IDENTITY_PER_SECOND`) | Bearer routes |
| Concurrent large uploads | 3 (`LORCA_RELAY_CONCURRENT_UPLOADS`), bodies &gt; 1 MiB | `PUT /v1/blobs`, `PUT /v1/files/{id}` |
| Quota | 5 GiB/identity (`LORCA_RELAY_QUOTA_BYTES`; `0` = unlimited) | Inserts |

Behind a reverse proxy set `LORCA_RELAY_TRUST_PROXY=true` so limits use `X-Forwarded-For`.

## Endpoint inventory

| Method | Path | Auth | Summary |
| --- | --- | --- | --- |
| `GET` | `/` | none | Plain text: `Lorca Relay is running...` |
| `GET` | `/v1/health` | none | Liveness + protocol |
| `GET` | `/metrics` | metrics token | Prometheus text |
| `POST` | `/v1/identities` | signed | Register identity + machine |
| `POST` | `/v1/auth/challenge` | none | Start machine login |
| `POST` | `/v1/auth/verify` | none | Redeem challenge → token |
| `DELETE` | `/v1/identity` | bearer | Delete whole identity |
| `GET` | `/v1/machines` | bearer | List machines + presence |
| `DELETE` | `/v1/machines/{machine_pubkey}` | bearer | Revoke one machine |
| `PUT` | `/v1/blobs` | bearer | Upload non-file blob |
| `GET` | `/v1/blobs` | bearer | Page blobs since `seq` |
| `GET` | `/v1/blobs/{id}` | bearer | One non-file blob |
| `DELETE` | `/v1/blobs/{id}` | bearer | Delete blob (and file object) |
| `PUT` | `/v1/files/{id}` | bearer | Upload attachment bytes |
| `GET` | `/v1/files/{id}` | bearer | Download attachment bytes |
| `DELETE` | `/v1/groups/{group}` | bearer | Delete chat group + attachments |
| `GET` | `/v1/groups/{group}/blobs` | bearer | Page group slots backwards |
| `GET` | `/v1/sync` | bearer | WebSocket presence + signals |
| `PUT` | `/v1/push/token` | bearer | Register APNs/FCM token |
| `DELETE` | `/v1/push/token` | bearer | Clear this machine’s token |
| `POST` | `/v1/push` | bearer | Queue push ciphertext |
| `POST` | `/v1/pair` | bearer | Open pairing mailbox |
| `DELETE` | `/v1/pair/{nonce}` | bearer | Cancel pairing |
| `POST` | `/v1/pair/{nonce}/request` | none | Joiner posts sealed request |
| `GET` | `/v1/pair/{nonce}/request` | bearer | Host reads request |
| `POST` | `/v1/pair/{nonce}/reply` | bearer | Host posts sealed reply |
| `GET` | `/v1/pair/{nonce}/reply` | none | Joiner polls reply |

## Health and metrics

:::endpoint GET /v1/health Liveness
No auth. Not gated by `Lorca-Protocol`.

<ResponseExample>
```json
{ "ok": true, "service": "lorca-relay", "protocol": 1 }
```
</ResponseExample>
:::

:::endpoint GET /metrics Prometheus scrape
Requires `Authorization: Bearer <LORCA_RELAY_METRICS_TOKEN>`. Without the env/flag the route returns `404`. Wrong token → `401` with empty body. Content-Type: `text/plain; version=0.0.4; charset=utf-8`. Metric names are prefixed `lorca_relay_`.
:::

## Identities and machines

:::endpoint POST /v1/identities Register identity and attest a machine
Signed by the identity key. Idempotent when the same `content_pubkey` is reused; different content key → `409`.

<ParamField body="payload" type="string" required>
base64url JSON with `identity_pubkey`, `content_pubkey`, `machine.machine_pubkey`, `machine.box_pubkey` (32-byte X25519 as base64url), `ts`
</ParamField>
<ParamField body="signature" type="string" required>
base64url Ed25519 over payload bytes
</ParamField>

<ResponseExample>
```json
{
  "identity_pubkey": "...",
  "machine_pubkey": "..."
}
```
</ResponseExample>
:::

:::endpoint POST /v1/auth/challenge Request login nonce
<RequestExample>
```json
{ "machine_pubkey": "<base64url ed25519>" }
```
</RequestExample>
<ResponseExample>
```json
{ "nonce": "<base64url>", "expires_in": 120 }
```
</ResponseExample>
:::

:::endpoint POST /v1/auth/verify Exchange signature for bearer token
<RequestExample>
```json
{
  "machine_pubkey": "...",
  "nonce": "...",
  "signature": "<ed25519(nonce bytes)>"
}
```
</RequestExample>
<ResponseExample>
```json
{
  "token": "<body>.<hmac>",
  "expires_at": 1710000000,
  "identity_pubkey": "...",
  "machine_pubkey": "..."
}
```
</ResponseExample>

Unknown/expired challenge → `401`. Unknown machine → `404`. Unpaired machine → `410`.
:::

:::endpoint GET /v1/machines List machines
Returns `{ "machines": [...], "now": <unix> }`. Each machine: `machine_pubkey`, `box_pubkey`, `last_seen`, `created_at`, `online`.
:::

:::endpoint DELETE /v1/machines/{machine_pubkey} Revoke a machine
`204` on success. Drops pending sealed envelopes, closes its sync socket, publishes a `machines` signal. Unknown machine → `404`.
:::

:::endpoint DELETE /v1/identity Delete the identity
`204`. Removes machines, blobs, attachments, push tokens, and usage. Paired clients that reconnect see `410` and forget the identity.
:::

## Blobs and files

:::endpoint PUT /v1/blobs Store a non-file blob
JSON body. `kind` must be in `KINDS` and must not be `file` (use the file route).

<ParamField body="id" type="string">
Optional client id; default UUID
</ParamField>
<ParamField body="kind" type="string" required>
Blob kind (not `file`)
</ParamField>
<ParamField body="ciphertext" type="string" required>
base64url ciphertext, 1…4 MiB decoded
</ParamField>
<ParamField body="recipient_machine_pubkey" type="string">
Sealed to one machine
</ParamField>
<ParamField body="slot" type="string">
Named slot for replaceable messages
</ParamField>
<ParamField body="keep_first" type="boolean">
With `slot`: keep the first version when replacing
</ParamField>
<ParamField body="group" type="string">
Chat/group id for later group delete/paging
</ParamField>

<ResponseExample>
```json
{ "id": "...", "seq": 42 }
```
</ResponseExample>

Duplicate id → `{ "id", "seq", "existing": true }`. Quota → `413`. Deleted group → `409`.
:::

:::endpoint GET /v1/blobs Page the identity log
Query: `since` (seq, default `0`), `kinds` (comma list; default all non-file), `limit` (1–500, default 200). Page capped at ~8 MiB of ciphertext.

<ResponseExample>
```json
{
  "blobs": [
    {
      "id": "...",
      "kind": "chat",
      "recipient_machine_pubkey": null,
      "seq": 42,
      "ciphertext": "...",
      "created_at": 1710000000
    }
  ],
  "seq": 42
}
```
</ResponseExample>

Pull until a page is empty; refresh when the sync socket says `blobs`. Asking for `file` in `kinds` → `400`.
:::

:::endpoint GET /v1/blobs/{id} Fetch one non-file blob
Returns a single `BlobOut` object. File kind → `400` (use `/v1/files/{id}`). Missing → `404`.
:::

:::endpoint DELETE /v1/blobs/{id} Delete a blob
`204`. For `file` kinds also deletes the object store key.
:::

:::endpoint PUT /v1/files/{id} Upload attachment ciphertext
Raw body. Optional query `?group=`. Requires `Content-Type: application/octet-stream`.

<ResponseExample>
```json
{ "id": "...", "seq": 7, "existing": false }
```
</ResponseExample>
:::

:::endpoint GET /v1/files/{id} Download attachment ciphertext
Returns `application/octet-stream` body. Missing row or missing object → `404`.
:::

:::endpoint DELETE /v1/groups/{group} Delete a chat group
`204`. Removes the group’s blobs/attachments and refuses future blobs for that group (`409 Group was deleted`). Idempotent.
:::

:::endpoint GET /v1/groups/{group}/blobs Page a chat backwards
Query: `before` (slot place/seq upper bound; omit for newest), `limit` (1–500, default 100).

<ResponseExample>
```json
{
  "slots": [
    { "place": 100, "blobs": [ { "id": "...", "kind": "chat", "seq": 100, "ciphertext": "...", "created_at": 1, "recipient_machine_pubkey": null } ] }
  ],
  "has_more": true
}
```
</ResponseExample>
:::

## Sync WebSocket

:::endpoint GET /v1/sync Presence and wake signals
Upgrade with bearer auth (same protocol header rules as other `/v1` routes).

While open the machine is **online**; `last_seen` updates on open/close. Server pings every 25s and drops a silent peer after ~50s.

Server → client text frames (signals only, no payload data):

```json
{"type":"blobs"}
```

```json
{"type":"machines"}
```

`blobs` means pull `GET /v1/blobs` (or the relevant sealed recipient path). `machines` means refresh `GET /v1/machines`. Client traffic can be any WS message to reset the silence timer; the relay does not parse client JSON on this socket.
:::

```mermaid
sequenceDiagram
  participant Device
  participant Relay
  Device->>Relay: POST /v1/auth/verify
  Relay-->>Device: bearer token
  Device->>Relay: GET /v1/sync (Bearer)
  Relay-->>Device: WS open (online)
  Note over Relay: blob inserted for identity
  Relay-->>Device: {"type":"blobs"}
  Device->>Relay: GET /v1/blobs?since=N
  Relay-->>Device: {"blobs":[...],"seq":M}
```

## Push

:::endpoint PUT /v1/push/token Register device push token
<ParamField body="platform" type="string" required>
`apns` or `fcm`
</ParamField>
<ParamField body="token" type="string" required>
APNs: hex ≤200 chars. FCM: printable ASCII ≤4096
</ParamField>
<ParamField body="environment" type="string">
`sandbox` for APNs development; otherwise `production`
</ParamField>

`204` on success.
:::

:::endpoint DELETE /v1/push/token Unregister this machine
`204`.
:::

:::endpoint POST /v1/push Queue encrypted push
Body `{ "ciphertext": "<base64url>" }` decoded size 1…2560 bytes. Delivery is async after the response.

<ResponseExample>
```json
{ "queued": 2 }
```
</ResponseExample>

`queued` is how many registered tokens this relay can actually send (APNs/FCM configured).
:::

## Pairing mailbox

TTL: 10 minutes. Ciphertext for request/reply: ≤64 KiB decoded.

| Step | Caller | Call |
| --- | --- | --- |
| 1 | Host (bearer) | `POST /v1/pair` → `{ "nonce", "expires_at" }` |
| 2 | Joiner (no auth) | `POST /v1/pair/{nonce}/request` `{ "ciphertext" }` → `204` |
| 3 | Host | `GET /v1/pair/{nonce}/request` → `{ "ciphertext": "..." \| null }` |
| 4 | Host | `POST /v1/pair/{nonce}/reply` `{ "ciphertext" }` → `204` |
| 5 | Joiner | `GET /v1/pair/{nonce}/reply` → `{ "ciphertext": "..." \| null }` |
| Cancel | Host | `DELETE /v1/pair/{nonce}` → `204` |

Second request on the same nonce → `409 Pairing already has a request`. Wrong identity on host routes → `403`. Unknown/expired → `404`.

## Local `lorca serve` HTTP

`lorca serve` binds `127.0.0.1` only (port `4862` / `LORCA_PORT`).

| Method | Path | Behavior |
| --- | --- | --- |
| `GET` | `/` | Plain text `lorca` |
| `GET` | `/ws` | WebSocket JSON RPC |

Request:

```json
{ "id": 1, "method": "hello", "params": {} }
```

Response:

```json
{ "id": 1, "result": { ... } }
```

or `{ "id": 1, "error": { "message": "..." } }`. Async app events arrive as `{ "event", "data" }` (and related event shapes). Method catalog lives in `crates/cli/src/api.rs` (`hello`, `bootstrap`, identity/pair/device, bots/chats, providers, …)—see the CLI reference for invocation from the command line.

On ready (with `--ready-stdout`), the process prints one JSON line to stdout: `{ "event": "ready", "port": <n> }`.

## Quick verify

<Steps>
<Step title="Health">
```bash
curl -s "$RELAY/v1/health"
```
Expect `{"ok":true,"service":"lorca-relay","protocol":1}`.
</Step>
<Step title="Root banner">
```bash
curl -s "$RELAY/"
```
Expect `Lorca Relay is running...`.
</Step>
</Steps>

## Related pages

<CardGroup>
<Card title="crates/relay reference" href="/ref-crates-relay">
Binary flags, storage backends, push config, and deploy defaults.
</Card>
<Card title="Configuration reference" href="/configuration-reference">
`LORCA_RELAY_*` env vars and CLI equivalents.
</Card>
<Card title="CLI reference" href="/cli-reference">
`lorca serve`, local port, and method-style invocation.
</Card>
<Card title="Architecture" href="/architecture">
How Devices, blobs, and the relay fit together.
</Card>
</CardGroup>

Next: run `curl -s http://127.0.0.1:8787/v1/health` against a local relay (or your deployed URL) and confirm `"protocol":1`.
