API Reference

v1

Manage websites, posts, authors, and uploads programmatically. Authenticate with a Bearer token and send JSON — all endpoints return a consistent { "data": ... } envelope.

Authentication

All API requests require authentication via an API key. Generate one from your Settings page. Include it as a Bearer token in the Authorization header of every request.

bash
curl -H "Authorization: Bearer th_sk_live_..." \
  https://tinyheadless.blog/api/v1/websites

Base URL

All endpoints are relative to the base URL below. Replace the domain with your own if self-hosting.

text
https://tinyheadless.blog/api/v1

Response Format

All successful responses return JSON with a data field. Errors return an error field with a message string.

Success

json
{
  "data": { ... }
}

Error

json
{
  "error": "Unauthorized"
}

Status Codes

The API uses standard HTTP status codes to indicate the outcome of a request.

CodeMeaning
200OK — request succeeded
201Created — resource was created (POST)
400Bad Request — invalid or missing parameters. Response includes issues array with field-level details.
401Unauthorized — missing or invalid API key
403Forbidden — valid key but insufficient permissions (e.g. plan limit reached)
404Not Found — resource does not exist or does not belong to your account
409Conflict — resource already exists (e.g. duplicate handle)
429Too Many Requests — monthly rate limit exceeded. See Rate Limits.
500Internal Server Error — unexpected failure; retry or contact support

Rate Limits

API requests are rate-limited on a monthly basis per account. The limit depends on your plan. When you exceed the limit, the API returns a 429 status with a Retry-After header.

json
// 429 Too Many Requests
{
  "error": "Monthly API request limit exceeded",
  "limit": 10000,
  "current": 10001
}

// Header: Retry-After: 86400

POST/api/v1/websites

Create Website

Creates a new website under your account. Each website gets a unique handle that determines its hosted URL (handle.tinyheadless.blog).

Request Body

handlerequired — Globally unique handle (2-32 chars, lowercase alphanumeric and hyphens, no leading/trailing dash)
titlerequired — Website title (1-100 chars)
descriptionWebsite description (max 500 chars)
logoUrlLogo image URL (use a presigned upload URL — automatically promoted on save)
headlessOnlyWhen true, disables static site generation (default: false)
bash
curl -X POST https://tinyheadless.blog/api/v1/websites \
  -H "Authorization: Bearer th_sk_live_..." \
  -H "Content-Type: application/json" \
  -d '{
    "handle": "myblog",
    "title": "My Blog",
    "description": "A blog about things",
    "logoUrl": "https://cdn.tinyheadless.blog/uploads/tmp/..."
  }'

JavaScript:

typescript
const res = await fetch('https://tinyheadless.blog/api/v1/websites', {
  method: 'POST',
  headers: {
    'Authorization': 'Bearer YOUR_API_KEY',
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    handle: 'myblog',
    title: 'My Blog',
    description: 'A blog about things',
  }),
})

// Status: 201 Created
const { data } = await res.json()

// Response:
// {
//   "data": {
//     "websiteId": "abc-123",
//     "handle": "myblog",
//     "title": "My Blog",
//     "description": "A blog about things",
//     "logoUrl": null,
//     "headlessOnly": false,
//     "status": "active",
//     "url": "https://myblog.tinyheadless.blog",
//     "createdAt": "2025-01-10T08:00:00.000Z",
//     "updatedAt": "2025-01-10T08:00:00.000Z"
//   }
// }

Errors: Returns 409 if the handle is already taken, or 403 if your account has reached its website limit.

GET/api/v1/websites

List Websites

Returns all websites associated with your account. Results are sorted by creation date (newest first). There is no pagination — all websites are returned in a single response.

bash
curl https://tinyheadless.blog/api/v1/websites \
  -H "Authorization: Bearer th_sk_live_..."

JavaScript:

typescript
const res = await fetch('https://tinyheadless.blog/api/v1/websites', {
  headers: { 'Authorization': 'Bearer YOUR_API_KEY' },
})

const { data } = await res.json()

// Response:
// {
//   "data": [
//     {
//       "websiteId": "abc-123",
//       "handle": "myblog",
//       "title": "My Blog",
//       "description": "A blog about things",
//       "headlessOnly": false,
//       "status": "active",
//       "url": "https://myblog.tinyheadless.blog",
//       "createdAt": "2025-01-10T08:00:00.000Z",
//       "updatedAt": "2025-01-12T14:30:00.000Z"
//     }
//   ]
// }
GET/api/v1/websites/:websiteId

Get Website

Returns a single website with full details including branding configuration and logo URL. Used by the embeddable editors.

typescript
const res = await fetch(
  'https://tinyheadless.blog/api/v1/websites/WEBSITE_ID',
  { headers: { 'Authorization': 'Bearer YOUR_API_KEY' } },
)

const { data } = await res.json()

// Response:
// {
//   "data": {
//     "websiteId": "abc-123",
//     "accountId": "acct-456",
//     "handle": "myblog",
//     "title": "My Blog",
//     "description": "A blog about things",
//     "branding": { "layout": "classic", "primaryColor": "#1a1a2e" },
//     "logoUrl": "https://cdn.example.com/logo.png",
//     "headlessOnly": false,
//     "status": "active",
//     "url": "https://myblog.tinyheadless.blog",
//     "createdAt": "2025-01-10T08:00:00.000Z",
//     "updatedAt": "2025-01-12T14:30:00.000Z"
//   }
// }
PUT/api/v1/websites/:websiteId

Update Website

Updates website settings. Use this to change the handle, title, description, logo, toggle headless-only mode, or publish/unpublish a website.

Request Body (all optional)

handleNew handle (2-32 chars, lowercase alphanumeric and hyphens). Returns 409 if taken.
titleWebsite title (1-100 chars)
descriptionWebsite description (max 500 chars)
logoUrlLogo image URL (use a presigned upload URL — automatically promoted on save)
headlessOnlyWhen true, disables static site generation — CMS operates as API-only
status"active" (published) or "disabled" (unpublished)
typescript
const res = await fetch(
  'https://tinyheadless.blog/api/v1/websites/WEBSITE_ID',
  {
    method: 'PUT',
    headers: {
      'Authorization': 'Bearer YOUR_API_KEY',
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      headlessOnly: true,  // API-only mode, no hosted blog
    }),
  },
)

const { data } = await res.json()
// data: updated website object with url field

Side effects: Changing the handle deletes all static pages under the old handle and rebuilds them under the new one. The old handle is freed immediately and can be claimed by any account. Unpublishing a website deletes all static pages from S3. Re-publishing rebuilds them. Enabling headless-only mode on an active website also removes static pages. Disabling headless-only mode triggers a full rebuild.

PUT/api/v1/websites/:websiteId/branding

Update Branding

Updates the branding/theme configuration for a website. Automatically triggers a rebuild of all static pages (landing page + published posts) with the new theme. Skipped if the website is in headless-only mode or unpublished. If ogImageUrl contains a presigned upload URL (i.e. uploads/tmp/), it is automatically promoted to permanent storage.

typescript
const res = await fetch(
  'https://tinyheadless.blog/api/v1/websites/WEBSITE_ID/branding',
  {
    method: 'PUT',
    headers: {
      'Authorization': 'Bearer YOUR_API_KEY',
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      branding: {
        layout: 'classic',
        primaryColor: '#1a1a2e',
        backgroundColor: '#ffffff',
        textColor: '#1a1a2e',
        accentColor: '#6366f1',
        fontHeading: 'Inter',
        fontBody: 'Inter',
        // ... any BlogTheme keys
      },
    }),
  },
)

const { data } = await res.json()
// data: updated website object
DELETE/api/v1/websites/:websiteId

Delete Website

Permanently deletes a website and all associated data: posts, authors, and the handle lock. All static pages and uploaded files are removed from S3. The handle is freed immediately and can be reused.

bash
curl -X DELETE https://tinyheadless.blog/api/v1/websites/WEBSITE_ID \
  -H "Authorization: Bearer th_sk_live_..."

JavaScript:

typescript
const res = await fetch(
  'https://tinyheadless.blog/api/v1/websites/WEBSITE_ID',
  {
    method: 'DELETE',
    headers: { 'Authorization': 'Bearer YOUR_API_KEY' },
  },
)

// Status: 200 OK
const result = await res.json()

// Response:
// { "data": { "deleted": true } }

Warning: This action is irreversible. All posts, authors, static pages, and uploaded files for the website will be permanently deleted.


GET/api/v1/websites/:websiteId/posts

List Posts

Returns all posts for a website, sorted by most recent first. By default only published posts are returned. Use the status query parameter to include drafts. There is no pagination — all matching posts are returned in a single response. The response does not include contentJson for performance — use Get Post to retrieve full content.

Query Parameters

statusFilter by status: published (default), draft, or all
categoryFilter by category name. Returns posts that include this category. Omit to return all.
slugFilter by slug. Returns 0 or 1 matching post.
typescript
// List all posts (including drafts)
const res = await fetch(
  'https://tinyheadless.blog/api/v1/websites/WEBSITE_ID/posts?status=all',
  { headers: { 'Authorization': 'Bearer YOUR_API_KEY' } },
)

const { data } = await res.json()

// Response:
// {
//   "data": [
//     {
//       "postId": "550e8400-e29b-...",
//       "websiteId": "abc-123",
//       "title": "My First Post",
//       "slug": "my-first-post",
//       "category": ["General"],
//       "excerpt": "A short summary...",
//       "thumbnail": null,
//       "authorId": null,
//       "status": "published",
//       "metaTitle": null,
//       "metaDescription": null,
//       "createdAt": "2025-01-15T10:30:00.000Z",
//       "updatedAt": "2025-01-16T08:00:00.000Z"
//     }
//   ]
// }
//
// Note: contentJson is NOT included in list responses for performance.
// Use Get Post (by ID) to retrieve the full content.

// Filter by category
const news = await fetch(
  'https://tinyheadless.blog/api/v1/websites/WEBSITE_ID/posts?category=News',
  { headers: { 'Authorization': 'Bearer YOUR_API_KEY' } },
)
GET/api/v1/websites/:websiteId/posts?slug={slug}

Get Post by Slug

Filter the posts list by slug. Useful for building pages with human-readable URLs. Returns an array with 0 or 1 matching post.

typescript
const slug = 'my-post-title'
const res = await fetch(
  `https://tinyheadless.blog/api/v1/websites/WEBSITE_ID/posts?slug=${slug}`,
  { headers: { 'Authorization': 'Bearer YOUR_API_KEY' } },
)

const { data } = await res.json()
// data: Array with 0 or 1 matching post
GET/api/v1/websites/:websiteId/posts/:postId

Get Post

Returns a single post with the full contentJson field containing the Yoopta editor JSON. Works for both draft and published posts.

typescript
const res = await fetch(
  'https://tinyheadless.blog/api/v1/websites/WEBSITE_ID/posts/POST_ID',
  { headers: { 'Authorization': 'Bearer YOUR_API_KEY' } },
)

const { data } = await res.json()

// Response:
// {
//   "data": {
//     "postId": "550e8400-e29b-...",
//     "websiteId": "abc-123",
//     "title": "My First Post",
//     "slug": "my-first-post",
//     "category": ["General"],
//     "excerpt": "A short summary...",
//     "thumbnail": null,
//     "authorId": null,
//     "contentJson": {             // Full Yoopta editor JSON
//       "block-1": {
//         "id": "block-1",
//         "type": "Paragraph",
//         "value": [{ "id": "el-1", "type": "paragraph",
//                     "children": [{ "text": "Hello world!" }] }],
//         "meta": { "order": 0, "depth": 0 }
//       }
//     },
//     "status": "published",
//     "metaTitle": "Custom SEO Title",
//     "metaDescription": "Custom meta description",
//     "createdAt": "2025-01-15T10:30:00.000Z",
//     "updatedAt": "2025-01-16T08:00:00.000Z"
//   }
// }
POST/api/v1/websites/:websiteId/posts

Create Post

Creates a new draft post. A URL-safe slug is automatically generated from the title. You can set post content in two ways:

  • content — a Markdown string (automatically converted to Yoopta JSON)
  • contentJson — a Yoopta editor JSON object (native format, see Content Format)

Precedence: If both content and contentJson are provided, contentJson takes precedence and content is ignored. The response always returns contentJson (the stored format).

Request Body

titlerequired — Post title (1-200 chars)
contentPost content as a Markdown string. Converted to Yoopta JSON server-side. Supports headings, bold, italic, lists, code blocks, images, blockquotes, and more.
contentJsonPost content as a Yoopta editor JSON object (native format). Takes precedence over content.
categoryArray of up to 3 category strings (defaults to ["General"])
excerptShort summary (max 500 chars)
thumbnailThumbnail image URL (use a presigned upload URL — automatically promoted on save)
authorIdAuthor ID to link to this post
metaTitleSEO meta title override (max 200 chars)
metaDescriptionSEO meta description override (max 500 chars)
bash
curl -X POST https://tinyheadless.blog/api/v1/websites/WEBSITE_ID/posts \
  -H "Authorization: Bearer th_sk_live_..." \
  -H "Content-Type: application/json" \
  -d '{
    "title": "My New Post",
    "category": ["News"],
    "content": "## Introduction\n\nThis is a **bold** statement."
  }'

Using Markdown (recommended for most integrations):

typescript
const res = await fetch(
  'https://tinyheadless.blog/api/v1/websites/WEBSITE_ID/posts',
  {
    method: 'POST',
    headers: {
      'Authorization': 'Bearer YOUR_API_KEY',
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      title: 'My New Post',
      category: ['News', 'Tech'],
      excerpt: 'A brief description of the post',
      content: '## Introduction\n\nThis is a **bold** statement.\n\n- First point\n- Second point\n\n> A wise quote.',
    }),
  },
)

// Status: 201 Created
const { data } = await res.json()

// Response:
// {
//   "data": {
//     "postId": "550e8400-e29b-...",
//     "websiteId": "WEBSITE_ID",
//     "title": "My New Post",
//     "slug": "my-new-post",
//     "category": ["News", "Tech"],
//     "excerpt": "A brief description of the post",
//     "contentJson": { ... },        // markdown converted to Yoopta JSON
//     "status": "draft",
//     "createdAt": "2025-01-15T10:30:00.000Z",
//     "updatedAt": "2025-01-15T10:30:00.000Z"
//   }
// }

Using contentJson directly (for advanced use or Yoopta editor output):

typescript
const res = await fetch(
  'https://tinyheadless.blog/api/v1/websites/WEBSITE_ID/posts',
  {
    method: 'POST',
    headers: {
      'Authorization': 'Bearer YOUR_API_KEY',
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      title: 'My New Post',
      contentJson: {
        'block-1': {
          id: 'block-1',
          type: 'Paragraph',
          value: [{ id: 'el-1', type: 'paragraph', children: [{ text: 'Hello world!' }] }],
          meta: { order: 0, depth: 0 }
        }
      },
    }),
  },
)
PUT/api/v1/websites/:websiteId/posts/:postId

Update Post

Updates a post. All fields are optional — only include the fields you want to change. Like Create Post, you can use content (Markdown) or contentJson (Yoopta JSON) to set content. Setting status to "published" will publish the post, and setting it to "draft" will unpublish a published post. Both trigger static site updates automatically.

Request Body (all optional)

titlePost title
slugURL slug
categoryArray of up to 3 category strings
excerptShort summary
contentMarkdown string — converted to Yoopta JSON server-side. Ignored if contentJson is also provided.
contentJsonYoopta editor JSON content (takes precedence over content)
thumbnailThumbnail image URL (use a presigned upload URL — automatically promoted on save)
authorIdAuthor ID to link to this post
status"draft" (unpublish) or "published" (publish)
metaTitleSEO meta title override
metaDescriptionSEO meta description override
bash
# Update and publish
curl -X PUT https://tinyheadless.blog/api/v1/websites/WEBSITE_ID/posts/POST_ID \
  -H "Authorization: Bearer th_sk_live_..." \
  -H "Content-Type: application/json" \
  -d '{ "status": "published" }'

# Unpublish
curl -X PUT https://tinyheadless.blog/api/v1/websites/WEBSITE_ID/posts/POST_ID \
  -H "Authorization: Bearer th_sk_live_..." \
  -H "Content-Type: application/json" \
  -d '{ "status": "draft" }'

Update content with Markdown and publish:

typescript
const res = await fetch(
  'https://tinyheadless.blog/api/v1/websites/WEBSITE_ID/posts/POST_ID',
  {
    method: 'PUT',
    headers: {
      'Authorization': 'Bearer YOUR_API_KEY',
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      content: '# Updated Title\n\nNew **content** here.\n\n- Point one\n- Point two',
      status: 'published',
      metaTitle: 'Custom SEO Title',
    }),
  },
)

const { data } = await res.json()

// Response:
// {
//   "data": {
//     "postId": "550e8400-e29b-...",
//     "websiteId": "abc-123",
//     "title": "My First Post",
//     "slug": "my-first-post",
//     "category": ["General"],
//     "excerpt": "A short summary...",
//     "contentJson": { ... },        // markdown converted to Yoopta JSON
//     "status": "published",
//     "metaTitle": "Custom SEO Title",
//     "createdAt": "2025-01-15T10:30:00.000Z",
//     "updatedAt": "2025-01-16T09:00:00.000Z"
//   }
// }

// Unpublish a post (revert to draft)
await fetch(
  'https://tinyheadless.blog/api/v1/websites/WEBSITE_ID/posts/POST_ID',
  {
    method: 'PUT',
    headers: {
      'Authorization': 'Bearer YOUR_API_KEY',
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({ status: 'draft' }),
  },
)

Side effects: Publishing a post or editing a published post triggers static site generation (HTML page + sitemap/RSS/landing page updates). Unpublishing removes the post page from S3. These side effects are skipped if the website is in headless-only mode or unpublished.

DELETE/api/v1/websites/:websiteId/posts/:postId

Delete Post

Permanently deletes a post. If the post was published, its static page is removed from S3 and the landing page is rebuilt (skipped in headless-only mode).

bash
curl -X DELETE https://tinyheadless.blog/api/v1/websites/WEBSITE_ID/posts/POST_ID \
  -H "Authorization: Bearer th_sk_live_..."

JavaScript:

typescript
const res = await fetch(
  'https://tinyheadless.blog/api/v1/websites/WEBSITE_ID/posts/POST_ID',
  {
    method: 'DELETE',
    headers: { 'Authorization': 'Bearer YOUR_API_KEY' },
  },
)

// Status: 200 OK
const result = await res.json()

// Response:
// { "data": { "deleted": true } }

Content Format Overview

TinyHeadless stores post content internally as contentJson — a structured JSON format based on the Yoopta Editor. Content is a flat object where each key is a unique block ID and each value describes one block of content (a paragraph, heading, image, list, etc.).

Markdown shortcut: You can send a content field with a Markdown string instead of building contentJson manually. The API converts Markdown to Yoopta JSON server-side. See Create Post and Update Post for examples. If both fields are provided, contentJson takes precedence.

json
{
  "contentJson": {
    "<block-id>": {
      "id": "<block-id>",
      "type": "Paragraph",
      "value": [ ... ],
      "meta": { "order": 0, "depth": 0 }
    },
    "<block-id>": { ... },
    "<block-id>": { ... }
  }
}

Blocks are rendered in the order defined by meta.order (ascending, starting from 0). You can use any string as a block ID — UUIDs, sequential IDs, or descriptive names like "intro" or "heading-1". The only requirement is that each block ID is unique within the post and matches the id field inside the block object.

Block Structure

Every block follows the same structure. The value array contains one or more elements. Most block types have a single element; list blocks have one element per list item.

Block Fields

idrequired — Unique block identifier, must match the object key
typerequired — Block type (e.g. "Paragraph", "HeadingOne", "Image")
valuerequired — Array of elements, each with id, type, children, and optional props
metarequired order (render position, 0-based) and depth (nesting level, usually 0)
json
// A single block
{
  "id": "block-1",
  "type": "Paragraph",
  "value": [
    {
      "id": "el-1",
      "type": "paragraph",
      "children": [
        { "text": "Hello " },
        { "text": "world", "bold": true },
        { "text": "!" }
      ]
    }
  ],
  "meta": { "order": 0, "depth": 0 }
}

The children array contains text nodes. Each text node has a text string and optional formatting flags (see Text Formatting below). Adjacent text nodes with different formatting are concatenated during rendering.

Block Types

TinyHeadless supports the following block types. The type field in the block object uses the capitalized name (left column). The type field inside each element uses the lowercase/hyphenated name (right column).

Block TypeElement TypeDescription
ParagraphparagraphStandard text paragraph
HeadingOneheading-oneH1 heading
HeadingTwoheading-twoH2 heading
HeadingThreeheading-threeH3 heading
BlockquoteblockquoteBlock quote
BulletedListbulleted-listUnordered list — one element per item in value
NumberedListnumbered-listOrdered list — one element per item in value
TodoListtodo-listChecklist with checkable items
CodecodeCode block (preformatted text)
ImageimageImage — uses props.src and props.alt
VideovideoVideo — uses props.src
FilefileFile attachment — uses props.src, props.name, props.size
EmbedembedEmbedded content (YouTube, tweets, etc.)
TabletableData table
CalloutcalloutHighlighted callout box
DividerdividerHorizontal rule / separator
AccordionaccordionCollapsible section

Image block example:

json
{
  "id": "img-1",
  "type": "Image",
  "value": [
    {
      "id": "el-img-1",
      "type": "image",
      "children": [{ "text": "" }],
      "props": {
        "src": "https://cdn.example.com/photo.jpg",
        "alt": "A description of the image"
      }
    }
  ],
  "meta": { "order": 3, "depth": 0 }
}

Bulleted list example (multiple elements in value):

json
{
  "id": "list-1",
  "type": "BulletedList",
  "value": [
    {
      "id": "li-1",
      "type": "bulleted-list",
      "children": [{ "text": "First item" }]
    },
    {
      "id": "li-2",
      "type": "bulleted-list",
      "children": [{ "text": "Second item with " }, { "text": "bold text", "bold": true }]
    },
    {
      "id": "li-3",
      "type": "bulleted-list",
      "children": [{ "text": "Third item" }]
    }
  ],
  "meta": { "order": 4, "depth": 0 }
}

Text Formatting

Each text node in a children array has a text string and optional boolean flags for inline formatting. Multiple flags can be combined.

FlagRenders AsExample
boldBold text{ "text": "important", "bold": true }
italicItalic text{ "text": "emphasis", "italic": true }
underlineUnderlined text{ "text": "note", "underline": true }
strikethroughStruck text{ "text": "old", "strikethrough": true }
codeInline code{ "text": "const x", "code": true }
highlightHighlighted{ "text": "key point", "highlight": true }

Mixed formatting in a paragraph:

json
{
  "id": "el-1",
  "type": "paragraph",
  "children": [
    { "text": "You can mix " },
    { "text": "bold", "bold": true },
    { "text": " and " },
    { "text": "italic", "italic": true },
    { "text": " and even " },
    { "text": "bold italic", "bold": true, "italic": true },
    { "text": " in the same paragraph." }
  ]
}

Full Example

A complete contentJson for a blog post with a heading, paragraphs, a list, and a code block.

json
{
  "heading": {
    "id": "heading",
    "type": "HeadingOne",
    "value": [{
      "id": "el-heading",
      "type": "heading-one",
      "children": [{ "text": "Getting Started with TinyHeadless" }]
    }],
    "meta": { "order": 0, "depth": 0 }
  },
  "intro": {
    "id": "intro",
    "type": "Paragraph",
    "value": [{
      "id": "el-intro",
      "type": "paragraph",
      "children": [
        { "text": "TinyHeadless is a " },
        { "text": "headless CMS", "bold": true },
        { "text": " that stores content as structured JSON and delivers it via a REST API." }
      ]
    }],
    "meta": { "order": 1, "depth": 0 }
  },
  "subheading": {
    "id": "subheading",
    "type": "HeadingTwo",
    "value": [{
      "id": "el-sub",
      "type": "heading-two",
      "children": [{ "text": "Key Features" }]
    }],
    "meta": { "order": 2, "depth": 0 }
  },
  "features": {
    "id": "features",
    "type": "BulletedList",
    "value": [
      { "id": "li-1", "type": "bulleted-list", "children": [{ "text": "Structured JSON content" }] },
      { "id": "li-2", "type": "bulleted-list", "children": [{ "text": "REST API with API key auth" }] },
      { "id": "li-3", "type": "bulleted-list", "children": [{ "text": "Optional static site generation" }] }
    ],
    "meta": { "order": 3, "depth": 0 }
  },
  "code-example": {
    "id": "code-example",
    "type": "Code",
    "value": [{
      "id": "el-code",
      "type": "code",
      "children": [{ "text": "curl -H \"Authorization: Bearer th_sk_live_...\" \\\n  https://tinyheadless.blog/api/v1/websites" }]
    }],
    "meta": { "order": 4, "depth": 0 }
  },
  "closing": {
    "id": "closing",
    "type": "Callout",
    "value": [{
      "id": "el-callout",
      "type": "callout",
      "children": [{ "text": "Tip: Use the embeddable editor to create content visually, then read back the contentJson to learn the format." }]
    }],
    "meta": { "order": 5, "depth": 0 }
  }
}

To create a post with this content in a single API call:

typescript
const res = await fetch(
  'https://tinyheadless.blog/api/v1/websites/WEBSITE_ID/posts',
  {
    method: 'POST',
    headers: {
      'Authorization': 'Bearer YOUR_API_KEY',
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      title: 'Getting Started with TinyHeadless',
      excerpt: 'Learn how TinyHeadless works as a headless CMS.',
      contentJson: { /* the JSON object above */ },
    }),
  },
)

// The response includes the full contentJson echoed back
const { data } = await res.json()

Rendering to HTML

If you use TinyHeadless with static site generation enabled, content is automatically rendered to HTML when a post is published. If you are using headless-only mode, you need to render the content yourself. Here is the rendering logic:

Rendering Rules

1.Sort all blocks by meta.order ascending
2.For each block, read type to determine the HTML element
3.For text blocks, iterate children and apply formatting flags
4.For media blocks (Image, Video, File), read URLs from element.props.src
5.For list blocks, each item in value becomes a <li>

Minimal renderer example (JavaScript):

typescript
function renderContentJson(contentJson) {
  const blocks = Object.values(contentJson)
  blocks.sort((a, b) => a.meta.order - b.meta.order)

  return blocks.map(block => {
    const el = block.value[0]
    if (!el) return ''

    const text = el.children
      .map(node => {
        let t = node.text ?? ''
        if (node.bold) t = `<strong>${t}</strong>`
        if (node.italic) t = `<em>${t}</em>`
        if (node.code) t = `<code>${t}</code>`
        return t
      })
      .join('')

    switch (block.type) {
      case 'Paragraph':    return `<p>${text}</p>`
      case 'HeadingOne':   return `<h1>${text}</h1>`
      case 'HeadingTwo':   return `<h2>${text}</h2>`
      case 'HeadingThree': return `<h3>${text}</h3>`
      case 'Blockquote':   return `<blockquote>${text}</blockquote>`
      case 'Code':         return `<pre><code>${text}</code></pre>`
      case 'Image':        return `<img src="${el.props?.src}" alt="${el.props?.alt ?? ''}" />`
      case 'Callout':      return `<aside>${text}</aside>`
      case 'Divider':      return '<hr />'
      case 'BulletedList':
        return '<ul>' + block.value.map(item =>
          '<li>' + item.children.map(n => n.text).join('') + '</li>'
        ).join('') + '</ul>'
      case 'NumberedList':
        return '<ol>' + block.value.map(item =>
          '<li>' + item.children.map(n => n.text).join('') + '</li>'
        ).join('') + '</ol>'
      default: return `<p>${text}</p>`
    }
  }).join('\n')
}

Tip: The easiest way to learn the content format is to use the embeddable post editor to create content visually, then call Get Post to see the resulting contentJson.


GET/api/v1/websites/:websiteId/authors

List Authors

Returns all authors for a website. There is no pagination — all authors are returned in a single response. Authors are optional profiles that can be linked to posts via authorId. Each author has a name, optional bio, and optional avatar.

typescript
const res = await fetch(
  'https://tinyheadless.blog/api/v1/websites/WEBSITE_ID/authors',
  { headers: { 'Authorization': 'Bearer YOUR_API_KEY' } },
)

const { data } = await res.json()

// Response:
// {
//   "data": [
//     {
//       "authorId": "auth-789",
//       "websiteId": "abc-123",
//       "name": "Jane Doe",
//       "bio": "Writer and developer",
//       "avatarUrl": "https://cdn.example.com/avatar.jpg",
//       "niceAvatarConfig": { "sex": "man", "faceColor": "#F9C9B6", ... },
//       "createdAt": "2025-01-10T08:00:00.000Z",
//       "updatedAt": "2025-01-10T08:00:00.000Z"
//     }
//   ]
// }
GET/api/v1/websites/:websiteId/authors/:authorId

Get Author

Returns a single author by ID.

typescript
const res = await fetch(
  'https://tinyheadless.blog/api/v1/websites/WEBSITE_ID/authors/AUTHOR_ID',
  { headers: { 'Authorization': 'Bearer YOUR_API_KEY' } },
)

const { data } = await res.json()

// data: { authorId, websiteId, name, bio, avatarUrl, niceAvatarConfig, createdAt, updatedAt }
POST/api/v1/websites/:websiteId/authors

Create Author

Creates a new author for a website. Use the returned authorId when creating or updating posts.

Request Body

namerequired — Author name (1-100 chars)
bioShort bio (max 500 chars)
avatarUrlAvatar image URL (use a presigned upload URL — automatically promoted on save)
niceAvatarConfigNiceAvatar configuration object for generated avatars
typescript
const res = await fetch(
  'https://tinyheadless.blog/api/v1/websites/WEBSITE_ID/authors',
  {
    method: 'POST',
    headers: {
      'Authorization': 'Bearer YOUR_API_KEY',
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      name: 'Jane Doe',
      bio: 'Writer and developer',
      avatarUrl: 'https://cdn.example.com/avatar.jpg',
    }),
  },
)

const { data } = await res.json()
// data: { authorId, websiteId, name, bio, avatarUrl, niceAvatarConfig, createdAt, updatedAt }
PUT/api/v1/websites/:websiteId/authors/:authorId

Update Author

Updates an existing author. Only include the fields you want to change.

Request Body (all optional)

nameAuthor name (1-100 chars)
bioShort bio (max 500 chars)
avatarUrlAvatar image URL (use a presigned upload URL — automatically promoted on save)
niceAvatarConfigNiceAvatar configuration object
typescript
const res = await fetch(
  'https://tinyheadless.blog/api/v1/websites/WEBSITE_ID/authors/AUTHOR_ID',
  {
    method: 'PUT',
    headers: {
      'Authorization': 'Bearer YOUR_API_KEY',
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      bio: 'Updated bio for Jane',
    }),
  },
)

const { data } = await res.json()
// data: updated author object
DELETE/api/v1/websites/:websiteId/authors/:authorId

Delete Author

Permanently deletes an author. Any posts linked to this author will have their authorId field automatically removed.

typescript
await fetch(
  'https://tinyheadless.blog/api/v1/websites/WEBSITE_ID/authors/AUTHOR_ID',
  {
    method: 'DELETE',
    headers: { 'Authorization': 'Bearer YOUR_API_KEY' },
  },
)

// { "data": { "deleted": true } }

POST/api/v1/uploads/presign

Presign Upload

Generates a presigned S3 PUT URL for uploading a file. The file is uploaded directly to S3 from the client (no server bandwidth used). The presigned URL expires after 10 minutes. Use the returned publicUrl anywhere — as post content images, post thumbnails, website logos, or author avatars. Uploads are automatically promoted from their temporary location to a permanent path when the resource is saved (post create/update, website create/update, author create/update). No manual promotion step is needed.

Request Body

filenamerequired — Original filename (used for extension)
contentTyperequired — MIME type (e.g. image/png)
bash
# Step 1: Get presigned URL
curl -X POST https://tinyheadless.blog/api/v1/uploads/presign \
  -H "Authorization: Bearer th_sk_live_..." \
  -H "Content-Type: application/json" \
  -d '{ "filename": "photo.jpg", "contentType": "image/jpeg" }'

# Step 2: Upload file directly to S3
curl -X PUT "<uploadUrl from response>" \
  -H "Content-Type: image/jpeg" \
  --data-binary @photo.jpg

JavaScript:

typescript
// Step 1: Get presigned URL
const presign = await fetch('https://tinyheadless.blog/api/v1/uploads/presign', {
  method: 'POST',
  headers: {
    'Authorization': 'Bearer YOUR_API_KEY',
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    filename: 'photo.jpg',
    contentType: 'image/jpeg',
  }),
})

const { data: { uploadUrl, key, publicUrl } } = await presign.json()

// Step 2: Upload file directly to S3
await fetch(uploadUrl, {
  method: 'PUT',
  headers: { 'Content-Type': 'image/jpeg' },
  body: file,  // File or Blob
})

// publicUrl is now accessible via CDN
// Use it as a post thumbnail, website logo, author avatar, or in post content
// Uploads are automatically promoted to a permanent path when the resource is saved

Embeddable Editors

TinyHeadless provides embeddable editors that you can drop into any website or app. The editors load in an iframe and communicate via postMessage. Your API key is passed securely — it never appears in URLs or server logs.

html
<script src="https://tinyheadless.blog/embed.js"></script>

The script exposes a global TinyHeadless object with two methods: editor() and customizer(). Both return an instance with a destroy() method to remove the iframe.

Post Editor Embed

Embeds the full post editor — rich text editing with the Yoopta block editor, image/video/file uploads, preview, save/publish buttons, and SEO metadata.

Options

apiKeyrequired — Your API key
websiteIdrequired — Target website
postIdPost ID to edit. Omit to create a new post.
onSaveCallback when the post is saved. Receives the post object.
onPublishCallback when the post is published.
html
<script src="https://tinyheadless.blog/embed.js"></script>
<div id="editor" style="height: 700px;"></div>
<script>
  // Edit an existing post
  const instance = TinyHeadless.editor('#editor', {
    apiKey: 'th_sk_live_...',
    websiteId: 'abc123',
    postId: 'post_456',  // omit to create a new post
    onSave: (post) => {
      console.log('Post saved:', post.title)
    },
    onPublish: (post) => {
      console.log('Post published:', post.slug)
    },
  })

  // Later: remove the editor
  // instance.destroy()
</script>

Layout Customizer Embed

Embeds the full theme customizer — layout picker, colors, typography, hero section, card styles, and a live preview. Changes are saved to the website and trigger a full static site rebuild.

Options

apiKeyrequired — Your API key
websiteIdrequired — Target website
pageWhich page to customize: "landing" (default) or "post"
onSaveCallback when branding is saved. Receives the branding object.
html
<script src="https://tinyheadless.blog/embed.js"></script>
<div id="customizer" style="height: 800px;"></div>
<script>
  const instance = TinyHeadless.customizer('#customizer', {
    apiKey: 'th_sk_live_...',
    websiteId: 'abc123',
    page: 'landing',  // or 'post'
    onSave: (branding) => {
      console.log('Theme saved:', branding)
    },
  })

  // Later: remove the customizer
  // instance.destroy()
</script>