# PostMD — API reference

**Covers** — every endpoint under `/api/v1`: authentication, the response envelope, error
codes, and the calls for documents, groups, notes, sharing, API keys and sessions.

**Not here** — what the terms mean and what the rules are is in [/docs/service](/docs/service);
read that first if a term below is unfamiliar. Graph data goes inside the document, not through
an endpoint, and is in [/docs/graph](/docs/graph). The map is [/llms.txt](/llms.txt).

Base URL: `/api/v1`
Machine-readable spec: [/api-docs](/api-docs)

New to the service? Read the [service overview](/docs/service) first. It explains
documents, groups, permissions and scopes, which this reference assumes you know.

## Authentication

**Publishing a document and reading one by its code need no credential.** Those
are the endpoints most integrations start with, and they work with an empty
`Authorization` header. Updating, deleting and edit mode need none either for a document
without a password; one with a password takes it in the `X-Document-Password` header, or is
open to its owner. Uploading several files at once, groups, notes and a member's own lists
identify you first.

Two MCP servers wrap these endpoints for clients that speak MCP. One is hosted at
`https://postmd.turink.com/mcp`, needs no install and covers publishing, reading, updating and
deleting. The other runs locally with `npx -y postmd-mcp-server` and adds the tools that need an
API key. Either is the practical route where a sandbox blocks arbitrary network calls but allows
registered tools.

Send your credential in the `Authorization` header as a bearer token.

```
Authorization: Bearer pmk_EXAMPLEKEYEXAMPLEKEY...
```

PostMD decides how to read the credential by looking at its prefix:

- A value starting with `pmk_` is treated as an **API key**.
- Any other value is treated as a **member access token** (issued by signing in
  through the web app).

Both reach the same endpoints. API keys are the intended credential for
integrations.

### What an API key cannot do

Some endpoints reject API keys and require a signed-in member session:

- Account and API key management

These are actions a person performs in the browser. An API key calling them
receives `403`.

### Scopes

An API key may only perform what its scopes allow.

| Scope | Allows |
|---|---|
| `documents:read` | Reading documents |
| `documents:write` | Uploading, updating and deleting documents |
| `groups:read` | Reading groups |
| `groups:write` | Creating, updating and deleting groups |

**Read and write are independent.** `documents:write` does not include
`documents:read`. Grant every scope your integration needs.

Each endpoint below states the scope it takes. Where an endpoint needs no credential, the
scope still applies to a key that is sent: a key without it is refused with `403` and
`E_ACC_0002` rather than ignored, and an expired or revoked key answers `401` with `E_ACC_0001`.
Send no `Authorization` header at all to call such an endpoint anonymously.

## Response format

Successful and failed JSON responses share one envelope:

```json
{
  "resultCode": "200",
  "message": null,
  "data": { }
}
```

| Field | Meaning |
|---|---|
| `resultCode` | Outcome of the call. `200` means success. **Branch on this value.** |
| `message` | English text describing the outcome. Empty on most successes. |
| `data` | The payload. Absent when the call fails. |

Two points to be clear about:

- **`message` is always English.** It carries the reason a call failed, and on a
  few successful calls it carries a fact the caller needs. It is empty otherwise.
- **One endpoint does not use this envelope when it succeeds.**
  `GET /documents/{docCode}/raw` returns the markdown file itself as `text/markdown`.
  When it fails it answers like every other endpoint, with the JSON envelope and a
  `resultCode`, so branch on the content type or on the HTTP status rather than
  assuming the body is markdown.

### Error codes

Every failure carries an HTTP status and a `resultCode`.

General codes mirror the HTTP status: `400`, `401`, `403`, `404`, `409`, `500`.
They mean what the status means and carry no extra detail. Trying to change or delete an
official document (`docs@turink.com`) answers the general `403`.

Codes beginning with `E_` identify one specific situation. Handle these when you
want to react to a particular cause.

| resultCode | HTTP | Situation |
|---|---|---|
| `E_AUTH_0001` | 401 | Email or password is wrong, or too many failed attempts |
| `E_AUTH_0002` | 403 | Account is suspended |
| `E_AUTH_0003` | 403 | Account has been withdrawn |
| `E_AUTH_0004` | 409 | Email is already registered |
| `E_AUTH_0005` | 401 | Refresh token is invalid or expired |
| `E_AUTH_0006` | 401 | A refresh token was reused; the sessions from that sign-in were revoked |
| `E_AUTH_0007` | 403 | Account is within its withdrawal grace period |
| `E_AUTH_0008` | 401 | The supplied current password is wrong |
| `E_ACC_0001` | 401 | API key is invalid, expired or revoked |
| `E_ACC_0002` | 403 | API key lacks a scope this endpoint requires |
| `E_ACC_0003` | 400 | Requested key validity is more than one year |
| `E_DOC_0001` | 404 | No such document, or it was deleted |
| `E_DOC_0002` | 401 | Document is password-protected and no password was sent |
| `E_DOC_0003` | 401 | Supplied document password is wrong |
| `E_DOC_0005` | 400 | Uploaded file is missing or is not a `.md` file |
| `E_DOC_0006` | 400 | The upload is not a well-formed `multipart/form-data` request, or a single upload carries more than one file |
| `E_DOC_0007` | 400 | Upload exceeds the size limit |
| `E_DOC_0009` | 400 | `notesOnReplace` missing or unknown on a call that sends `file` |
| `E_DOC_0010` | 409 | `notesOnReplace=abort` and the document has notes anchored to its text |
| `E_DOC_0011` | 409 | Another caller is editing this document |
| `E_DOC_0012` | 403 | The caller does not hold this document's edit session |
| `E_DOC_0013` | 409 | There is no edit session or working copy for this document |
| `E_DOC_0014` | 409 | The published document changed after edit mode started |
| `E_DOC_0015` | 400 | A bulk upload carries more than 100 files |
| `E_DOC_0016` | 400 | The write would take the owner over their storage quota |
| `E_DOC_0017` | 403 | `images` sent on an anonymous document, or by a caller who is not the owning member |
| `E_DOC_0018` | 400 | The document would use more than 30 images or more than 10 MB of them |
| `E_DOC_0019` | 400 | An image is not PNG, JPEG, GIF, WebP or SVG |
| `E_GRP_0001` | 404 | No such group, or it was deleted |
| `E_GRP_0002` | 400 | The default group cannot be modified or deleted |
| `E_NOTE_0001` | 404 | No such note on this document — including another member's `PRIVATE` note |
| `E_NOTE_0002` | 400 | A note needs text or a colour, and a colour-only highlight needs a quoted passage |
| `E_NOTE_0003` | 403 | Notes are not available on this document (nobody owns it) |
| `E_NOTE_0004` | 403 | `PRIVATE` was requested on a document the caller does not own |
| `E_RATE_0001` | 429 | Too many writes in a short time — at present, more than 60 notes in ten minutes |

## Conventions

**Dates** in requests and responses use `yyyyMMdd`, for example `20261201`.
A date is inclusive: an expiry of `20261201` remains valid until the end of that
day. A date has no time of day, so PostMD reads it as a date in **Asia/Seoul**,
where the service runs. `20261201` therefore ends at `2026-12-01T23:59:59+09:00`.

**Timestamps** are UTC and carry the `Z` marker. Fractional seconds are present and their
length varies, so `2026-08-08T11:23:58Z`, `2026-08-08T11:23:58.800272Z` and
`2026-08-08T11:23:58.065775661Z` are all shapes you will receive. Parse with a reader that
accepts a variable fraction rather than a fixed width, and convert to your reader's zone for
display.

**Upload size** depends on who owns the document: up to 1 MB for an anonymous document and 20 MB for a
member's. Updating a document follows its owner, not the caller. A member's documents together may use up to
50 MB of storage — published text and edit-mode working copies; deleted documents do not count. A write
that would go over is rejected with `E_DOC_0016`; one that shrinks a document is always accepted. See
"Storage used" under Account. A file name may be up to 255 characters; a longer one is rejected with `400`.

**Paged lists.** Endpoints that can grow — groups, documents in a group — return
one page at a time. Two optional parameters control it:

| Parameter | Meaning | Default |
|---|---|---|
| `page` | Page number, starting at 1 | `1` |
| `size` | Items per page | `20` (maximum `100`) |

Out-of-range values are adjusted rather than rejected: `page=0` is treated as
page 1, and `size=500` is capped at 100.

Paged responses carry a `pagination` object next to `data`:

```json
{
  "resultCode": "200",
  "pagination": { "totalItemCount": 25, "pageItemCount": 20, "pageNo": 1 },
  "data": [ ]
}
```

`totalItemCount` is the number of items matching your query — including any
filter such as `q` — and `pageItemCount` is the page size, so the number of
pages is `ceil(totalItemCount / pageItemCount)`. Requesting a page past the end
returns an empty `data` array rather than an error.

To read everything, request successive pages until `data` comes back empty.

API key listings are not paged, since a member holds only a handful.

**Writes use POST.** Updates and deletions are POST requests to an action path
such as `/update` or `/delete`, not `PUT` or `DELETE`.

---

# Endpoints

## Documents

Graph data has no endpoint of its own. It sits in the markdown file as an HTML comment, so the
calls below carry it like any other part of the content. Uploading a document with a graph in it
is an ordinary upload. Adding one to an existing document is an ordinary update, or a save in edit
mode when the person should see it before readers do. The format and the whole procedure are in
[Graph data](/docs/graph).

### Upload a document

```
POST /api/v1/documents
Content-Type: multipart/form-data
```

**This endpoint needs no credential.** Anyone can publish. Send an API key only if
you want to manage the document later; it must then carry `documents:write`.

| Field | Required | Description |
|---|---|---|
| `file` | yes | The `.md` file. Other file types are rejected. |
| `title` | no | Up to 100 characters; a longer title is rejected with `400`. Defaults to the file name without its `.md` extension, cut to 100 characters. |
| `password` | no | Readers must supply this password to see the content. If not set, anyone can update or delete the document. |
| `viewerStyle` | no | `readable` (default), `github`, `minimal`, `report`, `pamphlet` or `dark`. An unknown value falls back to `readable`. |
| `groupId` | no | File the document in this group. Omit it and the document goes into your default group. |
| `images` | no | An image file the markdown references by a local path. Repeat once per image. Members only — see "Images from your computer" |
| `imagePaths` | with `images` | The path exactly as the markdown writes it, one per `images` part, in the same order |

```json
{
  "resultCode": "200",
  "data": {
    "docCode": "P-889-419-571",
    "shareUrl": "https://postmd.turink.com/share/P-889-419-571",
    "viewerUrl": "https://postmd.turink.com/d/P-889-419-571",
    "anonymous": false,
    "retainedUntil": "2026-10-20T05:03:59Z"
  }
}
```

Store `docCode`. It identifies the document in every later call. `shareUrl` is the
address to hand out and `viewerUrl` is what opens when the reader follows it. Both
arrive assembled, so there is nothing to concatenate and no way to confuse the two.

The same fields come back from `POST /api/v1/documents/{docCode}/update` when you
update a document.

**Group placement.** `groupId` names the group the document goes into. Omit it and
the document goes into the uploader's default group.

**Retention period.** All documents except official ones carry a 30-day retention deadline,
members' documents as much as anonymous ones, and every upload answer includes it as
`retainedUntil`. Changing the document resets the deadline to 30 days from then, and reading the
body extends it to 30 days from then (at most once per day). Saving a working copy in edit mode
does not extend it; applying the working copy does.

**Uploading without a credential.** The document belongs to an internal anonymous account,
and the answer carries a `message` stating the deadline and who may change the document.

```json
{
  "resultCode": "200",
  "message": "Published anonymously. This document is kept until 2026-10-20T05:03:59Z and extends to 30 days from each read or change. Without a password, anyone holding its code can update or delete it; with one, the password is required.",
  "data": {
    "docCode": "P-889-419-571",
    "shareUrl": "https://postmd.turink.com/share/P-889-419-571",
    "viewerUrl": "https://postmd.turink.com/d/P-889-419-571",
    "anonymous": true,
    "retainedUntil": "2026-10-20T05:03:59Z"
  }
}
```

| Field | Meaning |
|---|---|
| `anonymous` | `true` when the upload carried no credential. Present on every upload answer |
| `retainedUntil` | The document is deleted after this instant unless a read or a change extends it. Absent only for official documents |
| `images` | Present only when the request carried `images`. See below |

#### Images from your computer

A markdown file that shows a picture by a local path (`![chart](./img/chart.png)`,
`<img src="chart.png">`) can be published together with that picture. Send each file as an `images`
part and, in the same order, the path exactly as the file writes it as an `imagePaths` field:

```
curl -H "Authorization: Bearer pmk_..." \
  -F file=@report.md \
  -F images=@img/chart.png -F imagePaths=./img/chart.png \
  https://postmd.turink.com/api/v1/documents
```

PostMD stores the picture and, in the stored text, replaces that path — and nothing else — with the
picture's address, such as `https://postmd.turink.com/uploads/q3Zb8kV1nR7xT2mP4sLw9a.png`. Your own
file is not changed. A path is replaced only where the text has it character for character; images
written as web addresses (`https://...`) or `data:` URIs are left alone and not counted. Paths in code
blocks are never touched.

- **Members only.** Images are accepted only when the call is made by the member who owns the
  document (signed in or with an API key). An anonymous upload with `images` is refused with
  `E_DOC_0017`, as is a call without that member's credential on someone else's document.
- **Limits.** A document may use up to 30 images totalling up to 10 MB, counted over every image the
  text uses after the change (`E_DOC_0018`). The same picture used twice counts once. Images count
  towards the member's storage quota.
- **Formats.** PNG, JPEG, GIF, WebP and SVG, judged from the bytes (`E_DOC_0019` otherwise; HEIC and
  AVIF are refused by name). Location and similar metadata is removed from photos; orientation is kept.
- **The answer** carries `images`: `replaced` (each path and its new address), `unmatched` (local
  paths in the text that no file was sent for, left as they were), `unused` (files whose path is not in
  the text, not stored) and `unsupported` (local images written with `srcset` or `![[...]]`, which are
  not rewritten). `message` states what was replaced.
- **Removal.** An image the text no longer uses is deleted when the document changes. A deleted
  document's images stop opening at once.

**Images of a password-protected document** open only with a token, because an `<img>` cannot send
the password header. Reading the document returns one: `imageToken` in `GET /{docCode}` and the
`X-Image-Token` header of `GET /{docCode}/raw`. Append it as `?t=` to the image address. A token is
valid for 12 hours. Images of a document without a password need no token.

If no password was set on the document, anyone (including anonymous callers) can update or delete it.
If a password was set, updates and deletions require the authenticated document owner or the `X-Document-Password` header.

#### Keeping the code in the file

A markdown file can carry the code of the document it was published as, in an HTML comment
whose first line ends with the marker `postmd:document`:

```
<!-- postmd:document
This file was published on PostMD as document P-889-419-571.
shareUrl: https://postmd.turink.com/share/P-889-419-571
Guide for agents: https://postmd.turink.com/llms.txt
-->
```

It sits at the top of the file, after YAML front matter if there is any. The viewer does not
show it and the server stores it as part of the content. An agent that opens the file later
can tell which document it belongs to without being told.

It holds only what stays fixed for the document's life: the code and the address. Whether a
password is set and how long the document is kept come from the API when needed.

The code exists only once an upload answers, so the comment goes into the file after an upload
or an update made from it, and reaches the stored copy with the next update. A file downloaded
from the viewer already carries it: the viewer writes the comment for the document being
downloaded, in place of any such comment at the top of the file.
`GET /api/v1/documents/{docCode}/raw` returns the stored content unchanged.

A file published again as a new document takes the new code; leaving the old comment out of that
upload keeps the old code out of the new document. Once the document is deleted, or a call with its code returns
`E_DOC_0001`, the comment points nowhere.

### Upload several documents at once

```
POST /api/v1/documents/bulk
Content-Type: multipart/form-data
```

**This endpoint needs a credential**, unlike a single upload: an API key with `documents:write`,
or a signed-in member. The documents belong to that member.

Send one `files` part per document, up to 100 in one request. More than that is rejected with
`E_DOC_0015` and nothing is published; send the rest in another request. `password`, `viewerStyle`
and `groupId` may be given once and apply to every file. Each title comes from its file name, cut to
100 characters.

Files are processed one by one, and a failure does not stop the others:

```json
{
  "resultCode": "200",
  "data": {
    "total": 3,
    "succeeded": 2,
    "results": [
      { "fileName": "a.md", "success": true,  "docCode": "P-411-752-212",
        "resultCode": "200", "message": null },
      { "fileName": "b.md", "success": true,  "docCode": "P-478-329-581",
        "resultCode": "200", "message": null },
      { "fileName": "c.txt", "success": false, "docCode": null,
        "resultCode": "E_DOC_0005", "message": "Only .md files can be uploaded" }
    ]
  }
}
```

The outer `resultCode` is `200` even when some files failed. Check `succeeded`
and each entry's `success` field.

### Read a document

Three endpoints return different amounts of the same document.

```
GET /api/v1/documents/{docCode}          # settings and content
GET /api/v1/documents/{docCode}/meta     # settings only, no content
GET /api/v1/documents/{docCode}/raw      # the markdown file, nothing else
```

These three read a single document by its code, and none of them needs a
credential — a shared link has to open for whoever receives it. A key sent with the call must
carry `documents:read`; a key with only `documents:write` is refused, so an integration that
reads and writes needs both scopes, or can read without sending its key. The scope matters for
listing a member's documents, which is not public.

Use `/raw` when you want the file as uploaded — with the local image paths that were sent with it
replaced by their addresses (see "Images from your computer") — it returns
`text/markdown` with no JSON envelope. Use `/meta` when you only need the title
or settings and want to avoid transferring the content.

Reading the document body (`GET /{docCode}` or `GET /{docCode}/raw`) automatically
extends the document's 30-day retention deadline to 30 days from now (performed at most
once a day when remaining retention is under 29 days).

**Password-protected documents.** Send the password in a header:

```
X-Document-Password: <password>
```

Without it the call fails with `E_DOC_0002`; with a wrong value, `E_DOC_0003`.
The document's authenticated owner does not need the header.

### Update a document

Two calls change a document's content. This one replaces it at once, so the next reader sees the
new text. Edit mode below keeps a working copy on the server instead, and the published text
stays as it was until someone applies it.

Replacing at once suits a change the person has already seen: a typo they pointed at, a file they
handed you to publish under the same code. A working copy suits a change they have not seen yet —
one you worked out from reading the document, one that will take a few attempts, one where being
wrong in public costs more than the extra call.

```
POST /api/v1/documents/{docCode}/update
Content-Type: multipart/form-data
```

Scope: `documents:write` (if calling with an API key).

**Permission rules:**
- Official documents (`docs@turink.com`) cannot be modified or deleted.
- If the caller is the authenticated owner of the document, the modification is allowed.
- If the document has no password, anyone (including anonymous callers) may update it.
- If the document has a password, the `X-Document-Password` header must be provided with the correct password.

| Field | Effect |
|---|---|
| `title` | Replaces the title. Up to 100 characters; a longer title is rejected with `400` |
| `password` | Sets a new password |
| `clearPassword` | `true` removes the password entirely |
| `viewerStyle` | Replaces the viewer theme |
| `file` | Replaces the document content with a new `.md` file |
| `images`, `imagePaths` | Images the new file references by local paths, as on upload. Only with `file`, only by the owning member |
| `notesOnReplace` | `keep` or `abort`. Required whenever `file` is sent |

Fields you omit are left unchanged. To remove a password you must send `clearPassword=true` — sending an empty value does not clear it.

#### Replacing content when the document has notes

Notes are located by the text they quote, not by a stored position. Replacing the
body therefore moves or loses where they point: a note whose quote no longer
appears loses its place in the body, and a note whose quote now appears somewhere
else points there instead. The note itself, including the text it quoted, is kept
either way.

`notesOnReplace` is required on every call that sends `file`, with or without notes
on the document.

| Value | Effect |
|---|---|
| `keep` | Replaces the content and leaves the notes as they are |
| `abort` | Refuses with `E_DOC_0010` if the document has notes anchored to its text |

Omitting it, or sending anything else, fails with `E_DOC_0009`. Metadata-only calls
— no `file` — do not take it.

A successful replace of a document that had anchored notes answers with `message`
saying so and how many there were. Notes without a quote are not counted: they are
attached to the document rather than to a sentence, so replacing the body leaves
nothing for them to lose.

### Edit mode

Edit mode holds a **working copy** of a document on the server. The published document does not
change while it exists. When the working copy is published, it replaces the published content in
one step.

```
POST /api/v1/documents/{docCode}/edit/start
POST /api/v1/documents/{docCode}/edit/content
GET  /api/v1/documents/{docCode}/edit/content
GET  /api/v1/documents/{docCode}/edit/status
POST /api/v1/documents/{docCode}/edit/publish
POST /api/v1/documents/{docCode}/edit/discard
POST /api/v1/documents/{docCode}/edit/takeover
```

`POST /edit/content` takes `images` and `imagePaths` the same way as an upload, for images the working
copy references by local paths.

Entering edit mode needs the same permission as updating the document. Official documents cannot
be entered; a document with a password needs the `X-Document-Password` header. No credential is
needed otherwise. A key sent with these calls needs `documents:write`, and `documents:read` for
`GET /edit/content`.

**One caller at a time.** `start` takes the document for an hour. A caller signed in or using an
API key is recognised by that; anyone else receives a session token in the answer and sends it
back in an `X-Edit-Session` header on every later call.

```json
{"resultCode":"200","data":{
  "editSession":"3f2a...",
  "expiresAt":"2026-09-22T09:00:00Z",
  "hasDraft":false,
  "draftUpdatedAt":null,
  "baseUpdatedAt":"2026-09-22T07:14:16Z"}}
```

`editSession` is `null` for a member or an API key, which are identified without it. `hasDraft`
is `true` when a working copy from an earlier session is still there; it is handed over as it is,
to continue or to discard. `GET /edit/content` returns it, so read it before writing a new one
over it.

**Hand the person a link carrying the session.** The token is yours, not theirs, so a viewer
opened without it shows the published document and a notice that someone is editing. Append the
token to the viewer address and the browser takes it, stores it for that tab and removes it from
the address bar.

This works only for a caller that entered without a credential, because only that caller gets a
token to hand over. Entered with an API key, the hold belongs to the key, and the member it
belongs to does not see the working copy in the browser. To show a person the working copy,
enter without the key; a document with a password still takes the `X-Document-Password`
header.

```
https://postmd.turink.com/d/{docCode}?edit=3f2a...
```

That is what makes the working copy visible to the person who asked for the change. Give them
that link once, after `start`.

**The hour is extended by saving, not by looking.** Each `POST /edit/content` sets it back to an
hour. `GET /edit/status` does not, so an open browser tab cannot hold a document indefinitely.

**Expiry releases the exclusive hold and keeps the working copy.** Nothing is deleted when the
hour runs out. If nobody else has entered in the meantime, the original caller's next save takes
the document back, and calling `start` again returns the same `editSession`, so the address
already handed over keeps working.

**Another caller can ask for it.** `POST /edit/takeover` shortens the holder's remaining time to
ten minutes; if the holder saves within those ten minutes they keep it and the request is
cancelled. The document's owner takes it immediately. A document nobody holds is granted at once.

| Field | Meaning |
|---|---|
| `granted` | `true` when the caller now holds the document |
| `editSession` | Present when `granted` is `true` and the caller has no credential: the token to send from now on |
| `expiresAt` | Present when `granted` is `true` |
| `waitUntil` | Present when `granted` is `false`. The holder loses it after this instant unless they save |

**`POST /edit/content`** is `multipart/form-data` with one field, `file`, and answers with
`draftUpdatedAt` and `expiresAt`. **`GET /edit/content`** returns the working copy as
`text/markdown` with no envelope, to the holder only.

**`GET /edit/status`** needs whatever reading the document needs, and no more, so a viewer can
show that someone is editing. It never returns the working copy's timestamp to anyone but the
holder.

```json
{"resultCode":"200","data":{
  "editing":true,
  "mine":false,
  "expiresAt":"2026-09-22T09:00:00Z",
  "takeoverRequested":false,
  "draftUpdatedAt":null}}
```

**`POST /edit/publish`** replaces the published content with the working copy. In the viewer a
person does this with the **Apply to document** button, `문서에 반영` in Korean. The endpoint is
called publish; that word appears nowhere on their screen. It is `multipart/form-data` and
always takes `notesOnReplace` (`keep` or `abort`), since applying always replaces the body, and
`overwriteChanged=true` for the case below. On success the working copy and the hold are gone and
the answer matches an update.

```
curl -X POST https://postmd.turink.com/api/v1/documents/{docCode}/edit/publish \
  -H "X-Edit-Session: 3f2a..." \
  -F notesOnReplace=keep
```

**`POST /edit/discard`** throws the working copy away and releases the hold.

**While a document is held, an ordinary `POST /update` from anyone else fails with
`E_DOC_0011`.** The permission check runs first, so a caller without the password still gets the
password error. Deleting is not blocked: a document deleted while held takes its working copy
with it.

**If the published document changed after edit mode started**, publishing fails with
`E_DOC_0014` rather than silently dropping that change. Send `overwriteChanged=true` to publish
anyway.

| Code | HTTP | Meaning |
|---|---|---|
| `E_DOC_0011` | 409 | Another caller is editing this document |
| `E_DOC_0012` | 403 | Not the holder of this document's edit session |
| `E_DOC_0013` | 409 | No edit session or working copy for this document |
| `E_DOC_0014` | 409 | The published document changed after the session started |

A working copy untouched for seven days is deleted, and so is one belonging to a document that
reaches the end of its retention. A document holds one working copy; there is no history and no
way back to an earlier one.

### Delete a document

```
POST /api/v1/documents/{docCode}/delete
```

Scope: `documents:write` (if calling with an API key).

**Permission rules:**
- Official documents (`docs@turink.com`) cannot be modified or deleted.
- If the caller is the authenticated owner of the document, deletion is allowed.
- If the document has no password, anyone (including anonymous callers) may delete it.
- If the document has a password, the `X-Document-Password` header must be provided with the correct password.

The document immediately stops being served and disappears from group listings.
There is no endpoint to undo this.

### Move a document to another group

```
POST /api/v1/documents/{docCode}/group
{ "groupId": 14 }
```

Scope: `documents:write`. Owner only, and you must own the target group.

A document belongs to at most one group, so this replaces the group it was in.
The document itself is untouched: its `docCode` and viewer URL keep working.

## Groups

Groups provide personal classification for a member's documents.

### List your groups

```
GET /api/v1/groups?page=&size=
```

Scope: `groups:read`. Paged.

Returns your personal groups.

### Create, update and delete a group

```
POST /api/v1/groups
{ "name": "Design docs" }

POST /api/v1/groups/{groupId}/update
{ "name": "New name" }

POST /api/v1/groups/{groupId}/delete
```

Scope: `groups:write`. Owner only.

Your default group cannot be renamed or deleted; either attempt returns `E_GRP_0002`.

Deleting any other group removes the group only. The documents in it move to your default group
and keep their codes and addresses.

### List documents in a group

```
GET /api/v1/groups/{groupId}/documents?page=&size=&sort=
```

Scopes: `groups:read` and `documents:read`. Paged. Owner only.

| Parameter | Effect |
|---|---|
| `q` | Search the title and file name for this text |
| `sort` | Ordering, see the table below |
| `page`, `size` | Page selection, as described under Conventions |

`q` must be URL-encoded. Searching is a substring match and is not case
sensitive. When `q` is given, `totalItemCount` counts the matches rather than
every document in the group.

**Ordering.** The whole result set is ordered on the server before it is split
into pages, so paging through the list never repeats or skips a document.

| `sort` | Order |
|---|---|
| `recent` (default) | Most recently updated first |
| `oldest` | Least recently updated first |
| `name` | Title A→Z |
| `name_desc` | Title Z→A |
| `created` | Most recently uploaded first |
| `created_asc` | Oldest upload first |

An unrecognised `sort` value falls back to `recent` rather than returning an
error.

## Notes

A note is text a member attaches to a document, optionally anchored to a quoted
passage of the body. A highlight is the same object with a colour and no text.
One object, two uses.

Notes require a signed-in identity: an API key acts as its owning member. On a
password-protected document, pass the same `X-Document-Password` header as when
reading the body.

**Notes live only on documents a person owns.** Anonymously published documents
and service-owned pages take no notes at all — `E_NOTE_0003`. Nobody there can act
on what gets left behind.

**Visibility follows ownership, not a request field.** On your own document you
choose: `PRIVATE` is visible to you alone, `SHARED` to everyone who can read the
document. On someone else's document every note is `SHARED`, so omit `scope`
there. Sending `PRIVATE` on a document you do not own fails with `E_NOTE_0004`
rather than automatically publishing it as shared.

A highlight is the same object, so a `PRIVATE` highlight is invisible to everyone
but its author.

### List notes on a document

```
GET /api/v1/documents/{docCode}/notes
```

Scope: `documents:read`. Returns the caller's own notes plus all `SHARED` notes,
newest first. Each row carries `mine` (the caller wrote it) and `manageable`
(the caller may resolve or delete it: the author, or the document owner for
`SHARED` notes). Clients should trust these flags instead of re-deriving the
permission rules.

Each row also carries `noteNo`, the note's number within its document, starting at
1. It is assigned when the note is created and never changes, so deleting a note
leaves a gap rather than renumbering the rest. The viewer prints it beside the
highlighted passage so a reader can pair a mark with its note by eye.

### Add a note or highlight

```
POST /api/v1/documents/{docCode}/notes
```

Scope: `documents:write`.

| Field | Meaning |
|---|---|
| `content` | The note text, up to 4000 characters. Omit for a colour-only highlight |
| `quotedContent` | The passage the note points to, up to 4000 characters. Omit to attach the note to the document as a whole |
| `scope` | `PRIVATE` or `SHARED`, and only on your own document. Omit it and ownership decides |
| `color` | `YELLOW`, `GREEN`, `BLUE` or `PURPLE`. Unknown values count as no colour |
| `textStart` | Character offset where the quote starts in the body. Optional; speeds up re-anchoring |

At least one of `content` and `color` is required, and a colour-only highlight
also needs `quotedContent` — a highlight must point at something. The quote is
matched against the body by text, so it survives edits elsewhere in the
document; if the quoted passage itself is edited away, the note keeps its text
but loses its anchor.

### Update, resolve and delete

```
POST /api/v1/documents/{docCode}/notes/{noteId}/update
POST /api/v1/documents/{docCode}/notes/{noteId}/resolve
POST /api/v1/documents/{docCode}/notes/{noteId}/delete
```

Scope: `documents:write`. Only the author can update. `update` takes the same
body as creation; omitting `scope` keeps the current one, and the ownership rule
above applies to any `scope` you do send. `resolve` takes
`{"resolved": true}` (or `false` to undo) and marks a discussion as settled —
it is meaningful on `SHARED` notes, and the author or the document owner may
set it. Delete follows the `manageable` rule above.

### List your notes across documents

```
GET /api/v1/notes
```

Scope: `documents:read`. Every note the caller wrote, with `docCode` and
`documentTitle` alongside each one, so an agent can jump from a note back to
its document.

## Sharing

```
GET /share/{docCode}
```

Returns a small HTML page whose Open Graph tags carry the document title, so
chat apps and social sites can show a preview. For the preview crawlers of the chat apps and
social sites it recognises, the description is the opening of the document's text; any other
caller gets a fixed description. A browser opening this URL is redirected to the viewer.

Password-protected documents reveal neither their title nor their text here — the preview shows
the service name and a generic description instead.

## Account and API keys

These endpoints require a signed-in member session. **API keys receive `403`**,
including for reading the key list. The one exception is "Storage used", which an API key with
`documents:read` may call.

### Storage used

```
GET /api/v1/account/storage
```

```json
{ "resultCode": "200", "data": { "usedBytes": 2228497, "quotaBytes": 52428800 } }
```

`usedBytes` is what your documents take up now, as stored. `quotaBytes` is your limit.

### List available scopes

```
GET /api/v1/account/api-key-scopes
```

Returns the scopes that can be granted, with a short description of each.

### List your API keys

```
GET /api/v1/account/api-keys
```

Returns keys that have not been revoked. Expired keys are included so you can
see that they need replacing.

```json
{
  "resultCode": "200",
  "data": [
    {
      "id": 11,
      "name": "publishing-bot",
      "description": "CI pipeline",
      "keyPrefix": "pmk_EXAMPLEK",
      "scopes": ["documents:read", "documents:write"],
      "expireDate": "20261201",
      "status": "ACTIVE",
      "createdAt": "2026-08-08T11:23:58Z"
    }
  ]
}
```

`status` is `ACTIVE` or `EXPIRED`. Revoked keys are not listed at all.
`keyPrefix` is the first 12 characters of the key, enough to recognise which key
an entry refers to.

### Issue an API key

```
POST /api/v1/account/api-keys
Content-Type: application/json

{
  "name": "publishing-bot",
  "description": "CI pipeline",
  "scopes": ["documents:read", "documents:write"],
  "expireDate": "20261201"
}
```

`expireDate` is required and may be at most one year from today. A longer period
fails with `E_ACC_0003`.

The response contains the key:

```json
{
  "resultCode": "200",
  "data": {
    "apiKey": "pmk_EXAMPLEKEYEXAMPLEKEYEXAMPLEKEYEXAMPLEKEYEXA",
    "info": { "id": 11, "keyPrefix": "pmk_EXAMPLEK", "status": "ACTIVE" }
  }
}
```

**This is the only time `apiKey` is returned.** PostMD stores a hash of it and
cannot show it again. Save it before discarding the response.

An issued key cannot be edited. To change its name or scopes, revoke it and
issue a new one.

### Revoke an API key

```
POST /api/v1/account/api-keys/{id}/revoke
```

The key stops working immediately and no longer appears in the key list.

### Highlight colour names

```
GET  /api/v1/account/note-colors
POST /api/v1/account/note-colors
POST /api/v1/account/note-colors/reset
```

Each member can name the four highlight colours (for example `YELLOW` →
"needs review"). Names are personal: a `SHARED` note carries its colour to
every reader, but each reader sees their own name for that colour.

`GET` returns `colors` (the four colour codes) and `labels` (only the entries
the member has set — an unset colour falls back to the app's default name, a
name saved as an empty string shows no name at all). `POST` accepts a
`{"COLOR": "name"}` object, updates only the colours present, and caps names
at 40 characters. `reset` clears every stored name so the defaults apply again.

## Member sessions

These endpoints serve the web app. Integrations should use an API key instead of
signing in.

| Endpoint | Purpose |
|---|---|
| `POST /api/v1/auth/register` | Create an account (`email`, `password`, `name`) |
| `POST /api/v1/auth/login` | Sign in (`email`, `password`, `trustThisDevice`) |
| `GET /api/v1/auth/me` | Details of the signed-in account |
| `POST /api/v1/auth/refresh` | Obtain a fresh access token |
| `POST /api/v1/auth/logout` | End this session, or all sessions with `?allDevices=true` |
| `POST /api/v1/auth/me/password` | Change the password (`currentPassword`, `newPassword`) |
| `POST /api/v1/auth/me/language` | Set the language the service uses for this member (`language`: `ko` or `en`) |
| `POST /api/v1/auth/withdraw` | Close the account (`password`) |

Changing the password and closing the account both require the current password
in the request body. Both also end every session on every device, so the client
must sign in again afterwards.

Signing in returns an access token valid for 30 minutes and sets a longer-lived
refresh token as an HttpOnly cookie. The refresh token never appears in a
response body.

`POST /auth/refresh` replaces the refresh token every time it is called. Sending
a token that has already been replaced is treated as a stolen credential: every
token descended from the same sign-in is revoked and the call fails with
`E_AUTH_0006`. Sessions the account started separately, such as a sign-in on
another device, are not affected. A client that refreshes from several places at
once should run one refresh and let the other requests wait for its result.
