# Postlark > API-first blog publishing platform ## Overview Postlark publishes, hosts, and optimizes blog content. It does NOT generate content. Users create content with their AI tools — Postlark handles publishing, SEO, OG images, CDN caching, and structured data automatically. ## Authentication Base URL: https://api.postlark.ai/v1 Auth header: Authorization: Bearer {API_KEY} API keys start with "pk_live_". Each key has scopes and optional blog binding. Multi-blog tokens: set X-Blog-Id header to target a specific blog. Interactive docs: https://api.postlark.ai/docs OpenAPI spec: https://api.postlark.ai/docs/openapi.json ## MCP Server Package: @postlark/mcp-server (npm) Install (Claude Code): claude mcp add postlark --env POSTLARK_API_KEY=pk_live_xxx -- npx @postlark/mcp-server 17 tools: create_post, update_post, list_posts, get_post, delete_post, publish_post, schedule_post, upload_image, list_blogs, set_active_blog, create_blog, update_blog, delete_blog, get_analytics, search_posts, discover_posts Note: MCP create_post defaults to status "published". REST API defaults to "draft". ## CLI Package: @postlark/cli (npm) Install: npm i -g @postlark/cli Login: postlark login Key commands: postlark posts list, postlark posts create, postlark deploy, postlark upload , postlark analytics --- ## POST /upload — Upload image Request body (JSON): - data: string (required) — Base64-encoded image data - filename: string (optional) — Original filename - content_type: string (required) — "image/jpeg" | "image/png" | "image/gif" | "image/webp" Alternative: multipart/form-data with "file" field. Response 201 (JSON): - url: string (CDN URL, e.g. "https://media.postlark.ai/{blogId}/{uuid}.jpg") Security: MIME type + magic bytes double validation, JPEG EXIF auto-stripped, UUID filenames, 5MB max. Rate limit: 10 uploads/minute per user. Scope: media:write ## POST /posts — Create post Request body (JSON): - title: string (required) — Post title - content: string (required) — Markdown content, max 1MB - slug: string (optional) — URL slug, auto-generated from title if omitted. Lowercase a-z0-9 hyphens, max 100 chars - tags: string[] (optional) — Max 10 items, each max 50 chars - status: "draft" | "published" (optional) — Default "draft" - meta: { description?: string, og_image?: string } (optional) Response 201 (JSON): - id: string (UUID) - slug: string - url: string (full URL, e.g. "https://my-blog.postlark.ai/my-post") - status: "draft" | "published" - created_at: string (ISO 8601) Errors: 400 (title/content required, content too large, invalid slug), 403 (post limit exceeded), 409 (slug taken) Scope: posts:write ## GET /posts — List posts Query params: - status: string (optional) — "draft" | "published" | "scheduled" - tag: string (optional) — Filter by tag name - page: integer (optional) — Default 1 - per_page: integer (optional) — Default 20, max 100 - sort: string (optional) — "created_at" (default) | "updated_at" | "published_at" - order: string (optional) — "desc" (default) | "asc" Response 200 (JSON): - data: array of objects: - id: string (UUID) - title: string - slug: string - status: "draft" | "published" | "scheduled" - tags: string[] - meta_description: string - published_at: string | null (ISO 8601) - created_at: string (ISO 8601) - updated_at: string (ISO 8601) - pagination: object: - page: integer - per_page: integer - total: integer - total_pages: integer Scope: posts:read ## GET /posts/:slug — Get single post Path param: slug (string) Response 200 (JSON): - id: string (UUID) - title: string - slug: string - content_md: string (Markdown source) - content_html: string (rendered HTML) - status: "draft" | "published" | "scheduled" - tags: string[] - meta_description: string - og_image_url: string | null - published_at: string | null (ISO 8601) - scheduled_at: string | null (ISO 8601) - created_at: string (ISO 8601) - updated_at: string (ISO 8601) Scope: posts:read ## PUT /posts/:slug — Update post (partial) Path param: slug (string) Request body (JSON, send only fields to change): - title: string (optional) - content: string (optional) — Markdown, max 1MB - slug: string (optional) — New slug - tags: string[] (optional) - meta: { description?: string, og_image?: string } (optional) Response 200 (JSON): Same shape as GET /posts/:slug Scope: posts:write ## DELETE /posts/:slug — Delete post Permanent. Removes from DB, KV cache, and CDN. Response 200 (JSON): - deleted: boolean (true) Scope: posts:write ## POST /posts/:slug/publish — Publish a draft Changes status from "draft" to "published". Writes to KV + CDN. Response 200 (JSON): - id: string (UUID) - slug: string - url: string - status: "published" - published_at: string (ISO 8601) Scope: posts:write ## POST /posts/:slug/schedule — Schedule post Requires Creator plan or above. Request body (JSON): - scheduled_at: string (required) — ISO 8601 datetime, must be in the future Response 200 (JSON): - slug: string - status: "scheduled" - scheduled_at: string (ISO 8601) Errors: 403 (Creator+ required), 400 (invalid/past datetime) Scope: posts:write --- ## POST /blogs — Create blog Request body (JSON): - slug: string (required) — Lowercase a-z0-9 hyphens, 1-63 chars. Some slugs are reserved. - name: string (required) — Max 200 chars - description: string (optional) — Max 1000 chars Response 201 (JSON): - id: string (UUID) - slug: string - name: string - url: string (e.g. "https://my-blog.postlark.ai") - created_at: string (ISO 8601) Blog limits: Free 1, Starter 1, Creator 3, Scale 5, Enterprise unlimited Errors: 400 (invalid slug, reserved name), 403 (blog limit exceeded), 409 (slug taken) Scope: blogs:write ## GET /blogs — List my blogs Response 200 (JSON): - data: array of objects: - id: string (UUID) - slug: string - name: string - description: string - custom_domain: string | null - created_at: string (ISO 8601) - updated_at: string (ISO 8601) Scope: blogs:read ## PUT /blogs/:id — Update blog settings Path param: id (string UUID) Request body (JSON, partial): - name: string (optional) - description: string (optional) - theme_config: object (optional): - customCss: string — CSS code (Starter+) - headerHtml: string — Header HTML (Creator+) - footerHtml: string — Footer HTML (Creator+) - adCode: string — Ad slot HTML (Starter+) Response 200 (JSON): Updated blog object Scope: blogs:write ## DELETE /blogs/:id — Delete blog Permanent. Deletes all posts, API keys, KV data, R2 images. Response 200 (JSON): - deleted: boolean (true) Scope: blogs:write --- ## POST /blogs/:id/api-keys — Generate API key Path param: id (string UUID) Request body (JSON): - name: string (optional) — Default "Default", max 100 chars - expires_in: integer | null (optional) — Days until expiration Response 201 (JSON): - id: string (UUID) - name: string - key: string (pk_live_xxx — shown only once, save immediately) - prefix: string (first 12 chars) - scopes: string[] (e.g. ["*"]) - expires_at: string | null (ISO 8601) - created_at: string (ISO 8601) Scope: blogs:write ## GET /blogs/:id/api-keys — List API keys Response 200 (JSON): - data: array of objects: - id: string (UUID) - name: string - key_prefix: string - scopes: string[] - expires_at: string | null (ISO 8601) - last_used_at: string | null (ISO 8601) - created_at: string (ISO 8601) Scope: blogs:read ## DELETE /blogs/:id/api-keys/:keyId — Revoke key Soft delete (marks as revoked, key stops working immediately). Response 200 (JSON): - revoked: boolean (true) Scope: blogs:write --- ## POST /account/tokens — Create scoped access token Request body (JSON): - name: string (required) — Token name, max 100 chars - scopes: string[] (optional) — Default ["*"]. Valid values: *, posts:read, posts:write, blogs:read, blogs:write, analytics:read, search:read, account:read, account:write, packs:read, packs:write, domains:read, domains:write - blog_id: string | null (optional) — Bind to specific blog UUID. Null = access all blogs. - expires_in: integer | null (optional) — Days until expiration. Null = never. Response 201 (JSON): - id: string (UUID) - name: string - key: string (pk_live_xxx — shown only once) - prefix: string - scopes: string[] - blog_id: string | null - expires_at: string | null (ISO 8601) - created_at: string (ISO 8601) Scope: account:write ## GET /account/tokens — List all tokens Response 200 (JSON): - data: array of objects: - id: string (UUID) - name: string - key_prefix: string - scopes: string[] - blog_id: string | null - expires_at: string | null (ISO 8601) - last_used_at: string | null (ISO 8601) - created_at: string (ISO 8601) Scope: account:read ## DELETE /account/tokens/:id — Revoke token Response 200 (JSON): - revoked: boolean (true) Scope: account:write --- ## GET /account/profile — Current user profile Response 200 (JSON): - id: string (UUID) - email: string - name: string - plan: "free" | "starter" | "creator" | "scale" | "enterprise" - created_at: string (ISO 8601) Scope: account:read ## GET /account/export — Export all data (GDPR) Max 5000 posts. Response 200 (JSON): - exported_at: string (ISO 8601) - user_id: string (UUID) - blogs: array of objects: - blog: { slug: string, name: string, description: string } - posts: array of { slug: string, title: string, content_md: string, tags: string[], status: string, created_at: string } - total_posts: integer - truncated: boolean (optional, present if total_posts >= 5000) Scope: account:read ## POST /account/delete — Delete account (GDPR) Permanently deletes: all blogs + posts, API keys, KV data, R2 images, Paddle subscriptions, auth user. Response 200 (JSON): - deleted: boolean (true) - message: string Scope: account:write --- ## GET /search?q=keyword — Full-text search (own blog) Query params: - q: string (required) — Search query, max 100 chars - page: integer (optional) — Default 1 - per_page: integer (optional) — Default 20, max 50 Searches published posts in your blog. FTS with 4-weight index: title(A) + description(B) + headings(C) + tags(D). Response 200 (JSON): - data: array of objects: - slug: string - title: string - excerpt: string (first ~200 chars) - tags: string[] - created_at: string (ISO 8601) - pagination: { page: integer, per_page: integer, total: integer, total_pages: integer } Scope: search:read ## GET /v1/discover?q=keyword — Public discovery (no auth) Searches ALL published posts across ALL Postlark blogs. No authentication required. IP rate limited (120/hr). FTS with 4-weight index: title(A) + description(B) + headings(C) + tags(D). Query params: - q: string (required) — Search query, max 100 chars - tag: string (optional) — Filter by tag - page: integer (optional) — Default 1 - per_page: integer (optional) — Default 20, max 50 Response 200 (JSON): - data: array of objects: - title: string - slug: string - excerpt: string - tags: string[] - created_at: string (ISO 8601) - blog_name: string - blog_domain: string - url: string (full URL) - llms_txt_url: string | null (post-level llms.txt, Creator+ blogs only) - pagination: { page, per_page, total, total_pages } --- ## GET /analytics/overview — Blog analytics Requires Starter plan or above. Response 200 (JSON): - total_views: integer - daily_views: array of { date: string (YYYY-MM-DD), views: integer } - top_posts: array of { slug: string, title: string, views: integer } Scope: analytics:read ## GET /analytics/posts/:slug — Post analytics Requires Starter plan or above. Response 200 (JSON): - slug: string - title: string - total_views: integer - daily_views: array of { date: string (YYYY-MM-DD), views: integer } Scope: analytics:read --- ## POST /packs/purchase — Buy Post Pack Requires Starter plan or above. Request body (JSON): - pack_type: "100" | "300" (required) Response 200 (JSON): - purchased: boolean (true) - pack_type: string - balance: integer (new total remaining) Scope: packs:write ## GET /packs/balance — Check pack balance Response 200 (JSON): - total_remaining: integer - packs: array of { id: string, balance: integer, purchased_at: string (ISO 8601) } Scope: packs:read --- ## Custom Domain API ## POST /blogs/:id/domain — Register custom domain Requires Starter plan or above. Request body (JSON): - hostname: string (required) — e.g. "blog.example.com" Response 200 (JSON): - hostname: string - status: "pending" | "active" - cname_target: string (e.g. "postlark-blog.workers.dev") - instructions: string Scope: domains:write ## GET /blogs/:id/domain — Get domain status Response 200 (JSON): - hostname: string - status: "pending" | "active" | "none" - cname_target: string | null Scope: domains:read ## DELETE /blogs/:id/domain — Remove custom domain Response 200 (JSON): - removed: boolean (true) Scope: domains:write --- ## Scopes Reference Tokens support fine-grained permissions: - * — Full access (all resources, all actions) - posts:read — List posts, get post - posts:write — Create, update, delete, publish, schedule posts - blogs:read — List blogs, get blog settings, list API keys - blogs:write — Create, update, delete blogs, generate/revoke API keys - analytics:read — View analytics (Starter+) - search:read — Search posts - account:read — View profile, export data, list tokens - account:write — Delete account, create/revoke tokens - packs:read — View pack balance - packs:write — Purchase packs - domains:read — View domain status - domains:write — Register/remove custom domains - media:read — (reserved) - media:write — Upload images ## Rate Limits Per user (not per token): Free 60/hr, Starter 300/hr, Creator 1000/hr, Scale 10000/hr, Enterprise 10000+/hr Response headers: X-RateLimit-Limit (integer), X-RateLimit-Remaining (integer), X-RateLimit-Reset (unix timestamp seconds) 429 response includes: { "error": "rate_limit_exceeded", "message": "...", "retry_after": integer (seconds) } ## Error Format All errors return JSON: { "error": string (error code), "message": string (human-readable) } Codes: 400 bad_request, 401 unauthorized, 403 forbidden, 404 not_found, 409 conflict, 429 rate_limit_exceeded, 500 internal_error 403 may include: { "upgrade_url": "https://app.postlark.ai/upgrade" } ## Image Workflow Step 1: Upload image → POST /upload (returns { url: "https://media.postlark.ai/{blogId}/{uuid}.{ext}" }) Step 2: Use the URL in Markdown → ![alt text](url) Step 3: Include in post content → POST /posts with content containing the image markdown Example workflow: 1. POST /upload with image → { "url": "https://media.postlark.ai/abc/123.jpg" } 2. POST /posts with content: "# My Post\n\n![photo](https://media.postlark.ai/abc/123.jpg)\n\nSome text." Dashboard: drag-and-drop into editor handles steps 1-2 automatically. CLI: `postlark upload photo.jpg` returns URL + ready-to-use Markdown syntax. MCP: `upload_image` tool returns URL + Markdown syntax. Supported formats: JPEG, PNG, GIF, WebP. Max 5MB. JPEG EXIF auto-stripped. ## Markdown Extensions 11 extensions: heading-id, toc, footnotes, math (KaTeX), mermaid diagrams, callouts, emoji, task lists, enhanced code blocks (title, line numbers, diff), syntax highlighting (highlight.js), YouTube embed. YouTube embed: paste a YouTube URL on its own line → auto-converted to responsive iframe (youtube-nocookie.com). Supported URL formats: youtube.com/watch?v=ID, youtu.be/ID, youtube.com/embed/ID. ## Content Limits - Post content: max 1,048,576 bytes (1MB) - Tags per post: max 10 - Tag length: max 50 chars - Blog slug: max 63 chars (lowercase a-z0-9 hyphens) - Post slug: max 100 chars - Blog name: max 200 chars - Blog description: max 1000 chars - API key name: max 100 chars - Search query: max 100 chars - Data export: max 5000 posts - Pagination: max 100 per page (search: max 50) ## Plans - Free $0: 10 posts total, 1 blog, subdomain (noindex), 60 req/hr - Starter $9/mo: 15 posts/month, 1 blog, custom domain, SEO indexing, basic analytics, custom CSS, ad slot, 300 req/hr - Creator $29/mo: 50 posts/month, 3 blogs, scheduled posts, advanced analytics, header/footer HTML, badge removal, webhooks, 1000 req/hr - Scale $79/mo: unlimited posts, 5 blogs, white-label, full HTML+CSS control, programmatic SEO, SLA 99.9%, 10000 req/hr - Enterprise $199+/mo: unlimited everything, unlimited blogs, dedicated support, custom SLA - Post Packs add-on (Starter+): 100 posts for $10, 300 posts for $25. Never expire. Consumed after monthly quota. ## Links - Docs: https://docs.postlark.ai - API Reference (Scalar UI): https://api.postlark.ai/docs - OpenAPI 3.0 spec: https://api.postlark.ai/docs/openapi.json - Landing: https://postlark.ai - Dashboard: https://app.postlark.ai - MCP Server (npm): https://www.npmjs.com/package/@postlark/mcp-server - MCP Server (GitHub): https://github.com/postlark/mcp-server