# PostMD — API reference

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, invite links 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. Everything else — listing, updating, deleting, groups —
identifies you first.

An MCP server wraps these endpoints for clients that speak MCP: `npx -y
postmd-mcp-server`. It follows the same rule — nothing to publish, an API key for the
rest — and it 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
- Invite links, joining a group, leaving a group, managing members

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 scopes it requires. Calling one without the
required scope returns `403` with `E_ACC_0002`.

## 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. Written for logs and debugging. |
| `data` | The payload. Absent when the call fails. |

Two points to be clear about:

- **Do not show `message` to end users.** It is always English and its wording
  may change. Map `resultCode` to your own text instead.
- **One endpoint does not use this envelope.** `GET /documents/{docCode}/raw`
  returns the markdown file itself as `text/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.

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; every session was 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_0004` | 403 | Document's sharing period has ended |
| `E_DOC_0005` | 400 | Uploaded file is missing or is not a `.md` file |
| `E_DOC_0006` | 400 | Attachment is missing or its file type is not allowed |
| `E_DOC_0007` | 400 | Upload exceeds the size limit |
| `E_GRP_0001` | 404 | No such group, or it was deleted |
| `E_GRP_0002` | 400 | The default group cannot be modified or deleted |
| `E_GRP_0003` | 403 | Group has passed its expiry date |
| `E_GRP_0004` | 403 | Caller is neither the group's owner nor a member |
| `E_GRP_0005` | 403 | Invite link is turned off or no longer valid |
| `E_GRP_0006` | 409 | Caller is already in this group |
| `E_GRP_0007` | 400 | A group owner cannot leave their own group |
| `E_GRP_0008` | 404 | No such folder in this group |
| `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 | This document accepts `PRIVATE` notes only |

## 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, for example
`2026-08-08T11:23:58Z`. Convert to your reader's zone for display.

**Upload size** is limited to 1 MB per markdown file.

**Paged lists.** Endpoints that can grow — groups, documents in a group, group
members — 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.

Folder listings are not paged: a folder tree is only useful in one piece. API
key listings are not paged either, 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

### 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 | Defaults to the file name without its `.md` extension. |
| `password` | no | Readers must supply this password to see the content. |
| `shareEndDate` | no | `yyyyMMdd`. The document stops being served after this date. Omit for no end date. |
| `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. |

```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"
  }
}
```

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 three fields come back from `POST /api/v1/documents/{docCode}` 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.

**Uploading without a credential.** The document is owned by an internal account,
which means nobody can update or delete it afterwards.

### Upload several documents at once

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

Scope: `documents:write`

Send one `files` part per document. `password`, `shareEndDate`, `viewerStyle`
and `groupId` may be given once and apply to every file.

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. An API key
with `documents:read` is accepted but not required here. The scope matters for
listing a member's documents, which is not public.

Use `/raw` when you want the file exactly as uploaded — it returns
`text/markdown` with no JSON envelope. Use `/meta` when you only need the title
or sharing settings and want to avoid transferring the content.

**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 owner does not need the header.

**Expired documents** fail with `E_DOC_0004`, again except for the owner.

### Update a document

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

Scope: `documents:write`. Only the document's owner may call this.

| Field | Effect |
|---|---|
| `title` | Replaces the title |
| `password` | Sets a new password |
| `clearPassword` | `true` removes the password entirely |
| `shareEndDate` | Replaces the sharing end date (`yyyyMMdd`) |
| `clearShareEndDate` | `true` removes the end date, making sharing open-ended |
| `viewerStyle` | Replaces the viewer theme |
| `file` | Replaces the document content with a new `.md` file |

Fields you omit are left unchanged. To remove a password or an end date you must
send the matching `clear...` flag — sending an empty value does not clear it.

### Delete a document

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

Scope: `documents:write`. Owner only.

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, "folderId": 1 }
```

Scope: `documents:write`. Owner only, and you must be able to use the target
group (own it or be a member).

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

`folderId` is optional and files the document inside a folder of that group.
Omit it to place the document at the group's top level.

### Upload an image or attachment

```
POST /api/v1/documents/uploads
Content-Type: multipart/form-data      # single field: file
```

Scope: `documents:write`

Allowed types: `png`, `jpg`, `jpeg`, `gif`, `webp`, `svg`, `bmp`, `pdf`.

```json
{
  "resultCode": "200",
  "data": {
    "url": "/files/attachments/2026/08/802896ad.png",
    "fileName": "diagram.png",
    "size": 20481
  }
}
```

Use the returned `url` inside your markdown. A typical flow:

1. Upload each image and collect its `url`.
2. Rewrite the image paths in your markdown to those URLs.
3. Upload the markdown with `POST /api/v1/documents`.

Relative paths such as `../images/a.png` do not work after upload, because
PostMD receives only the markdown file and not the surrounding folder.

## Groups

### List your groups

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

Scope: `groups:read`. Paged.

Returns groups you own and groups you have joined. `owner` tells the two apart.

### Create, update and delete a group

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

POST /api/v1/groups/{groupId}/update
{ "name": "New name", "expireDate": "20271231", "clearExpireDate": false }

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

Scope: `groups:write`. Update and delete are owner-only.

`expireDate` is optional; a group without one never expires. Set
`clearExpireDate` to `true` to remove an existing date.

Your default group cannot be renamed away or deleted — attempting to delete it
returns `E_GRP_0002`.

### List documents in a group

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

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

Available to the group's owner and its members. Anyone else receives
`E_GRP_0004`.

| Parameter | Effect |
|---|---|
| `folderId` | Return only documents filed in that folder |
| `rootOnly` | `true` returns only documents that are not in any folder |
| `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.

### Folders

Folders organise documents inside one group.

```
GET  /api/v1/groups/{groupId}/folders
POST /api/v1/groups/{groupId}/folders                   { "name": "Specs", "parentFolderId": null }
POST /api/v1/groups/{groupId}/folders/{folderId}/update { "name": "Specs v2", "parentFolderId": 3 }
POST /api/v1/groups/{groupId}/folders/{folderId}/delete
```

Scopes: `groups:read` to list, `groups:write` to create, update or delete.

`parentFolderId` places a folder inside another folder; `null` puts it at the
top level. The list endpoint returns every folder of the group as a flat array —
build the tree yourself from `parentFolderId`.

A folder must be empty before it can be deleted. If it still holds sub-folders
or documents, the call fails with `400`.

### Members and invite links

These endpoints require a signed-in member session. **API keys receive `403`.**

```
GET  /api/v1/groups/{groupId}/invite                     # current link state
POST /api/v1/groups/{groupId}/invite?enabled=true        # turn on, issuing a new link
POST /api/v1/groups/{groupId}/invite?enabled=false       # turn off
GET  /api/v1/invites/{inviteCode}                        # which group a link points to
POST /api/v1/invites/{inviteCode}/join                   # join that group
GET  /api/v1/groups/{groupId}/members?page=&size=        # owner only, paged
POST /api/v1/groups/{groupId}/members/{memberId}/remove  # owner only
POST /api/v1/groups/{groupId}/leave
```

Turning the invite link on returns the code and its URL:

```json
{
  "resultCode": "200",
  "data": {
    "enabled": true,
    "inviteCode": "TEMBeQ0brKMj2wzqB6GPqz1sOaQrg5_h",
    "url": "/invite/TEMBeQ0brKMj2wzqB6GPqz1sOaQrg5_h",
    "updatedAt": "2026-08-08T11:51:02"
  }
}
```

Points to be aware of:

- Turning the link off, or on again, never removes existing members.
- Turning it on again produces a **different** code; the old one stops working.
- `GET /api/v1/invites/{inviteCode}` works without signing in, so a visitor can
  see which group they are being invited to before creating an account.
- A group owner cannot leave their own group (`E_GRP_0007`). Delete the group
  instead.

## 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.

Visibility is per note. `PRIVATE` notes are visible only to their author.
`SHARED` notes are visible to everyone who can read the document, and only
documents owned by a person accept them — anonymously published documents and
service-owned documents accept `PRIVATE` notes only.

### 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.

### 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` (default) or `SHARED` |
| `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. `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. A browser opening this URL is
redirected to the viewer.

Password-protected and expired documents do not reveal their title 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.

### 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:58"
    }
  ]
}
```

`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.

### 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.

### 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.

## 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/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
session for that account is revoked and the call fails with `E_AUTH_0006`.
Because of this, a client must not issue two refresh calls in parallel — run one
and let other requests wait for its result.
