# nineonefour.org API — keys, files, images, speech

One set of API keys gives access to three services. Each service has its own host name. Each
grant on a key opens one service (or one file root). Only HTTPS is accepted.

| Host | Service | Grant on the key | Full how-to |
|---|---|---|---|
| `service.nineonefour.org` | key requests (no key needed) | none | this page |
| `files.nineonefour.org` | file storage | a root name with `r`, `rw` or `rwd` | this page |
| `images.nineonefour.org` | text to image (Iris-3B, GPU) | `images` = `use` | `GET https://images.nineonefour.org/` |
| `speech.nineonefour.org` | text to speech (Fish-Speech Drama 3, CPU) | `speech` = `use` | `GET https://speech.nineonefour.org/` |

Contents:

1. Keys and grants
2. Key requests (`service.nineonefour.org`)
3. How to send the key
4. File API (`files.nineonefour.org`)
5. Image API (`images.nineonefour.org`), summary
6. Speech API (`speech.nineonefour.org`), summary
7. Errors

---

## 1. Keys and grants

A key looks like `mfk_<id>_<secret>`. Keep it secret. Do not put it in a URL or in a git
repository. A key has:

- **grants**: a map of scope → level.
  - a file **root** (top folder, for example `massfile`) → `r` (list and download), `rw` (also
    upload, make folders, move and copy) or `rwd` (also delete)
  - `images` → `use`
  - `speech` → `use`
- an optional **expiry**. An expired or revoked key gets `401`.

A request to a service that the key has no grant for gets `403`.

To get a key, make a key request (section 2), or ask the operator.

---

## 2. Key requests — `https://service.nineonefour.org`

No key is necessary for these requests. A key request records:

- what you send: a message, contact, label, the grants that you ask for, and device facts
- what the server sees: your IP address, your `User-Agent` header (raw text, and the browser, OS
  and device type read from it), your `Accept-Language` header, and the time

The operator reads the request and approves or refuses it. The operator decides the grants and
the expiry of the key.

### 2.1 Endpoints

| Request | Authorization | What it does |
|---|---|---|
| `POST /v1/key-requests` | none | make a request; returns a claim token |
| `GET /v1/key-requests/me` | `Bearer <claim token>` | the state of your request; after approval, your key (one time) |
| `DELETE /v1/key-requests/me` | `Bearer <claim token>` | withdraw a pending request |

### 2.2 `POST /v1/key-requests`

Request: `Content-Type: application/json`, at most 16 KB.

```json
{
  "message": "Sam from the studio. I need to upload renders and make preview images.",
  "contact": "sam@example.com",
  "label":   "sam-workstation",
  "grants":  {"massfile": "rw", "images": "use"},
  "device":  {"hostname": "studio-3", "os": "Windows 11", "model": "Dell 7920"}
}
```

| Field | Type | Necessary | Limits |
|---|---|---|---|
| `message` | string | yes | who you are and why you want a key; at most 2000 characters |
| `contact` | string | no | how to reach you; at most 200 characters |
| `label` | string | no | a name for the key, for example the device name; at most 200 characters |
| `grants` | object, string → string | no | the grants that you ask for (section 1); at most 20 |
| `device` | object | no | any facts about the device; at most 4 KB as JSON |

Response `202 Accepted`:

```json
{
  "id": "db35fbdb70c2",
  "status": "pending",
  "claim": "mfr_db35fbdb70c2_4wvX1Hqu7bn80Fhq2GdxLg6Bcup3X9d-3HHfr_jKch8",
  "createdUtc": "2026-10-15T20:10:28.6471131Z"
}
```

**Keep the claim token.** The server shows it only once and keeps only a hash of it. It is the
only way to read the state and to get the key.

One IP address can have at most 3 pending requests, and the server holds at most 100 pending
requests. After that, you get `429`.

### 2.3 `GET /v1/key-requests/me`

Request header: `Authorization: Bearer mfr_...`

Response `200 OK`:

```json
{
  "id": "db35fbdb70c2",
  "status": "issued",
  "createdUtc": "2026-10-15T20:10:28Z",
  "decidedUtc": "2026-10-15T21:02:11Z",
  "reason": "Welcome.",
  "grants": {"massfile": "rw", "images": "use"},
  "keyId": "7c1e0a9b42d3",
  "keyExpiresUtc": "2027-01-13T21:02:11Z",
  "issuedUtc": "2026-10-15T21:05:40Z",
  "token": "mfk_7c1e0a9b42d3_..."
}
```

| `status` | Meaning |
|---|---|
| `pending` | The operator has not decided. Ask again later (for example once an hour) |
| `denied` | Refused. `reason` can tell why |
| `issued` | Approved, and the key was made |

The key is made at the first `GET /v1/key-requests/me` after the approval. **Only that response
contains `token`**, your new key. Save it at once: later responses do not contain it, and the
server does not keep it. `grants` and `keyExpiresUtc` show what the operator gave.

### 2.4 `DELETE /v1/key-requests/me`

Withdraws a `pending` request. Response `204 No Content`. A request that is already decided
gets `409`.

### 2.5 Example

```sh
# 1. Ask
curl -s -X POST https://service.nineonefour.org/v1/key-requests \
     -H 'Content-Type: application/json' \
     -d '{"message":"Sam from the studio.","label":"sam-laptop","grants":{"massfile":"r"},
          "device":{"hostname":"'"$(hostname)"'","os":"'"$(uname -sr)"'"}}' | tee request.json
CLAIM=$(jq -r .claim request.json)

# 2. Later: look at the state. When it is "issued", save the token.
curl -s -H "Authorization: Bearer $CLAIM" https://service.nineonefour.org/v1/key-requests/me \
     | tee state.json | jq -r '.token // empty' > key.txt
```

---

## 3. How to send the key

Put the key in one of these headers on each request:

```
Authorization: Bearer mfk_...
X-Api-Key: mfk_...
```

---

## 4. File API — `https://files.nineonefour.org`

All paths are `{root}/{folder}/{file}`, for example `massfile/photos/2026/cat.png`. `GET /v1/roots`
tells which roots your key can use. Names that start with `.mf-` are reserved.

### 4.1 Endpoints

| Request | Query | Body | Response | Access |
|---|---|---|---|---|
| `GET /v1/roots` | | | `200` roots list | any grant |
| `GET /v1/files/` | | | `200` roots list | any grant |
| `GET /v1/files/{root}/{folder}` | | | `200` folder listing | `r` |
| `GET /v1/files/{root}/{file}` | | | `200` the file (`206` for `Range`) | `r` |
| `HEAD /v1/files/{root}/{file}` | | | headers only (size, date, `ETag`) | `r` |
| `GET /v1/files/{path}?stat=true` | `stat=true` | | `200` one entry | `r` |
| `PUT /v1/files/{root}/{file}` | `overwrite`, `parents` | the file bytes | `201` entry (new) or `200` entry (replaced) | `rw` |
| `POST /v1/mkdir/{root}/{folder}` | `parents` | | `201` entry | `rw` |
| `POST /v1/move` | | `{"from": path, "to": path}` | `200` entry at the new place | `rw` |
| `POST /v1/copy` | | `{"from": path, "to": path}` | `200` entry of the copy | `rw` |
| `DELETE /v1/files/{root}/{path}` | | | `204` | `rwd` |
| `GET /api/health` | | | `200` | no key |

Query options:

- `overwrite=true` on `PUT`: replace a file that exists. Without it you get `409`.
- `parents=true` on `PUT` and `mkdir`: make the missing folders in the path.

Notes:

- Uploads are atomic. The file appears only when the upload is complete. If an upload fails, an
  old file with that name does not change.
- If `to` of a move or copy is a folder that exists, the item goes into that folder.
- `DELETE` on a folder deletes the folder and all of its contents.
- Downloads support `Range` (continue a download) and `ETag` / `If-None-Match`.

### 4.2 Response bodies

Roots list:

```json
{"roots": [{"name": "massfile", "label": "Mass files", "access": "rw"}]}
```

Entry (a file or a folder):

```json
{"name": "cat.png", "path": "massfile/photos/cat.png", "isDir": false, "size": 48213, "modifiedUtc": "2026-10-15T16:09:27Z"}
```

Folder listing:

```json
{"path": "massfile/photos", "entries": [ {"name": "cat.png", "...": "..."} ]}
```

### 4.3 Examples

```sh
KEY=mfk_...
API=https://files.nineonefour.org/v1

curl -H "Authorization: Bearer $KEY" $API/roots                                     # what can I use?
curl -H "Authorization: Bearer $KEY" $API/files/massfile/                            # list a folder
curl -H "Authorization: Bearer $KEY" -T cat.png "$API/files/massfile/photos/cat.png?parents=true"   # upload
curl -H "Authorization: Bearer $KEY" -T cat.png "$API/files/massfile/photos/cat.png?overwrite=true" # replace
curl -H "Authorization: Bearer $KEY" -o cat.png $API/files/massfile/photos/cat.png   # download
curl -H "Authorization: Bearer $KEY" -C - -o big.iso $API/files/massfile/isos/big.iso # continue a download
curl -H "Authorization: Bearer $KEY" -X POST "$API/mkdir/massfile/photos/2026?parents=true"
curl -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
     -d '{"from":"massfile/photos/cat.png","to":"massfile/photos/2026/cat.png"}' $API/move
curl -H "Authorization: Bearer $KEY" -X DELETE $API/files/massfile/photos/2026/cat.png
```

Python:

```python
import requests
api = "https://files.nineonefour.org/v1"
h = {"Authorization": f"Bearer {key}"}
with open("cat.png", "rb") as f:
    requests.put(f"{api}/files/massfile/photos/cat.png", data=f, headers=h,
                 params={"parents": "true"}).raise_for_status()
data = requests.get(f"{api}/files/massfile/photos/cat.png", headers=h).content
```

---

## 5. Image API — `https://images.nineonefour.org` (summary)

Needs the `images` grant. The full how-to is at `GET https://images.nineonefour.org/`. The model
is released after 5 minutes with no work; the first request after that loads it first (1 to 6
minutes).

| Request | Body | Response |
|---|---|---|
| `POST /v1/generate` | image body (below) | `200` the image (waits until it is done) |
| `POST /v1/jobs` | image body | `202` `{"id", "status": "queued", "position"}` |
| `GET /v1/jobs/{id}` | | `200` `{"id", "status", "image_url", ...}`; `status` is `queued`, `running`, `done` or `error` |
| `GET /v1/jobs/{id}/image` | | `200` the image; `409` until it is done |
| `GET /v1/sizes` | | `200` the trained sizes |
| `GET /v1/status` | | `200` service status (`loaded`: the model is in memory) |

Image body: `{"prompt": "...", "model": "turbo"|"base", "aspect_ratio": "16:9", "width": 1024,
"height": 1024, "seed": 1, "format": "png"|"jpeg"|"webp", "quality": 95, "response": "image"|"json",
"negative_prompt": "...", "steps": 100, "cfg_scale": 3.0}`. Only `prompt` is necessary.

```sh
curl -H "Authorization: Bearer $KEY" -H 'Content-Type: application/json' \
     -d '{"prompt":"a red fox in snow, morning light","aspect_ratio":"16:9"}' \
     -o fox.png https://images.nineonefour.org/v1/generate
```

---

## 6. Speech API — `https://speech.nineonefour.org` (summary)

Needs the `speech` grant. The full how-to is at `GET https://speech.nineonefour.org/`. Speech is
slow (it runs on the CPU), so each request is a job. The model is released after 5 minutes with no
work; loading it again takes about 5 minutes.

| Request | Body | Response |
|---|---|---|
| `POST /v1/jobs` | speech body (below) | `202` `{"id", "status": "queued", "position", ...}` |
| `GET /v1/jobs` | | `200` `{"jobs": [...]}` your jobs |
| `GET /v1/jobs/{id}` | | `200` `{"id", "status", "audio_url", "seconds", ...}` |
| `GET /v1/jobs/{id}/audio` | | `200` the audio; `409` until it is done |
| `DELETE /v1/jobs/{id}` | | `204`; only a finished job |

Speech body: `{"text": "...", "format": "wav"|"mp3"|"opus"|"pcm", "seed": 1, "single_chunk": false,
"chunk_length": 200, "normalize": true, "max_new_tokens": 1024, "top_p": 0.7, "temperature": 0.7,
"repetition_penalty": 1.2}`. Only `text` is necessary (at most 5000 characters). Other fields get
`400`. Finished jobs are kept for one hour.

```sh
curl -H "Authorization: Bearer $KEY" -H 'Content-Type: application/json' \
     -d '{"text":"Hello there.","format":"mp3"}' https://speech.nineonefour.org/v1/jobs
```

---

## 7. Errors

Errors are JSON (`application/problem+json`) with a `detail` text that tells what is wrong.

| Code | Meaning |
|---|---|
| `400` | Bad request (the `detail` tells which field), or plain HTTP was used |
| `401` | No key, or the key is unknown, expired or revoked. For key requests: no claim token, or an unknown one |
| `403` | The key does not have the necessary grant or access, or the path is not permitted |
| `404` | Nothing is at that path, or the job is unknown or expired |
| `409` | The file exists (use `overwrite=true`), a file and a folder have the same name, a job is not done, or a key request is already decided |
| `413` | The body is larger than the limit |
| `422` | An image field is not correct |
| `429` | Too many jobs or key requests wait. Try again later |
| `502` | A model service did not answer |
