API Reference

Lesstruct API Reference (/api/v1)#

Lesstruct exposes a versioned, API-key-authenticated REST API at /api/v1 for creating, reading, updating, and deleting Content and Media. It is designed for programmatic consumers — the lesstruct-cli, MCP servers, AI agents (Claude Code, OpenCode, Hermes, …), and human integrators — and accepts Markdown as a first-class authoring format.

This reference documents the implemented surface. For the design intent, see _bmad-output/planning-artifacts/architecture-ai-cli.md.

Overview#

  • Base URL. The API is served from the same origin as your Lesstruct server, under the /api/v1 prefix. Example: https://your-lesstruct.example/api/v1/content.
  • Transport. HTTPS in production. All request and response bodies are application/json, except media upload which is multipart/form-data.
  • Authentication. Every /api/v1 request carries an API key as a Bearer token (see Authentication). /api/v1 is Bearer-only — there is no cookie/JWT fallback.
  • Versioning. The v1 URL segment pins the contract. Breaking changes ship under a new version segment.
  • JSON conventions. Keys are camelCase. Strings are UTF-8. Timestamps are ISO 8601 strings.

Authentication#

Requests authenticate with a personal API key in the Authorization header:

1
Authorization: Bearer lesstruct_a1b2c3d4e5f6_<secret>

The key string has the format lesstruct_<keyID>_<secret>:

  • lesstruct_ — a recognizable prefix (like GitHub’s ghp_), so keys are easy to detect in logs and scanners.
  • keyID — 12 hex characters (e.g. a1b2c3d4e5f6). This is the public, safely-displayable identifier.
  • secret — 32 hex characters (≥128 bits). It is stored only as a salted hash and is never logged.

Creating keys#

API keys are created in the admin panel under Profile → API Keys (this is a browser/JWT action, not part of /api/v1). When you create a key:

  1. You give it a human-readable name (e.g. “Claude Code”).
  2. The full key string is shown exactly once, with a copy button and a “you won’t see this again” warning. Save it immediately.
  3. Thereafter, the key is displayed only as its prefix (lesstruct_a1b2c3d4e5f6••••).

You can revoke a key at any time from the same view; revoked keys immediately stop authenticating.

Logging hygiene#

Only the keyID is ever logged — the secret and the full key string are redacted in all log output. Integrators should apply the same redaction in their own logs.

Authorization#

A key acts as the user who created it. It inherits that user’s role and permissions, and every operation is scoped to that user’s own resources (you can only list/read your own content and media, unless your role is Admin). Lesstruct’s existing role-based access control governs every request unchanged.

Conventions#

Response envelope#

All responses use a uniform envelope with three optional top-level keys: data, error, and meta.

Single resource (create / get / update, and single media get):

1
2
3
{
  "data": { "content": { "id": 7, "title": "Hello", "..." : "..." } }
}

List (content list, media list) — data is a bare array, not wrapped in an object:

1
2
3
4
{
  "data": [ { "id": 7, "..." : "..." }, { "id": 6, "..." : "..." } ],
  "meta": { "pagination": { "nextCursor": "Ng", "hasMore": true } }
}

Watch the asymmetry. A single content item is {"data":{"content":{…}}} (wrapped under content), but a list is {"data":[…]} (bare array). This is intentional and is the most common source of client bugs. Empty lists render as "data":[] (the key is always present).

Error:

1
2
3
{
  "error": { "code": "VALIDATION_ERROR", "message": "title is required and must be between 1 and 200 characters" }
}

Pagination#

List endpoints use cursor (keyset) pagination, which is stable across inserts and deletes (unlike offset pagination).

ParameterDefaultRangeNotes
limit501100Missing/invalid/negative → 50; over 100 → clamped to 100.
cursor(omit)opaqueOmit for the first page. Pass the nextCursor from the previous response.

The cursor is an opaque, unpadded base64url token encoding the id of the last item on the current page. Do not construct or inspect it — treat it as opaque and echo it back. An invalid cursor returns 400 VALIDATION_ERROR "Invalid cursor".

The response includes meta.pagination:

  • nextCursor — present only when hasMore is true. Pass it as the next request’s cursor.
  • hasMore — whether another page exists.
1
2
3
4
5
6
7
# First page
curl -H "Authorization: Bearer lesstruct_a1b2c3d4e5f6_<secret>" \
  "https://your-lesstruct.example/api/v1/content?limit=50"

# Next page (use the nextCursor from the previous response)
curl -H "Authorization: Bearer lesstruct_<...>" \
  "https://your-lesstruct.example/api/v1/content?limit=50&cursor=Ng"

Lists are scoped to the caller’s own resources — an API key cannot enumerate another user’s content or media (Admin-role keys excepted, per the role inheritance above).

Visibility (no-enumeration model)#

To avoid disclosing which resources exist, operations on a resource you don’t own (and aren’t an Admin for) return 404 NOT_FOUNDnever 403 FORBIDDEN:

  • Drafts are readable only by their owner.
  • Published content is readable by any authenticated key.
  • GET/PUT/DELETE on a resource you don’t own → 404 NOT_FOUND (existence is not disclosed).

Content#

The Content resource lets you publish posts, pages, and other content types over the API. Content is stored as canonical Tiptap JSON; you may submit Markdown and let the server convert it (see Authoring in Markdown).

Content object#

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
{
  "id": 7,
  "title": "Hello world",
  "slug": "hello-world",
  "body": "{\"type\":\"doc\",\"content\":[{\"type\":\"paragraph\",...}]}",
  "status": "published",
  "postType": "post",
  "language": "en",
  "tags": ["intro", "demo"],
  "customFields": { "subtitle": "My first post" },
  "author": "Ari",
  "createdAt": "2026-06-15T10:00:00Z",
  "updatedAt": "2026-06-15T10:00:00Z"
}
FieldTypeNotes
idintStable identifier.
titlestring1–200 chars.
slugstringURL slug. Auto-generated from the title; the slug you send in create/update is not honored.
bodystringThe canonical content — a Tiptap JSON document string.
statusstring"draft" or "published".
postTypestringContent type (e.g. post, page), from your configured post types.
languagestringLanguage code. Settable on create/update; must be in the server’s configured languages list, else 400 ErrInvalidLanguage.
tagsstring[]Tags. Settable on create/update; normalized server-side (lowercased, trimmed, deduped, length-bounded) via ValidateTags.
customFieldsobjectTOML-defined, server-validated custom-field values.
authorstringDisplay name of the author. Read-only — derived from the API key’s user.
createdAt / updatedAtstringISO 8601 timestamps.

Create content#

1
POST /api/v1/content

Request body:

1
2
3
4
5
6
7
8
{
  "title": "Hello world",
  "body": "# Hello\n\nThis is my first post.",
  "format": "markdown",
  "postType": "post",
  "customFields": { "subtitle": "My first post" },
  "isPublished": true
}
FieldRequiredNotes
titleyes1–200 chars.
bodyyesThe content. With format: markdown it is Markdown (converted server-side to Tiptap); with format: tiptap (the default) it must be a valid Tiptap JSON document string.
formatno"markdown" or "tiptap". Defaults to "tiptap". Matched case-insensitively after trimming leading/trailing whitespace.
postTypenoContent type.
tagsnoArray of tag strings. Server normalizes (trim, lowercase, dedupe, length-bound) via ValidateTags; an invalid tag returns 400 VALIDATION_ERROR.
languagenoLanguage code (e.g. "en", "id"). Must be in the server’s configured languages list (config.toml [languages]); an unknown code returns 400 VALIDATION_ERROR (ErrInvalidLanguage).
slugnoAccepted for API stability but not honored — the server auto-generates the slug from the title.
customFieldsnoCustom-field values, validated through the same path the admin uses.
isPublishednotrue"published"; false/omitted → "draft".

Response 200 OK:

1
{ "data": { "content": { "id": 7, "title": "Hello world", "slug": "hello-world", "..." : "..." } } }

Create returns 200 OK (not 201 Created) by design — consistent with the other /api/v1 success responses.

Errors: 400 VALIDATION_ERROR (bad/missing fields, invalid Tiptap, custom-field validation, or Markdown that converts to Tiptap the server rejects — see Authoring in Markdown).

Get content#

1
GET /api/v1/content/{id}

Response 200 OK: {"data":{"content":{…}}}.

Returns 404 NOT_FOUND if the content does not exist or you are not allowed to read it (a draft owned by someone else). Published content is readable by any authenticated key.

List content#

1
GET /api/v1/content?limit=50&cursor=<cursor>&tag=foo&tag=bar&language=en&status=draft&post_type=post&author=alice&search=golang

Returns the caller’s own content (drafts and published), newest-first, using cursor pagination. All filters AND together with the cursor; pass multiple tag values to AND-of-tags (the post must carry every tag).

Query parameters:

ParamTypeNotes
limitintDefault 50, max 100.
cursorstringOpaque token from a previous list call.
tagstring (repeatable)AND-of-tags — the post must carry every tag.
languagestringFilter by language code.
statusdraft | publishedUnknown values return 400 VALIDATION_ERROR.
post_typestringFilter by post type.
authorstringAdmin only. Non-admins receive 403 FORBIDDEN.
searchstringTitle / meta-description substring (case-insensitive). Min length 2; shorter values are dropped.

Response 200 OK:

1
2
3
4
{
  "data": [ { "id": 7, "..." : "..." }, { "id": 6, "..." : "..." } ],
  "meta": { "pagination": { "nextCursor": "Ng", "hasMore": true } }
}

Update content#

1
PUT /api/v1/content/{id}

Accepts title, body, format, postType, customFields, isPublished, tags, and language. SEO metadata (metaDescription, ogTitle, ogDescription), allowComments, and translationGroupId are preserved from the existing item and cannot be changed via this endpoint — any values you send for them are ignored. slug is accepted for API stability but not honored (the server auto-generates the slug from the title). format: markdown converts the body to Tiptap before storing.

Response 200 OK: {"data":{"content":{…}}} with the updated item.

Returns 404 NOT_FOUND if the item does not exist or you are not its owner (and not Admin) — existence is not disclosed (see Visibility). Errors: 400 VALIDATION_ERROR.

Delete content#

1
DELETE /api/v1/content/{id}

Response 204 No Content (empty body) on success. A subsequent GET returns 404 NOT_FOUND.

Returns 404 NOT_FOUND if the item does not exist or you are not its owner (and not Admin).

Publish content#

1
POST /api/v1/content/{id}/publish

Standalone status-toggle verb. No request body. On the draft → published transition the server auto-generates SEO metadata (when the SEO service is configured) and fires the AfterPublish plugin hook. Publishing an already-published post is a 200 no-op: the row is persisted unchanged, no hook fires, no SEO is regenerated.

Response 200 OK: {"data":{"content":{…}}} with the item now in status: "published".

Returns 404 NOT_FOUND if the item does not exist or you are not its owner (and not Admin) — existence is not disclosed. Errors: 400 VALIDATION_ERROR (bad id).

1
2
curl -X POST -H "Authorization: Bearer lesstruct_a1b2c3d4e5f6_<secret>" \
  "https://your-lesstruct.example/api/v1/content/7/publish"

Unpublish content#

1
POST /api/v1/content/{id}/unpublish

Standalone status-toggle verb. No request body. Sets status: "draft". Never fires the AfterPublish hook (the hook is wired to the draft → published edge only). Unpublishing an already-draft post is a 200 no-op.

Response 200 OK: {"data":{"content":{…}}} with the item now in status: "draft".

Returns 404 NOT_FOUND if the item does not exist or you are not its owner (and not Admin). Errors: 400 VALIDATION_ERROR (bad id).

1
2
curl -X POST -H "Authorization: Bearer lesstruct_a1b2c3d4e5f6_<secret>" \
  "https://your-lesstruct.example/api/v1/content/7/unpublish"

Media#

Upload, retrieve, and list media (images). Media is deduplicated by content hash and stored with generated variants (e.g. WebP + thumbnails).

Media object#

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
{
  "id": 12,
  "filename": "a1b2c3d4.webp",
  "originalFilename": "photo.jpg",
  "mimeType": "image/jpeg",
  "fileSize": 204800,
  "width": 1200,
  "height": 800,
  "altText": "A scenic mountain view",
  "isWebp": true,
  "hash": "a1b2c3d4e5f6...",
  "url": "https://your-lesstruct.example/uploads/media/a1b2c3d4.webp",
  "variants": {
    "thumbnail": { "url": "https://your-lesstruct.example/uploads/media/a1b2c3d4-200.webp", "width": 200 }
  },
  "createdAt": "2026-06-15T10:00:00Z",
  "updatedAt": "2026-06-15T10:00:00Z"
}
FieldTypeNotes
idintStable identifier.
filename / originalFilenamestringStored / uploaded filename.
mimeTypestringSource MIME type (JPG/PNG/GIF/WebP).
fileSizeintBytes.
width / heightintPixel dimensions.
altTextstringAccessibility text.
isWebpboolWhether the primary stored file is WebP. (Note the key isWebp, not isWebP.)
hashstringContent hash used for dedup.
urlstringThe absolute URL to reference this media in content (e.g. https://your-lesstruct.example/uploads/media/<file>).
variantsobjectMap of variant name → { "url", "width" } (e.g. thumbnail).
createdAt / updatedAtstringISO 8601 timestamps.

Upload media#

1
2
POST /api/v1/media
Content-Type: multipart/form-data

The body is multipart/form-data with:

  • file (required) — the image part (JPG, PNG, GIF, or WebP). A missing file part returns 400 VALIDATION_ERROR "file part is required".
  • metadata — a JSON part: {"altText":"A scenic mountain view"}. A non-empty altText is required for accessibility. The part is optional only in the multipart sense: omitting it (or sending empty altText) causes the service to reject the upload with 400 VALIDATION_ERROR. Always send it.
1
2
3
4
curl -H "Authorization: Bearer lesstruct_a1b2c3d4e5f6_<secret>" \
  -F "file=@photo.jpg" \
  -F 'metadata={"altText":"A scenic mountain view"}' \
  "https://your-lesstruct.example/api/v1/media"

Response 200 OK: {"data":{"media":{…}}}.

StatusCodeWhen
200Uploaded; a new media item was created and stored.
400VALIDATION_ERRORMissing file part; unsupported/oversized file; empty altText.
409CONFLICTA file with the same content hash already exists. The upload is not stored; upload a different file or use the existing media’s url.

Duplicate handling. The API returns 409 CONFLICT for a duplicate upload (rather than returning the existing item, as the admin panel does). This keeps the contract honest: the upload was not stored.

Get media#

1
GET /api/v1/media/{id}

Response 200 OK: {"data":{"media":{…}}}.

Returns 404 NOT_FOUND if the media does not exist or you are not its owner (and not Admin) — existence is not disclosed.

List media#

1
GET /api/v1/media?limit=50&cursor=<cursor>

Returns the caller’s own media, newest-first, using cursor pagination. Same envelope as the content list: a bare data array plus meta.pagination.

Shared path note. GET /api/v1/media and GET /api/v1/media/{id} are shared with the browser admin panel; the server dispatches to the agent handler when the request presents a lesstruct_-prefixed Bearer token, and to the browser handler otherwise. For agent clients this is transparent — always send the Bearer key. POST /api/v1/media (upload) is agent/Bearer-only.

Errors#

Errors use the envelope’s error object: {"error":{"code":"…","message":"…"}}. (A details field is reserved on the object but is not currently populated by the /api/v1 handlers.)

Error catalog#

HTTPCodeMeaningEmitted by
401UNAUTHORIZEDNo / undecodable identity.handler / middleware
401INVALID_API_KEYThe key is malformed or unknown.auth middleware
401REVOKED_KEYThe key has been revoked.auth middleware
401EXPIRED_KEYThe key has expired.auth middleware
400VALIDATION_ERRORBad request body, invalid Tiptap, custom-field validation, invalid cursor, invalid id, missing file part, bad alt text, etc.handler
404NOT_FOUNDResource does not exist, or you don’t own it (and aren’t Admin) — existence is not disclosed.handler
403FORBIDDENReserved for service-layer rejections. Not the response for resources you don’t own — those return 404 (no-enumeration). Rarely emitted on the agent surface.handler
409CONFLICTDuplicate media upload.media handler
429RATE_LIMITEDYou have exceeded the per-key rate limit.rate-limit middleware
500INTERNAL_ERRORUnexpected server error.handler

No-enumeration#

Resource existence is never disclosed: a request for a resource you don’t own (and aren’t Admin for) returns 404 NOT_FOUND, not 403 FORBIDDEN. Treat 404 on GET/PUT/DELETE as “not found or not yours”.

Authoring in Markdown#

Set format: "markdown" on create/update to author content in Markdown. The server parses it with goldmark (core CommonMark) and converts it to canonical Tiptap JSON, which is what is stored. Raw Markdown is never persisted.

1
2
3
4
curl -X POST -H "Authorization: Bearer lesstruct_<...>" \
  -H "Content-Type: application/json" \
  -d '{"title":"Hello","body":"# Hello\n\n**Bold** and *italic*.","format":"markdown","isPublished":true}' \
  "https://your-lesstruct.example/api/v1/content"

Supported Markdown#

MarkdownResult
# H1###### H6Headings (levels 1–6).
Plain textParagraphs.
**bold**, __bold__Bold.
*italic*, _italic_Italic.
`code`Inline code.
```lang … ``` (fenced)Code block, with language from the info string.
Indented codeCode block (no language).
---, ***, ___Horizontal rule.
> quoteBlockquote (nestable).
- a / * a / + aBullet list.
1. aOrdered list.
![alt](url "title")Image (src/alt/title).
[text](url "title")Link (href/title).
<https://example.com>Autolink (→ link).
Hard line break (··\n or text\)Hard break.

Sanitized / not enabled#

  • Raw HTML is sanitized. Inline and block raw HTML is reduced to safe plain text (tags are stripped, visible text is kept) via bluemonday. Raw HTML markup is never stored. Converting rich HTML formatting to Tiptap marks is out of scope — only the visible text survives.
  • Tables, task lists, and strikethrough are not enabled (core CommonMark only). They render as plain text/paragraphs.

URL safety#

The converted document must pass Lesstruct’s Tiptap validator, which restricts URL schemes:

  • Link href must be http, https, mailto, or empty.
  • Image src must be http, https, or empty.

A link or image with another scheme (javascript:, data:, file:, …) causes the converted document to fail validation and the request returns 400 VALIDATION_ERROR. This is intentional and applies site-wide (including admin-authored content). Use an http(s) URL or upload the media first (see Images).

Markdown is an ingest format only. It is always converted to Tiptap JSON before storage; you cannot retrieve the original Markdown. Round-trip (Tiptap → Markdown) is out of scope.

Images#

  • External images![alt](https://cdn.example.com/img.png) passes through unchanged; the src is stored as-is (subject to the http(s) scheme rule above).

  • Local media — to embed an image you upload, first upload it via POST /api/v1/media, then reference the returned url in your Markdown:

    1
    2
    3
    4
    5
    6
    7
    8
    
    # 1. Upload
    curl -H "Authorization: Bearer lesstruct_<...>" \
      -F "file=@photo.jpg" -F 'metadata={"altText":"..."}' \
      "https://your-lesstruct.example/api/v1/media"
    # → { "data": { "media": { "url": "https://your-lesstruct.example/uploads/media/a1b2c3d4.webp", ... } } }
    
    # 2. Reference the returned url
    ![A scenic view](https://your-lesstruct.example/uploads/media/a1b2c3d4.webp)

Rate limiting#

/api/v1 is rate-limited per API key (not per IP) for attribution and fairness, using the same token-bucket limiter as the rest of the API. When you exceed the limit you receive 429 RATE_LIMITED. Browser/admin routes are rate-limited per IP.

If you hit the limit, wait and retry with backoff. The limit is shared across all requests made with a given key.

OpenAPI snippet#

A machine-readable OpenAPI fragment for the Content create endpoint. A full OpenAPI specification is deferred to post-MVP; this snippet is suitable for agent tooling consumption.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
openapi: 3.0.3
info:
  title: Lesstruct API
  version: "1.0"
  description: >
    Versioned, API-key-authenticated Content and Media API.
    Auth: HTTP Bearer scheme with a `lesstruct_`-prefixed API key.
servers:
  - url: https://your-lesstruct.example
components:
  securitySchemes:
    apiKey:
      type: http
      scheme: bearer
      description: "Authorization: Bearer lesstruct_<keyID>_<secret>"
  schemas:
    Content:
      type: object
      description: "A content item (see Content object above for the full field set)."
      properties:
        id: { type: integer }
        title: { type: string }
        slug: { type: string }
        body: { type: string, description: "Canonical Tiptap JSON document string." }
        status: { type: string, enum: [draft, published] }
  responses:
    Error:
      description: Error
      content:
        application/json:
          schema:
            type: object
            properties:
              error:
                type: object
                properties:
                  code: { type: string }
                  message: { type: string }
security:
  - apiKey: []
paths:
  /api/v1/content:
    post:
      summary: Create content
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [title, body]
              properties:
                title: { type: string, minLength: 1, maxLength: 200 }
                body: { type: string, description: "Tiptap JSON (format=tiptap) or Markdown (format=markdown)" }
                format: { type: string, enum: [markdown, tiptap], default: tiptap, description: "Server matches case-insensitively after trimming whitespace." }
                postType: { type: string }
                slug: { type: string, description: "Accepted but not honored; slug is auto-generated." }
                customFields: { type: object, additionalProperties: true }
                isPublished: { type: boolean, default: false }
      responses:
        "200":
          description: Created
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      content: { $ref: "#/components/schemas/Content" }
        "400": { $ref: "#/components/responses/Error" }
        "401": { $ref: "#/components/responses/Error" }
        "429": { $ref: "#/components/responses/Error" }

The same pattern extends to the remaining /api/v1/content[/{id}] and /api/v1/media operations described above. A complete OpenAPI document will be generated in a follow-up.