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.
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.
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
{
"data": { ... }
}Error
{
"error": "Unauthorized"
}Status Codes
The API uses standard HTTP status codes to indicate the outcome of a request.
| Code | Meaning |
|---|---|
200 | OK — request succeeded |
201 | Created — resource was created (POST) |
400 | Bad Request — invalid or missing parameters. Response includes issues array with field-level details. |
401 | Unauthorized — missing or invalid API key |
403 | Forbidden — valid key but insufficient permissions (e.g. plan limit reached) |
404 | Not Found — resource does not exist or does not belong to your account |
409 | Conflict — resource already exists (e.g. duplicate handle) |
429 | Too Many Requests — monthly rate limit exceeded. See Rate Limits. |
500 | Internal 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.
// 429 Too Many Requests
{
"error": "Monthly API request limit exceeded",
"limit": 10000,
"current": 10001
}
// Header: Retry-After: 86400/api/v1/websitesCreate 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)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:
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.
/api/v1/websitesList 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.
curl https://tinyheadless.blog/api/v1/websites \ -H "Authorization: Bearer th_sk_live_..."
JavaScript:
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"
// }
// ]
// }/api/v1/websites/:websiteIdGet Website
Returns a single website with full details including branding configuration and logo URL. Used by the embeddable editors.
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"
// }
// }/api/v1/websites/:websiteIdUpdate 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-onlystatus"active" (published) or "disabled" (unpublished)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 fieldSide 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.
/api/v1/websites/:websiteId/brandingUpdate 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.
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/api/v1/websites/:websiteIdDelete 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.
curl -X DELETE https://tinyheadless.blog/api/v1/websites/WEBSITE_ID \ -H "Authorization: Bearer th_sk_live_..."
JavaScript:
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.
/api/v1/websites/:websiteId/postsList 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 allcategoryFilter by category name. Returns posts that include this category. Omit to return all.slugFilter by slug. Returns 0 or 1 matching post.// 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' } },
)/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.
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/api/v1/websites/:websiteId/posts/:postIdGet Post
Returns a single post with the full contentJson field containing the Yoopta editor JSON. Works for both draft and published posts.
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"
// }
// }/api/v1/websites/:websiteId/postsCreate 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 postmetaTitleSEO meta title override (max 200 chars)metaDescriptionSEO meta description override (max 500 chars)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):
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):
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 }
}
},
}),
},
)/api/v1/websites/:websiteId/posts/:postIdUpdate 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 titleslugURL slugcategoryArray of up to 3 category stringsexcerptShort summarycontentMarkdown 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 poststatus"draft" (unpublish) or "published" (publish)metaTitleSEO meta title overridemetaDescriptionSEO meta description override# 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:
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.
/api/v1/websites/:websiteId/posts/:postIdDelete 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).
curl -X DELETE https://tinyheadless.blog/api/v1/websites/WEBSITE_ID/posts/POST_ID \ -H "Authorization: Bearer th_sk_live_..."
JavaScript:
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.
{
"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 keytyperequired — Block type (e.g. "Paragraph", "HeadingOne", "Image")valuerequired — Array of elements, each with id, type, children, and optional propsmetarequired — order (render position, 0-based) and depth (nesting level, usually 0)// 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 Type | Element Type | Description |
|---|---|---|
Paragraph | paragraph | Standard text paragraph |
HeadingOne | heading-one | H1 heading |
HeadingTwo | heading-two | H2 heading |
HeadingThree | heading-three | H3 heading |
Blockquote | blockquote | Block quote |
BulletedList | bulleted-list | Unordered list — one element per item in value |
NumberedList | numbered-list | Ordered list — one element per item in value |
TodoList | todo-list | Checklist with checkable items |
Code | code | Code block (preformatted text) |
Image | image | Image — uses props.src and props.alt |
Video | video | Video — uses props.src |
File | file | File attachment — uses props.src, props.name, props.size |
Embed | embed | Embedded content (YouTube, tweets, etc.) |
Table | table | Data table |
Callout | callout | Highlighted callout box |
Divider | divider | Horizontal rule / separator |
Accordion | accordion | Collapsible section |
Image block example:
{
"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):
{
"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.
| Flag | Renders As | Example |
|---|---|---|
bold | Bold text | { "text": "important", "bold": true } |
italic | Italic text | { "text": "emphasis", "italic": true } |
underline | Underlined text | { "text": "note", "underline": true } |
strikethrough | { "text": "old", "strikethrough": true } | |
code | Inline code | { "text": "const x", "code": true } |
highlight | Highlighted | { "text": "key point", "highlight": true } |
Mixed formatting in a paragraph:
{
"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.
{
"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:
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
meta.order ascendingtype to determine the HTML elementchildren and apply formatting flagsImage, Video, File), read URLs from element.props.srcvalue becomes a <li>Minimal renderer example (JavaScript):
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.
/api/v1/uploads/presignPresign 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)# 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.jpgJavaScript:
// 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 savedEmbeddable 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.
<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 keywebsiteIdrequired — Target websitepostIdPost 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.<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 keywebsiteIdrequired — Target websitepageWhich page to customize: "landing" (default) or "post"onSaveCallback when branding is saved. Receives the branding object.<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>