# Phone photo auto-backup — API contract

Written 2026-08-04, for the mobile developer. This is the server-side contract for "photos taken on the
phone appear on the server automatically", i.e. what the Immich app does today.

**The mobile app is not built here.** This document is the boundary: everything below is either already
live or explicitly listed as missing, so the app can be built against it rather than reverse-engineered
from the web client.

---

## The short version

**Almost all of this already exists.** Officer proxies a self-hosted Immich through the `officer-photos`
sidecar, and the sidecar's route allow-list already permits the `assets` resource for `GET POST PUT
DELETE` (`src/servers/sidecar/photos/routes.ts:22`). Immich's own upload and dedupe endpoints are
therefore already reachable through Officer, with Officer's auth in front and the Immich API key never
leaving the sidecar.

So the work is not "build a photo ingest service". It is: use the endpoints below, and close the three
gaps at the bottom.

---

## Topology

```
  phone app                officer                     officer-photos            Immich
  ─────────                ───────                     ──────────────            ──────
  Authorization:    →   userMiddleware (JWT)     →   allow-list by first    →   x-api-key
  Bearer <platform      createSidecarProxy           path segment +             (held ONLY
  JWT>                  injects X-Officer-User       method                     in the sidecar)
```

The phone holds a **platform JWT** — the same credential `monorepo-mobile/packages/core/src/services/
api.ts:58` already sends as `Authorization: Bearer <token>`. It never holds an Immich key and never
talks to Immich directly.

Base path for everything below: **`/api/photos/_officer/…`**, which maps to `<immich>/api/…`.
So `/api/photos/_officer/assets` is Immich's `POST /api/assets`.

---

## Endpoints

### 1. Is the library reachable at all

```
GET /api/photos/_health
→ 200 { ok: true, configured: true, account: "<label>", version: "…", ms: 42 }
→ 503 { ok: false, configured: false, error: "not connected" }
→ 502 { ok: false, configured: true, error: "…" }
```

`configured: false` means the owner has not connected an Immich account in Officer yet. Show that as a
setup prompt, not an error — it is not something the app can fix.

### 2. Ask what the server already has ← this is what makes backup cheap

```
POST /api/photos/_officer/assets/bulk-upload-check
Content-Type: application/json

{ "assets": [ { "id": "<deviceAssetId>", "checksum": "<base64 sha1 of the file bytes>" }, … ] }

→ 200 { "results": [ { "id": "…", "action": "accept" | "reject", "reason": "duplicate" | …,
                       "assetId": "<existing server id, when rejected as duplicate>" }, … ] }
```

Call this **before** uploading anything. It is the difference between a backup pass costing a few KB and
costing the whole camera roll. Batch generously — a few hundred ids per call.

`checksum` is base64-encoded **SHA-1** of the raw file bytes. Not SHA-256, not hex.

### 3. Upload one asset

```
POST /api/photos/_officer/assets
Content-Type: multipart/form-data

  assetData        the file bytes (required)
  deviceAssetId    stable per-device id for this photo (required)
  deviceId         stable id for this device (required)
  fileCreatedAt    ISO 8601 (required)
  fileModifiedAt   ISO 8601 (required)
  isFavorite       "true" | "false"
  duration         for video
  livePhotoVideoId when pairing a live photo's video part

→ 201 { "id": "<asset id>", "status": "created" }
→ 200 { "id": "<existing id>", "status": "duplicate" }
```

`deviceAssetId` + `deviceId` is the identity pair Immich dedupes on, alongside the checksum. Keep both
stable across app reinstalls if you can — if they change, the checksum still prevents duplicate _files_,
but the server will not recognise the asset as one it has already seen from this device.

### 4. Albums (optional, for "put my backups in an album")

```
GET    /api/photos/_officer/albums
POST   /api/photos/_officer/albums                  { albumName, assetIds? }
PUT    /api/photos/_officer/albums/<id>/assets      { ids: [...] }
```

### 5. Reading the library back

```
GET /api/photos/_officer/assets/<id>/thumbnail?size=preview|thumbnail
GET /api/photos/_officer/assets/<id>/original
POST /api/photos/_officer/search/metadata          { … }
GET /api/photos/_officer/timeline/…
```

Range and `If-None-Match` are forwarded, so thumbnails cache and videos seek properly.

---

## The backup loop the app should implement

1. Enumerate new local assets since the last successful run (by creation date, per album/bucket).
2. Compute SHA-1 for each; batch into `bulk-upload-check`.
3. Upload only the `accept` ones, one at a time, honouring a user setting for "wi-fi only".
4. Record the returned server id against the local asset id so step 1 can skip it next time.
5. On failure, retry with backoff. **Do not** assume a failed upload means the asset is absent — re-run
   the check first; a request can succeed server-side and fail on the way back.

Immich's own app does essentially this, which is the reason to match it rather than invent something.

---

## Gaps — what is NOT done, and who has to close it

### A. Immich is not currently connected in Officer _(owner action, blocks live testing)_

`GET /api/photos/_health` returns `configured: false` right now. The Immich container is running
(`immich_server`, port 2283) but Officer has no API key stored for it — the key used to live in `.env`
as `IMMICH_API_KEY` and was correctly removed when credentials moved into the sidecar's own storage.

To close: in Immich, Account Settings → API Keys → new key; in Officer, `/photos` → connection settings
→ paste URL + key. **Until this is done none of the endpoints above can be tested end to end**, which is
why this document describes them from the code rather than from a live capture.

### B. Upload bodies are fully buffered, twice _(server work, needs a decision)_

`createSidecarProxy` does `await ctx.req.arrayBuffer()` (`create-proxy.ts:103`) and the photos sidecar
does it again (`photos/routes.ts:93`). A 4K video is therefore held in memory twice per upload. Limits
are generous (server 50 GB, photos sidecar 4 GB) so nothing will _fail_ at phone-photo sizes, but a
backup pass over a large video library will make the platform's memory graph unpleasant.

The fix is to stream the body through instead of buffering, which touches the shared proxy factory and
so affects every sidecar. Worth doing before heavy use; not worth blocking the app on.

### C. No resumable upload _(server work, needs a decision)_

An interrupted upload restarts from zero. On mobile data that is a real cost for videos. Immich's own
app has the same limitation, so matching it is defensible for a first version — but if it turns out to
matter, this is the piece that has to be designed rather than borrowed.

---

## Things deliberately not exposed

The sidecar's allow-list refuses Immich's `admin/*`, `auth/*`, `oauth/*`, `api-keys/*`, `sessions/*`,
`jobs/*`, `system-config/*`, `system-metadata/*` and `libraries/*`. A proxy that can mint its own
credentials is not a proxy. If the app genuinely needs something behind that line, it should be a
decision, not a widened regex.
