# Getting Started with Postlark ## 1. Sign Up Go to https://app.postlark.ai/signup — sign in with Google or GitHub. ## 2. Create a Blog In the Dashboard, pick a slug (e.g. "my-blog") and name. Blog is live instantly at my-blog.postlark.ai. No API key needed for this step. ## 3. Get an API Key Dashboard → Settings → API Keys → Generate Key. Copy the key (shown only once). ## 4. Publish Your First Post ### Via Dashboard Dashboard → Posts → New → Write Markdown → Publish. ### Via Claude Code (MCP) claude mcp add postlark --env POSTLARK_API_KEY=pk_live_your_key -- npx @postlark/mcp-server claude "Write a hello world post and publish it on Postlark" ### Via Claude Desktop Add to claude_desktop_config.json: { "mcpServers": { "postlark": { "command": "npx", "args": ["@postlark/mcp-server"], "env": { "POSTLARK_API_KEY": "pk_live_your_key" } } } } Config locations: macOS ~/Library/Application Support/Claude/, Windows %APPDATA%\Claude/, Linux ~/.config/Claude/ ### Via REST API curl -X POST https://api.postlark.ai/v1/posts \ -H "Authorization: Bearer pk_live_your_key" \ -H "Content-Type: application/json" \ -d '{"title": "Hello World", "content": "# Hello\n\nMy first post.", "status": "published"}' Note: API defaults to "draft". MCP defaults to "published". ## 5. Visit Your Blog Open https://my-blog.postlark.ai. Post is live with automatic SEO meta tags, OG image, and JSON-LD. Free plan: subdomain with noindex. Starter ($9/mo)+: custom domain with full SEO indexing. --- # Authentication Base URL: https://api.postlark.ai/v1 ## API Key Header: Authorization: Bearer pk_live_xxxxxxxxxxxx Generate in Dashboard → Settings → API Keys. Each key is bound to a specific blog (or all blogs for multi-blog tokens). ## JWT Session (Dashboard only) Header: Authorization: Bearer eyJxxx... Used by the Dashboard internally. For external integrations, always use API keys. When using JWT, specify blog with X-Blog-Id header (optional, auto-selects first blog if omitted). ## Scopes 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 ## Token Expiration API keys do not expire by default. Optional expiration can be set at creation (expires_in days). JWT tokens expire per Supabase Auth settings (default 1 hour, auto-refreshed by Dashboard). Revoked/expired tokens return 401. ## Rate Limits Per user (not per token): - Free: 60/hr - Starter: 300/hr - Creator: 1,000/hr - Scale: 10,000/hr - Enterprise: 10,000+/hr Response headers: X-RateLimit-Limit (integer), X-RateLimit-Remaining (integer), X-RateLimit-Reset (unix timestamp) 429 response: { "error": "rate_limit_exceeded", "message": "...", "retry_after": integer (seconds) } ## Error Format All errors: { "error": string, "message": string } Status codes: 400 bad_request, 401 unauthorized, 403 forbidden, 404 not_found, 409 conflict, 429 rate_limit_exceeded, 500 internal_error --- # Posts API ## 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. 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: - id: string (UUID) - slug: string - url: string - status: "draft" | "published" - created_at: string (ISO 8601) Errors: 400, 403 (post limit), 409 (slug taken) Scope: posts:write ## GET /posts — List posts Query: status? ("draft"|"published"|"scheduled"), tag? (string), page? (int, default 1), per_page? (int, default 20 max 100), sort? ("created_at"|"updated_at"|"published_at"), order? ("desc"|"asc") Response 200: - data: array of: - 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: { page: int, per_page: int, total: int, total_pages: int } Scope: posts:read ## GET /posts/:slug — Get single post Response 200: - 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) Request body (JSON, send only fields to change): - title: string (optional) - content: string (optional) - slug: string (optional) - tags: string[] (optional) - meta: { description?: string, og_image?: string } (optional) Response 200: Same as GET /posts/:slug Scope: posts:write ## DELETE /posts/:slug — Delete post Permanent. Removes from DB, KV cache, CDN. Response 200: { deleted: boolean (true) } Scope: posts:write ## POST /posts/:slug/publish — Publish a draft Response 200: - id: string (UUID) - slug: string - url: string - status: "published" - published_at: string (ISO 8601) Scope: posts:write ## POST /posts/:slug/schedule — Schedule post (Creator+) Request: { scheduled_at: string (ISO 8601, future) } Response 200: { slug: string, status: "scheduled", scheduled_at: string } Errors: 403 (Creator+ required), 400 (past datetime) Scope: posts:write --- # Blogs API ## POST /blogs — Create blog Request: { slug: string (required, a-z0-9 hyphens, 1-63 chars), name: string (required, max 200), description?: string (max 1000) } Response 201: { id: string (UUID), slug: string, name: string, url: string, created_at: string (ISO 8601) } Blog limits: Free 1, Starter 1, Creator 3, Scale 5, Enterprise unlimited Errors: 400 (invalid/reserved slug), 403 (limit), 409 (slug taken) Scope: blogs:write ## GET /blogs — List my blogs Response 200: { data: [{ id: string, slug: string, name: string, description: string, custom_domain: string|null, created_at: string, updated_at: string }] } Scope: blogs:read ## PUT /blogs/:id — Update blog Request (partial): { name?: string, description?: string, theme_config?: { customCss?: string, headerHtml?: string, footerHtml?: string, adCode?: string } } Response 200: Updated blog object Scope: blogs:write ## DELETE /blogs/:id — Delete blog Permanent. Deletes all posts, API keys, KV data, R2 images. Response 200: { deleted: boolean (true) } Scope: blogs:write ## POST /blogs/:id/api-keys — Generate API key Request: { name?: string (max 100), expires_in?: integer (days) } Response 201: { id: string, name: string, key: string (pk_live_xxx, shown once), prefix: string, scopes: string[], expires_at: string|null, created_at: string } Scope: blogs:write ## GET /blogs/:id/api-keys — List API keys Response 200: { data: [{ id: string, name: string, key_prefix: string, scopes: string[], expires_at: string|null, last_used_at: string|null, created_at: string }] } Scope: blogs:read ## DELETE /blogs/:id/api-keys/:keyId — Revoke key Response 200: { revoked: boolean (true) } Scope: blogs:write ## POST /blogs/:id/domain — Register custom domain (Starter+) Request: { hostname: string } Response 200: { hostname: string, status: "pending"|"active", cname_target: string, instructions: string } Scope: domains:write ## GET /blogs/:id/domain — Domain status Response 200: { hostname: string, status: "pending"|"active"|"none", cname_target: string|null } Scope: domains:read ## DELETE /blogs/:id/domain — Remove domain Response 200: { removed: boolean (true) } Scope: domains:write --- # Account API ## GET /account/profile — User profile Response 200: { id: string (UUID), email: string, name: string, plan: "free"|"starter"|"creator"|"scale"|"enterprise", created_at: string (ISO 8601) } Scope: account:read ## POST /account/tokens — Create scoped access token Request: { name: string (required, max 100), scopes?: string[] (default ["*"]), blog_id?: string|null (null=all blogs), expires_in?: integer|null (days, null=never) } Valid scopes: *, posts:read, posts:write, blogs:read, blogs:write, analytics:read, search:read, account:read, account:write, packs:read, packs:write, domains:read, domains:write Response 201: { id: string, name: string, key: string (pk_live_xxx, shown once), prefix: string, scopes: string[], blog_id: string|null, expires_at: string|null, created_at: string } Scope: account:write ## GET /account/tokens — List all tokens Response 200: { data: [{ id: string, name: string, key_prefix: string, scopes: string[], blog_id: string|null, expires_at: string|null, last_used_at: string|null, created_at: string }] } Scope: account:read ## DELETE /account/tokens/:id — Revoke token Response 200: { revoked: boolean (true) } Scope: account:write ## GET /account/export — Export all data (GDPR) Max 5000 posts. Response 200: { exported_at: string, user_id: string, blogs: [{ blog: { slug: string, name: string, description: string }, posts: [{ slug: string, title: string, content_md: string, tags: string[], status: string, created_at: string }] }], total_posts: integer, truncated?: boolean } Scope: account:read ## POST /account/delete — Delete account (GDPR) Deletes: all blogs, posts, API keys, KV data, R2 images, Paddle subscriptions, auth user. Response 200: { deleted: boolean (true), message: string } Scope: account:write ## GET /search?q=keyword — Full-text search Query: q (required, max 100), page? (int), per_page? (int, max 50) Searches published posts only (PostgreSQL FTS). Response 200: { data: [{ slug: string, title: string, excerpt: string, tags: string[], created_at: string }], pagination: { page: int, per_page: int, total: int, total_pages: int } } Scope: search:read ## GET /analytics/overview — Blog analytics (Starter+) Response 200: { total_views: integer, daily_views: [{ date: string, views: integer }], top_posts: [{ slug: string, title: string, views: integer }] } Scope: analytics:read ## GET /analytics/posts/:slug — Post analytics (Starter+) Response 200: { slug: string, title: string, total_views: integer, daily_views: [{ date: string, views: integer }] } Scope: analytics:read ## POST /packs/purchase — Buy Post Pack (Starter+) Request: { pack_type: "100"|"300" } Response 200: { purchased: boolean, pack_type: string, balance: integer } Scope: packs:write ## GET /packs/balance — Pack balance Response 200: { total_remaining: integer, packs: [{ id: string, balance: integer, purchased_at: string }] } Scope: packs:read --- # MCP Server (@postlark/mcp-server) ## Installation ### Claude Code (CLI) claude mcp add postlark --env POSTLARK_API_KEY=pk_live_your_key -- npx @postlark/mcp-server ### Claude Desktop Add to claude_desktop_config.json (Settings → Developer → Edit Config): { "mcpServers": { "postlark": { "command": "npx", "args": ["@postlark/mcp-server"], "env": { "POSTLARK_API_KEY": "pk_live_your_key" } } } } Config locations: - macOS: ~/Library/Application Support/Claude/claude_desktop_config.json - Windows: %APPDATA%\Claude\claude_desktop_config.json - Linux: ~/.config/Claude/claude_desktop_config.json Note: MCP create_post defaults to "published". REST API defaults to "draft". ## 9 Tools ### create_post — Create and publish a post Params: title (string, required), content (string, required), slug (string, optional), tags (string[], optional), status ("draft"|"published", optional, default "published") ### update_post — Update existing post Params: slug (string, required), title (string, optional), content (string, optional), tags (string[], optional) ### list_posts — List posts with filters Params: status (string, optional), tag (string, optional), page (integer, optional), per_page (integer, optional) ### get_post — Get single post with full content Params: slug (string, required) ### delete_post — Permanently delete a post Params: slug (string, required) ### schedule_post — Schedule for future publication (Creator+) Params: slug (string, required), scheduled_at (string, ISO 8601, required) ### list_blogs — List all accessible blogs Params: none ### set_active_blog — Switch active blog for multi-blog tokens Params: blog_id (string, required) ### get_analytics — View blog analytics (Starter+) Params: period (string, optional, "7d"|"30d"|"90d") ## Multi-Blog Workflow 1. list_blogs → see all blogs 2. set_active_blog(blog_id) → switch context 3. create_post / list_posts / etc. → targets the active blog If token is bound to a single blog, set_active_blog is not needed. --- # Markdown Features Postlark supports GitHub Flavored Markdown (GFM) plus 10 extensions. ## Basic GFM Headings (#, ##, ###), bold (**text**), italic (*text*), links [text](url), images ![alt](url), ordered/unordered lists, tables (|col|col|), blockquotes (>), inline code (`code`), fenced code blocks (```lang), horizontal rules (---), strikethrough (~~text~~). ## 10 Extensions ### 1. Heading IDs + Anchors All headings get auto-generated IDs. Clickable # anchor links. Example: ## My Section →

#My Section

### 2. Table of Contents Place [TOC] or [toc] anywhere. Generates navigation from h1-h3 headings. ### 3. Footnotes Syntax: text[^1] ... [^1]: Footnote definition Rendered at bottom of page with back-links. ### 4. Math (KaTeX) Inline: $E = mc^2$ → rendered inline math Block: $$\int_0^\infty e^{-x} dx = 1$$ → centered block math Also: ```math or ```latex fenced blocks. CDN: KaTeX. ### 5. Mermaid Diagrams ```mermaid graph LR A --> B --> C ``` Rendered as SVG. Auto dark mode. CDN: Mermaid. ### 6. Callouts (5 types) > [!NOTE] text → blue, info icon > [!TIP] text → green, lightbulb > [!IMPORTANT] text → purple, important > [!WARNING] text → yellow, warning > [!CAUTION] text → red, danger ### 7. Emoji Shortcodes :rocket: → 🚀, :fire: → 🔥, :check: → ✅, :heart: → ❤️, etc. (46 supported) ### 8. Task Lists - [x] Done item (styled checkbox, read-only) - [ ] Pending item ### 9. Enhanced Code Blocks ```typescript title="example.ts" showLineNumbers const x = 1; ``` Features: title bar, line numbers, diff highlighting (+/- lines). ### 10. Syntax Highlighting Language-specific highlighting via highlight.js CDN. Copy button on hover. ## Content Limits Post content: max 1MB. Tags: max 10, each max 50 chars. ## Security HTML sanitized: script, iframe, object, embed, form, input tags removed. on* event handlers removed. javascript: URLs removed. --- # Guides ## SEO Postlark auto-generates all SEO essentials on publish: - Meta title + description (auto-extracted from content, first 2 sentences, max 160 chars) - Open Graph tags (og:title, og:description, og:image, og:type) - Twitter Card tags - JSON-LD structured data (Article schema with author, dates, keywords) - Canonical URL (auto-set to blog domain) - Sitemap (/sitemap.xml, auto-updated) - RSS feed (/rss.xml) - Language detection (auto html lang attribute from content) - 0 KB JavaScript to readers (fast load, better SEO) Custom domain (Starter+): full Google indexing. Free subdomain: noindex by default. Subdomain → custom domain: automatic 301 redirect. ## Custom Domain (Starter+) Setup via Dashboard or API: 1. Dashboard → Settings → Domain → enter hostname (e.g. blog.example.com) 2. Add CNAME record at DNS provider: blog → {slug}.postlark.ai 3. SSL auto-provisioned API: POST /blogs/:id/domain { hostname: "blog.example.com" } Status check: GET /blogs/:id/domain Remove: DELETE /blogs/:id/domain ## Theme Customization Customize via Dashboard → Settings → Theme, or via PUT /blogs/:id API. ### Custom CSS (Starter+) Add CSS that overrides the default blog theme. Available CSS variables: --bg, --bg-secondary, --text, --text-secondary, --border, --accent, --code-bg ### Header/Footer HTML (Creator+) Add custom HTML to blog header and footer. Script/iframe/on* handlers are sanitized. ### Ad Code (Starter+) Insert ad slot HTML (Google AdSense, etc.) below post content. ### Badge Free/Starter: "Powered by Postlark" badge displayed. Creator+: badge removed. ## Markdown Features See: https://docs.postlark.ai/llms/markdown.txt GFM + 10 extensions: heading anchors, TOC, footnotes, math (KaTeX), mermaid diagrams, callouts (5 types), emoji shortcodes, task lists, enhanced code blocks, syntax highlighting. --- # Plans & Limits ## 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, programmatic SEO, SLA 99.9%, 10000 req/hr - Enterprise $199+/mo: unlimited everything, unlimited blogs, dedicated support, custom SLA ## Feature Matrix Feature | Free | Starter | Creator | Scale | Enterprise API + MCP access | yes | yes | yes | yes | yes Custom domain | no | yes | yes | yes | yes Custom CSS | no | yes | yes | yes | yes Ad slot | no | yes | yes | yes | yes Basic analytics | no | yes | yes | yes | yes Scheduled posts | no | no | yes | yes | yes Header/footer HTML | no | no | yes | yes | yes Badge removal | no | no | yes | yes | yes Advanced analytics | no | no | yes | yes | yes White-label | no | no | no | yes | yes Full HTML+CSS | no | no | no | yes | yes Dedicated support | no | no | no | no | yes ## 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 (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) ## Post Packs (Starter+ add-on) - 100 posts for $10 - 300 posts for $25 - Never expire. Consumed after monthly quota is exhausted.