# Postbreeze — full documentation This file is a single-document mirror of the entire Postbreeze docs site, generated for LLM ingestion. The canonical version lives at https://docs.postbreeze.ai. For a short index, see https://postbreeze.ai/llms.txt. Every page is rendered in the same order as the docs sidebar. Sections are separated by `---` on its own line. --- # Quickstart > Schedule your first post in under five minutes. Postbreeze is a social media scheduling platform that lets you manage and publish content across all major platforms from a single API. ## Install the SDK ```bash npm npm install @postbreeze/node ``` ```bash pnpm pnpm add @postbreeze/node ``` ```bash yarn yarn add @postbreeze/node ``` ## Authentication Postbreeze authenticates every request with a Bearer API key. Keys look like `pb_live_` and are mintable from the dashboard. ### Getting your API key Open **Settings → Developers → New API key** in the [dashboard](https://postbreeze.ai/dev/api-keys). Leave **Full access** toggled ON for the simplest setup — the key will work across every workspace you belong to. ### Set up the client ```ts import Postbreeze from "@postbreeze/node"; const postbreeze = new Postbreeze({ apiKey: process.env.POSTBREEZE_API_KEY!, }); ``` ## Key concepts Four foundational primitives in Postbreeze: - **Workspaces** — containers that group connected accounts, posts, and team members together (think "brands" or "clients") - **Social accounts** — your connected Instagram, X, TikTok, etc., belonging to a workspace - **Posts** — content to publish, schedulable to multiple accounts across platforms simultaneously - **Media** — images and videos uploaded to your library, reusable across posts ## Step 1: Create a workspace A workspace is the top-level tenant. If you're an agency managing multiple brands, create one workspace per brand. ```ts SDK const workspace = await postbreeze.workspaces.create({ name: "Acme Co.", }); console.log(workspace.id); ``` ```bash cURL curl -X POST https://api.postbreeze.ai/api/v1/workspaces \ -H "Authorization: Bearer $POSTBREEZE_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "name": "Acme Co." }' ``` Save the `id` value — you'll need it for the next step. ## Step 2: Connect a social account Now connect a social media account to the workspace. This uses OAuth, so it returns an `authorizeUrl` that the user opens in a browser to grant access. ```ts SDK const { authorizeUrl } = await postbreeze.socialAccounts.connect({ workspaceId: workspace.id, platform: "INSTAGRAM", }); console.log("Open this URL:", authorizeUrl); ``` ```bash cURL curl -X POST https://api.postbreeze.ai/api/v1/social-accounts/connect \ -H "Authorization: Bearer $POSTBREEZE_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "workspaceId": "wsp_…", "platform": "INSTAGRAM" }' ``` Open the URL in a browser to authorize Postbreeze. After authorization, the platform redirects back and the account is connected to your workspace. ### Available platforms `INSTAGRAM`, `FACEBOOK_PAGE`, `X`, `LINKEDIN_PERSON`, `LINKEDIN_COMPANY`, `TIKTOK_PERSONAL`, `TIKTOK_BUSINESS`, `YOUTUBE`, `PINTEREST`, `THREADS`, `BLUESKY`. ## Step 3: Get your connected accounts List the accounts your key can act on. By default the call returns **every account across every workspace** — each row carries its own `workspaceId`. ```ts SDK const accounts = await postbreeze.socialAccounts.list(); for (const account of accounts) { console.log(`${account.platform}: @${account.handle} (${account.id})`); } ``` ```bash cURL curl https://api.postbreeze.ai/api/v1/social-accounts \ -H "Authorization: Bearer $POSTBREEZE_API_KEY" ``` To narrow the list to a single workspace, pass `workspaceId`: ```ts const acmeAccounts = await postbreeze.socialAccounts.list({ workspaceId: workspace.id, }); ``` ## Step 4: Schedule your first post Use an `accountId` from the previous step. The workspace is derived from the account — no `workspaceId` argument needed here. ```ts SDK const post = await postbreeze.posts.create({ content: "Hello from the Postbreeze API! 👋", scheduledFor: new Date(Date.now() + 60_000).toISOString(), platforms: [{ accountId: accounts[0].id }], }); console.log("Scheduled:", post.id); ``` ```bash cURL curl -X POST https://api.postbreeze.ai/api/v1/posts \ -H "Authorization: Bearer $POSTBREEZE_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "content": "Hello from the Postbreeze API! 👋", "scheduledFor": "2026-06-15T09:00:00Z", "platforms": [{ "accountId": "soc_…" }] }' ``` Your post is now scheduled and will publish automatically at the specified time. ### Posting to multiple platforms Add more entries to the `platforms` array to cross-post the same content. Same caption goes to every account unless you override it per-platform with `captionOverride`. ```ts await postbreeze.posts.create({ content: "Same caption, three platforms.", scheduledFor: "2026-06-15T09:00:00Z", platforms: [ { accountId: "soc_ig_123" }, { accountId: "soc_x_456" }, { accountId: "soc_tiktok_789" }, ], }); ``` ### Publishing immediately To publish right now instead of scheduling, set `scheduledFor` to a few seconds in the future: ```ts await postbreeze.posts.create({ content: "Going live right now.", scheduledFor: new Date(Date.now() + 5_000).toISOString(), platforms: [{ accountId: "soc_…" }], }); ``` ### Creating a draft To save a post without scheduling, omit `scheduledFor`: ```ts await postbreeze.posts.create({ content: "Draft — not scheduled yet.", platforms: [{ accountId: "soc_…" }], }); ``` The post is saved with status `DRAFT` and won't publish until you later call `posts.schedule({ postId, scheduledAt })`. ## What's next? - [Platform guides](/platforms/overview) — Instagram, TikTok, X, … - [Adding media](/concepts/scheduling) — images, videos, URL ingest - [Webhooks](/concepts/webhooks) — react to events as they happen - [Workspaces](/concepts/workspaces) — multi-tenant patterns - [MCP install](/mcp/install) — drive Postbreeze from Claude / Cursor --- # Media uploads > Upload images and videos to attach to your scheduled posts. Posts with media perform better on every platform. Postbreeze uses **presigned URLs** so your file goes directly from your code to storage — the API server never touches the bytes. Uploaded media is **account-global** — once a file is uploaded, the same `publicUrl` works across every workspace and every social account your key can reach. Upload once, reuse anywhere. ## Step 1: Get a presigned URL `POST /media/presign` with just `filename` + `contentType`. No workspace, no file size required. ```ts SDK const { uploadUrl, publicUrl, headers } = await postbreeze.media.presign({ filename: "hero.jpg", contentType: "image/jpeg", }); ``` ```bash cURL curl -X POST https://api.postbreeze.ai/api/v1/media/presign \ -H "Authorization: Bearer $POSTBREEZE_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "filename": "hero.jpg", "contentType": "image/jpeg" }' ``` Response: ```json { "uploadUrl": "https:///users/usr_…/med_abc/hero.jpg?X-Amz-…", "publicUrl": "https://media.postbreeze.ai/users/usr_…/med_abc/hero.jpg", "expiresAt": "2026-06-15T09:15:00.000Z", "headers": { "Content-Type": "image/jpeg" } } ``` The `uploadUrl` is valid for **15 minutes**. Issue the PUT promptly, or re-presign. ## Step 2: Upload the file A direct `PUT` to `uploadUrl`. The signature is in the URL — no `Authorization` header needed on this request. ```ts Node.js import { readFile } from "node:fs/promises"; const fileBytes = await readFile("./hero.jpg"); await fetch(uploadUrl, { method: "PUT", body: fileBytes, headers, // { "Content-Type": "image/jpeg" } }); ``` ```ts Browser const file = inputEl.files[0]; await fetch(uploadUrl, { method: "PUT", body: file, headers, // { "Content-Type": "image/jpeg" } }); ``` ```bash cURL curl -X PUT "$UPLOAD_URL" \ -H "Content-Type: image/jpeg" \ --data-binary "@./hero.jpg" ``` A `200 OK` from R2 means the bytes are stored. Your `publicUrl` is now live. ## Step 3: Use in a post Pass `publicUrl` into the post's `mediaItems` array: ```ts await postbreeze.posts.create({ content: "Launching today 🚀", platforms: [{ accountId: "soc_…" }], mediaItems: [{ url: publicUrl, type: "image" }], }); ``` `type` can be `"image"`, `"video"`, `"gif"`, or `"document"` (uppercase also works). ## Same media, many platforms When `mediaItems` is at the **post level**, it's used by every platform listed in `platforms[]`: ```ts await postbreeze.posts.create({ content: "Same image, three platforms.", platforms: [ { accountId: "soc_ig_123" }, { accountId: "soc_x_456" }, { accountId: "soc_tiktok_789" }, ], mediaItems: [{ url: publicUrl, type: "image" }], }); ``` One upload, one `publicUrl`, every platform — no per-platform duplication. ## Different media per platform Sometimes you need a square crop for Instagram and a landscape one for YouTube. Override per-target with `mediaIds`: ```ts await postbreeze.posts.create({ caption: "Cross-platform release", targets: [ { socialAccountId: "soc_yt_…", mediaIds: [landscapeMediaId], // landscape video }, { socialAccountId: "soc_tiktok_…", mediaIds: [portraitMediaId], // portrait video }, ], }); ``` ## Attach a file you already have on a CDN If your image already lives on a public HTTPS URL, you can skip the presign + PUT entirely and paste the URL straight into `mediaItems`: ```ts await postbreeze.posts.create({ content: "Launching today 🚀", platforms: [{ accountId: "soc_…" }], mediaItems: [ { url: "https://cdn.example.com/hero.jpg", type: "image" }, ], }); ``` Postbreeze will fetch the URL server-side (SSRF-guarded) and store the bytes — same outcome as a direct upload. ## Supported formats | Type | Formats | Max size | |---|---|---| | Images | JPG, PNG, GIF, WebP, HEIC, HEIF | 5 GB | | Videos | MP4, MOV, AVI, WebM | 5 GB | | Documents | PDF (LinkedIn only) | 100 MB | The size limits above are the **storage ceiling**. Each platform enforces its own (much tighter) caps at publish time — see [per-platform limits](#per-platform-limits) below. The server accepts files up to the storage ceiling so you can keep originals alongside re-encoded variants; the publisher rejects files that exceed the destination platform's limit. ## Per-platform limits Each platform enforces its own media rules at publish time: | Platform | Images | Videos | Documents | Notes | |---|---|---|---|---| | Instagram | up to 10 (carousel) | 1 | — | 9:16 for Reels, 4:5–1.91:1 for Feed | | Facebook | up to 10 (carousel) | 1 | — | — | | X | up to 4 | 1 | — | Can't mix images + video in one post | | LinkedIn | up to 20 | 1 | 1 PDF (carousel) | The only platform that accepts PDFs | | TikTok | up to 35 (carousel) | 1 | — | — | | Threads | up to 20 (mixed) | 1 | — | — | | Pinterest | up to 5 (carousel) | 0 | — | — | | YouTube | 0 | 1 | — | Video only | See each [platform guide](/platforms/overview) for full per-platform rules. ### LinkedIn PDFs LinkedIn's "document carousel" surface accepts a single PDF. Upload it the same way as any other file, then attach with `type: "document"`: ```ts const { uploadUrl, publicUrl, headers } = await postbreeze.media.presign({ filename: "deck.pdf", contentType: "application/pdf", }); await fetch(uploadUrl, { method: "PUT", body: pdfBytes, headers }); await postbreeze.posts.create({ caption: "Our Q3 deck — page-by-page.", targets: [ { socialAccountId: "soc_linkedin_…", mediaItems: [{ url: publicUrl, type: "document" }], }, ], }); ``` If you put a PDF in the post-level `mediaItems` for a cross-post that includes non-LinkedIn targets, Postbreeze rejects the post with a `400` — PDFs only land on LinkedIn. Use per-target `mediaItems` (or `mediaIds`) to send a different asset to the other platforms. --- # Platform settings > Per-platform options that go beyond a caption and a few images. Every platform has its own knobs — Instagram's REEL vs FEED, X's `replySettings`, TikTok's privacy and consent flags, YouTube's `madeForKids`, LinkedIn's PDF carousels. Postbreeze exposes these through a single field on each platform target: ```ts platforms: [ { accountId: "soc_…", platformOptions: { platform: "INSTAGRAM", kind: "REEL", }, }, ] ``` `platformOptions` is a discriminated union — the `platform` field picks which shape applies. Pass only the fields you want to override; everything else falls back to a sensible default. ## Target-level fields (every platform) These live **next to** `platformOptions`, not inside it. They apply to any platform that supports them. | Field | Type | Description | |---|---|---| | `accountId` | `string` | Required. The `SocialAccount.id` to publish to. | | `captionOverride` | `string` (≤10,000) | Per-platform caption that overrides the post-level `content`. Useful when one platform needs a different tone, length, or set of hashtags. | | `firstComment` | `string` (≤2,000) | Auto-posts as the first reply once the main post is live. Supported on **Instagram, Facebook, X, YouTube, TikTok, LinkedIn, Threads**. Ignored on platforms that don't have a comments surface. | | `platformOptions` | object | Platform-specific options (this page). Discriminated by `platform`. | | `mediaIds` | `string[]` | Overrides the post-level media for this target only. Use when one platform needs a different crop or asset. | ```json { "accountId": "soc_…", "captionOverride": "Different caption for X — shorter, punchier.", "firstComment": "Drop a 🔥 if you want the deep-dive thread.", "platformOptions": { "platform": "X", "replySettings": "following" } } ``` The full Zod schema lives in [`packages/shared/src/index.ts`](https://github.com/postbreeze/postbreeze/blob/main/packages/shared/src/index.ts). This page is the human-readable copy. ## Instagram | Field | Type | Description | |---|---|---| | `platform` | `"INSTAGRAM"` | Discriminator. Required. | | `kind` | `"FEED" \| "REEL"` | Which Instagram surface to publish to. Defaults to `"FEED"`. | ```json { "platform": "INSTAGRAM", "kind": "REEL" } ``` **Constraints** - 📐 Feed posts require aspect ratio between **4:5 (0.8)** and **1.91:1**. - 📱 Reels must be a single **9:16** video, 3–90 seconds. - 🎠 Feed carousels support up to **10** media items. - 🚫 **Stories are not supported in v1** — they're rejected at validation. - 🚫 Mixing image + video in one Feed post is rejected by the API. - 💬 `firstComment` is supported on Feed posts and Reels (not stories — and stories don't ship in v1 anyway). ## Facebook | Field | Type | Description | |---|---|---| | `platform` | `"FACEBOOK_PAGE"` | Discriminator. Required. | | `kind` | `"FEED" \| "PHOTO" \| "PHOTO_CAROUSEL" \| "VIDEO" \| "REEL"` | Publishing surface. Defaults to `"FEED"`. With media attached, `FEED` is auto-normalized to `PHOTO` or `PHOTO_CAROUSEL`. | | `link` | `string` (URL) | Optional outbound link. With `kind: "FEED"`, Facebook auto-generates the link preview card from the URL's Open Graph tags. | | `reelTitle` | `string` (≤2,200) | Optional Reel title prepended to the description on publish. | ```json { "platform": "FACEBOOK_PAGE", "kind": "REEL", "reelTitle": "Behind the launch" } ``` **Constraints** - 🚫 Cannot mix videos and images in the same post. - ✅ Up to **10** images for Feed photo carousels. - 🎬 Reels must be **9:16**, between **3 and 90 seconds**. - 📊 Reels are capped at **30 publishes per Page per 24h** by Meta. - 🔗 Use `link` (with `kind: "FEED"`) for OG-card link previews. - 💬 `firstComment` is supported on every surface except Reels. ## X (Twitter) | Field | Type | Description | |---|---|---| | `platform` | `"X"` | Discriminator. Required. | | `replySettings` | `"everyone" \| "following" \| "mentionedUsers"` | Who can reply. Defaults to `"everyone"`. Enforced server-side by X. | | `threadParts` | `string[]` (each ≤4,000, up to 25 items) | Tail of a thread. When non-empty, each entry posts as a separate tweet threaded under the first. `content` is the FIRST tweet in the thread. | | `replyToId` | `string` | Optional tweet ID — turns this post into a reply to an existing tweet. | ```json { "platform": "X", "replySettings": "following", "threadParts": [ "Here's the second tweet in the thread.", "And the third — wrapping up with the link → https://example.com" ] } ``` **Constraints** - 🖼️ Up to **4** images **or** **1** video per tweet — never a mix. - 🧵 `threadParts` adds up to **25** tweets after the root. Each entry inherits no media; only the root tweet carries the post's `mediaItems`. - 💬 `firstComment` is published as a reply to the **root** tweet, not the last thread part. - 🌍 Tweet text is always globally visible — X doesn't expose geo-restriction on text. ## LinkedIn (Personal) | Field | Type | Description | |---|---|---| | `platform` | `"LINKEDIN_PERSON"` | Discriminator. Required. | | `visibility` | `"PUBLIC" \| "CONNECTIONS"` | Who can see the post. Defaults to `"PUBLIC"`. | | `postAsPdfCarousel` | `boolean` | Composite the attached images into a single PDF and post as a "document". LinkedIn renders it as a swipeable carousel under the post. | | `pdfCarouselTitle` | `string` (≤100) | Title shown above the PDF carousel. Required when `postAsPdfCarousel` is `true`. | ```json { "platform": "LINKEDIN_PERSON", "visibility": "PUBLIC", "postAsPdfCarousel": true, "pdfCarouselTitle": "Q2 launch retro" } ``` **Constraints** - 🖼️ Up to **20** images per post. - 🚫 Multi-video posts are not supported. - 📄 Single **PDF document** posts supported — upload the PDF and attach with `type: "document"`. See [Media uploads → LinkedIn PDFs](/concepts/media-uploads#linkedin-pdfs). - 🔗 Link previews are auto-generated when no media is attached. ## LinkedIn (Company) Same shape as LinkedIn Personal, with the discriminator `platform: "LINKEDIN_COMPANY"`. Use for organization pages the connected user administers. | Field | Type | Description | |---|---|---| | `platform` | `"LINKEDIN_COMPANY"` | Discriminator. Required. | | `visibility` | `"PUBLIC" \| "CONNECTIONS"` | Kept for shape parity — company actors **always post publicly**, no matter what's passed. | | `postAsPdfCarousel` | `boolean` | See LinkedIn Personal above. | | `pdfCarouselTitle` | `string` (≤100) | Title shown above the PDF carousel. | ```json { "platform": "LINKEDIN_COMPANY", "postAsPdfCarousel": true, "pdfCarouselTitle": "Quarterly numbers" } ``` **Constraints** - 🏢 One `SocialAccount` per administered organization. Use the per-org `accountId` to fan out to multiple pages. - 📊 Org-page analytics require LinkedIn Marketing Developer Platform approval — until that lands, the analytics surface shows an "approval pending" banner. Posting still works. ## TikTok (Personal) **Required by TikTok.** `privacy` has **no default** — you must pass one of the four values below. Branded-content (`brandContentToggle: true`) is only allowed with `privacy: "PUBLIC_TO_EVERYONE"`. | Field | Type | Description | |---|---|---| | `platform` | `"TIKTOK_PERSONAL"` | Discriminator. Required. | | `privacy` | `"PUBLIC_TO_EVERYONE" \| "MUTUAL_FOLLOW_FRIENDS" \| "FOLLOWER_OF_CREATOR" \| "SELF_ONLY"` | **Required.** TikTok's Content Sharing Guidelines forbid a default here — the creator must pick a value their `creator_info` API confirmed is allowed for their account. | | `allowComments` | `boolean` | Whether viewers can comment. Defaults to `false`. | | `allowDuet` | `boolean` | Whether viewers can duet. Defaults to `false`. | | `allowStitch` | `boolean` | Whether viewers can stitch. Defaults to `false`. | | `autoAddMusic` | `boolean` | Let TikTok auto-add a recommended music track. Defaults to `false`. Photo posts only. | | `brandOrganicToggle` | `boolean` | "Your Brand" disclosure — content promotes the creator's own brand. Defaults to `false`. Surfaced to TikTok as `brand_organic_toggle`. | | `brandContentToggle` | `boolean` | "Branded Content" disclosure — paid partnership / third-party brand. Defaults to `false`. Surfaced to TikTok as `brand_content_toggle`. | | `photoTitle` | `string` (≤90 UTF-16 runes) | Photo posts only — TikTok exposes a short metadata title separate from the caption. Ignored for video posts. | ```json { "platform": "TIKTOK_PERSONAL", "privacy": "PUBLIC_TO_EVERYONE", "allowComments": true, "allowDuet": false, "allowStitch": false, "brandContentToggle": true } ``` **Constraints** - 🎬 Videos publish via `PULL_FROM_URL`; the host of the media URL must be on TikTok's verified-domain list (your R2 public bucket). - 📸 Photo carousels support up to **35** images. - 📝 Video captions: up to 2,200 characters. Photo titles capped at **90** characters — use `content` for longer descriptions. - 🔒 While the app is in TikTok **Sandbox**, `privacy` is forced to `"SELF_ONLY"` regardless of what you pass — that's a TikTok constraint until App Review lands. - 🤝 Branded content **cannot** be `SELF_ONLY`, `MUTUAL_FOLLOW_FRIENDS`, or `FOLLOWER_OF_CREATOR`. Validation rejects the combination pre-flight. ## TikTok (Business) Same shape as TikTok Personal, with the discriminator `platform: "TIKTOK_BUSINESS"`. Use for accounts authenticated through TikTok for Business. ```json { "platform": "TIKTOK_BUSINESS", "privacy": "PUBLIC_TO_EVERYONE", "allowComments": true, "brandContentToggle": false } ``` The reserved `TIKTOK_BUSINESS` slot exists for accounts that grant the Business OAuth flow; the publish shape and validation rules are identical to Personal. ## YouTube | Field | Type | Description | |---|---|---| | `platform` | `"YOUTUBE"` | Discriminator. Required. | | `visibility` | `"PUBLIC" \| "UNLISTED" \| "PRIVATE"` | Defaults to `"PUBLIC"`. | | `madeForKids` | `boolean` | COPPA flag — required by YouTube on every upload. Videos marked made-for-kids have restricted features (no comments, no notifications, limited ad targeting). Defaults to `false`. | | `youtubeTitle` | `string` (≤100) | Optional title that overrides the first line of `content`. YouTube splits title from description; this field carries the override. | ```json { "platform": "YOUTUBE", "visibility": "UNLISTED", "madeForKids": false, "youtubeTitle": "Launch day — full walkthrough" } ``` **Constraints** - 🎬 YouTube targets accept a **single video**, no images. - ⏱️ Videos **≤ 3 minutes** in 9:16 are automatically published as **YouTube Shorts**; longer videos publish as regular videos. Postbreeze doesn't override this — it's YouTube's classification. - 🖼️ Custom thumbnails work for regular videos only (not Shorts). - 💬 `firstComment` is supported and posted as a top-level comment after upload completes. - 🚫 Tags, category overrides, AI-disclosure flag (`containsSyntheticMedia`) are not exposed in v1 — defaults are applied server-side (`categoryId: "22"` "People & Blogs"). ## Pinterest | Field | Type | Description | |---|---|---| | `platform` | `"PINTEREST"` | Discriminator. Required. | | `board` | `string` | Pinterest board ID. **Required** by the API for any pin. | | `title` | `string` (≤100) | Optional pin title — distinct from the post `content`, which becomes the pin description. | | `link` | `string` (URL) | Optional outbound link the pin opens when clicked. | ```json { "platform": "PINTEREST", "board": "1234567890123456789", "title": "Spring lookbook", "link": "https://example.com/spring" } ``` **Constraints** - 📌 Every pin requires a `board`. There is no default board fallback in v1. - 🖼️ Pinterest carousels accept up to **5** images. - 🚫 Video pins are not supported in v1. - 🔗 `link` becomes the pin's destination — leave it unset for image-only inspiration pins. ## Threads | Field | Type | Description | |---|---|---| | `platform` | `"THREADS"` | Discriminator. Required. | | `kind` | `"TEXT" \| "IMAGE" \| "VIDEO" \| "CAROUSEL"` | Publishing surface. Defaults to `"TEXT"`. The compose flow normally auto-derives it from the attached media (no media → TEXT, single image → IMAGE, etc.); set it explicitly if you want to force a surface. | | `threadParts` | `string[]` (each ≤500, up to 25 items) | Optional thread tail. When non-empty, each entry posts as a separate Threads post chained via `reply_to_id` under the previous one. `content` is the FIRST post in the thread. | ```json { "platform": "THREADS", "kind": "TEXT", "threadParts": [ "Second post in the thread — explaining the why.", "Third post — the punchline." ] } ``` **Constraints** - 📝 Each post (root + every follow-up) is capped at **500 characters** — Threads has no "See more" fold. The publisher rejects pre-flight if any part is over. - 🧵 `threadParts` adds up to **25** follow-ups after the root. Each follow-up is text-only; only the root post carries the attached `mediaItems`. - 🎠 Carousels accept **2–20** mixed items (images and videos) on the root post. - 🎬 Videos up to **5 minutes** / **1 GB**. - 📊 Posting limits: **250** published posts / 24h, **1,000** replies / 24h. Each follow-up counts toward the reply quota. - 💬 `firstComment` is posted as a reply to the **root** post, not the last thread part. ## Cross-platform example One post fanned out to four platforms, each with its own `platformOptions`, per-target caption override, and first-comment: ```ts await postbreeze.posts.create({ content: "We just launched 🚀", mediaItems: [{ url: heroUrl, type: "image" }], platforms: [ { accountId: "soc_ig_…", firstComment: "Link in bio! 🔗", platformOptions: { platform: "INSTAGRAM", kind: "FEED" }, }, { accountId: "soc_x_…", captionOverride: "We just launched 🚀 → https://example.com", platformOptions: { platform: "X", replySettings: "everyone", threadParts: [ "Here's what's new — a thread 🧵", "Schedule once, publish everywhere. No spreadsheet required.", ], }, }, { accountId: "soc_li_…", firstComment: "What do you think? Drop a comment below 👇", platformOptions: { platform: "LINKEDIN_PERSON", visibility: "PUBLIC", }, }, { accountId: "soc_tt_…", platformOptions: { platform: "TIKTOK_PERSONAL", privacy: "PUBLIC_TO_EVERYONE", allowComments: true, allowDuet: false, allowStitch: false, }, }, ], }); ``` **Defaults that kick in if you omit `platformOptions`** - Instagram → `kind: "FEED"` - Facebook → `kind: "FEED"` - X → `replySettings: "everyone"` - LinkedIn (Personal/Company) → `visibility: "PUBLIC"` - YouTube → `visibility: "PUBLIC"`, `madeForKids: false` - Threads → `kind` derived from media The exceptions are **TikTok** (`privacy` has no default) and **Pinterest** (`board` has no default) — those two targets require `platformOptions` with at least the required field, or the post is rejected at validation. --- # Rate limits > Per-key throughput ceilings. API requests are bucketed in two windows: | Window | Limit | | -------------------------- | ------------- | | **Burst** (60 seconds) | 60 requests | | **Sustained** (15 minutes) | 1000 requests | The first bucket to fill returns `429 Too Many Requests`. Wait until the window rolls over, then retry. Clients should respect the standard `Retry-After` response header. These limits are intentionally generous for normal use but tight enough to prevent a leaked key from being weaponized. If you need a higher ceiling for a legitimate use case, email pontus@postbreeze.ai. ## Inside the dashboard Cookie-authenticated dashboard traffic uses a separate, looser bucket — the limits above apply only to `Authorization: Bearer pb_live_…` requests. --- # Error Handling > Predictable failure modes for the Postbreeze REST API. Every Postbreeze endpoint returns the same JSON envelope on failure. Branch your client code on `code` and `statusCode` — never on the human-readable `message`, which can change between releases. ## Response shape ```json { "statusCode": 403, "code": "PLAN_LIMIT_POSTS", "message": "Your plan allows 100 posts/month.", "limit": 100, "current": 100, "requestId": "req_01HSAB7N4P9K2D6CXEZTQVRMW3" } ``` | Field | Type | Always present | Notes | | ------------- | ------------------- | -------------- | ------------------------------------------------------------------------------------------- | | `statusCode` | `number` | yes | Mirrors the HTTP status. Convenient for clients that strip the response envelope. | | `code` | `string` (SCREAMING\_SNAKE) | yes | Stable, machine-readable. The contract you should switch on. Listed below. | | `message` | `string` | yes | One-sentence English summary. Safe to surface to end users; do not parse. | | `requestId` | `string` | yes | Forward this when you open a support ticket — it indexes the server-side log line. | | _extras_ | varies | sometimes | Domain errors include structured context (`limit`, `current`, `retryAfterMs`, `details`, …). | The envelope is identical across `/api/v1/*`, dashboard endpoints, and MCP-tool responses. There is no separate "user-facing" vs "API" shape. ## Stability contract - **`code` and `statusCode` are stable.** We will not change them without a major version bump. Add new codes to your `switch` as we ship new features; existing codes will keep their meaning. - **`message` is not stable.** Copy may be tightened, translated, or re-phrased between releases. Show it; never `===` it. - **Extra fields are additive.** A `PLAN_LIMIT_POSTS` error today carries `limit` + `current`; future versions may add `resetAt`. Treat the envelope as open-shape. ## HTTP status codes | Status | Meaning | | ------ | ------------------------------------------------------------------------------------------------------------- | | `400` | Malformed request — invalid JSON, missing required field, or business rule violated (`MUST_SPECIFY_EXACTLY_ONE`, etc.). | | `401` | Missing or invalid `Authorization: Bearer pb_live_…` header. | | `403` | Authenticated, but the action is forbidden — wrong workspace, insufficient role, plan limit, or feature gate. | | `404` | Resource doesn't exist (or has been soft-deleted). Also returned when crossing a workspace boundary, to avoid leaking existence. | | `409` | Conflict — duplicate slug, foreign-key violation, or platform-already-connected. | | `422` | Semantic validation failed — typically a per-platform rule (TikTok caption length, IG image aspect ratio). | | `429` | Rate limited. Respect the `Retry-After` response header (seconds). | | `500` | Internal error. Retry the request; if it persists, email `pontus@postbreeze.ai` with the `requestId`. | | `503` | A required integration (Stripe, R2, Postgres) is degraded. Transient — retry with backoff. | ## Error codes The table below covers every domain code currently emitted by the API. Codes are grouped by area; the **HTTP** column tells you which status they ride on so you can short-circuit on `statusCode` first when that's all you need. ### Authentication & access | Code | HTTP | Meaning | | ----------------------------- | ---- | ------------------------------------------------------------------------------------ | | `UNAUTHORIZED` | 401 | No bearer token, or the token is malformed. | | `INVALID_API_KEY` | 401 | Bearer token doesn't match any active key. Re-issue from **Settings → Developers**. | | `FORBIDDEN_WORKSPACE` | 403 | The API key is valid but not scoped to the workspace you're targeting. | | `INSUFFICIENT_ROLE` | 403 | Your workspace role can't perform this action (e.g., `CLIENT_REVIEWER` posting). | | `FEATURE_NOT_AVAILABLE` | 403 | Your plan doesn't include this feature (`APPROVALS`, `WHITE_LABEL`, `API`, …). | ### Plan limits & billing | Code | HTTP | Extras | Meaning | | --------------------------------- | ---- | --------------------- | ------------------------------------------------------------------------ | | `PLAN_LIMIT_POSTS` | 403 | `limit`, `current` | Monthly post quota reached for the workspace owner's plan. | | `PLAN_LIMIT_API_POSTS` | 403 | `limit`, `current` | API-authenticated post quota reached. Upgrade to Developer for per-account pricing. | | `PLAN_LIMIT_WORKSPACES` | 403 | `limit`, `current` | Plan's workspace cap reached. Add a paid workspace add-on, or upgrade. | | `WORKSPACE_REQUIRES_PAID_ADD_ON` | 402 | | Action requires the monthly per-workspace add-on. POST to `/api/billing/workspace-add-on/checkout`. | | `WORKSPACE_BILLING_FAILED` | 402 | `stripeMessage` | Stripe rejected the charge for the workspace add-on. Update payment method. | | `X_PAYMENT_METHOD_REQUIRED` | 402 | | Connecting an X account needs a card on file (X API is metered). | | `DEVELOPER_PAYMENT_METHOD_REQUIRED` | 402 | | Developer-plan connection past the free quota needs a card on file. | ### Posts & publishing | Code | HTTP | Extras | Meaning | | -------------------------- | ---- | ----------------- | ----------------------------------------------------------------------------- | | `VALIDATION_FAILED` | 400 | `details[]` | Body or query failed Zod / class-validator. `details` lists per-field errors. | | `MUST_SPECIFY_EXACTLY_ONE` | 400 | `fields[]` | Mutually exclusive fields — pass one, not both (e.g. `mediaIds` vs `mediaItems`). | | `TARGET_NOT_PUBLISHED` | 400 | | Action requires a `PUBLISHED` target (e.g., retry first-comment). | | `NO_EXTERNAL_ID` | 400 | | Target was never published to the platform — nothing to act on. | | `NO_COMMENT_TO_RETRY` | 400 | | No first-comment text saved on this target. | | `NO_ERROR_TO_RETRY` | 400 | | First comment already posted successfully — nothing to retry. | | `RETRY_COOLDOWN` | 400 | `retryAfterMs` | Retried too quickly. Wait the cooldown out, then call again. | | `PLATFORM_ALREADY_CONNECTED` | 409 | `accountId` | This platform identity is already connected to the workspace. | ### Media | Code | HTTP | Extras | Meaning | | --------------------- | ---- | ----------------------- | ---------------------------------------------------------------------- | | `URL_INGEST_DISABLED` | 400 | | URL ingest is disabled on this Postbreeze instance. | | `URL_REJECTED` | 400 | `reason` | URL failed the SSRF allow-list (private IP, blocked host, bad scheme). | | `UNSUPPORTED_MIME` | 400 | `mime` | File type isn't supported for the chosen platform / asset slot. | | `FILE_TOO_LARGE` | 400 | `sizeBytes`, `maxBytes` | File exceeds per-asset size cap. | | `EMPTY_BODY` | 400 | | Upload completed but returned 0 bytes. | | `FETCH_FAILED` | 400 | `status?`, `reason?` | URL ingest couldn't fetch the source. Verify the URL is reachable. | | `STORAGE_LIMIT` | 403 | `usedBytes`, `maxBytes` | Workspace owner's storage quota reached. Delete media or upgrade. | | `STORAGE_WRITE_FAILED` | 503 | | R2 / S3 write failed mid-stream. Transient — retry. | | `MEDIA_IN_USE` | 409 | `postCount` | Can't delete an asset that's attached to scheduled posts. | ### Generic & infrastructure | Code | HTTP | Meaning | | --------------- | ---- | ------------------------------------------------------------------------------------ | | `CONFLICT` | 409 | A row with the same unique key already exists (duplicate slug, e-mail, etc.). | | `FK_VIOLATION` | 409 | Referenced row no longer exists — likely a race with a delete. | | `NOT_FOUND` | 404 | Row not found. We return 404 (not 403) when crossing workspaces to avoid leaks. | | `RETRY` | 503 | Transient Postgres serialization conflict. Safe to retry immediately. | | `INTERNAL_ERROR`| 500 | Unhandled server error. Always log the `requestId` and retry with backoff. | ## Handling errors in code ```ts Node.js (SDK) import Postbreeze, { APIError } from "@postbreeze/node"; const postbreeze = new Postbreeze({ apiKey: process.env.POSTBREEZE_API_KEY! }); try { await postbreeze.posts.create({ content: "Launching today 🚀", platforms: [{ accountId: "soc_…" }], }); } catch (err) { if (!(err instanceof APIError)) throw err; switch (err.code) { case "PLAN_LIMIT_POSTS": // err.extras.limit, err.extras.current are present. alert(`You've used ${err.extras.current}/${err.extras.limit} this month.`); break; case "RETRY_COOLDOWN": await sleep(err.extras.retryAfterMs); // retry… break; case "INVALID_API_KEY": // Bubble up; user needs to re-issue. throw err; default: console.error(`API error [${err.code}] req=${err.requestId}: ${err.message}`); throw err; } } ``` ```python Python import os, time, httpx pb = httpx.Client( base_url="https://api.postbreeze.ai/api/v1", headers={"Authorization": f"Bearer {os.environ['POSTBREEZE_API_KEY']}"}, ) r = postbreeze.post("/posts", json={ "content": "Launching today 🚀", "platforms": [{"accountId": "soc_…"}], }) if r.is_error: body = r.json() code = body["code"] if code == "PLAN_LIMIT_POSTS": raise RuntimeError( f"Used {body['current']}/{body['limit']} posts this month" ) if code == "RETRY_COOLDOWN": time.sleep(body["retryAfterMs"] / 1000) # retry… if code == "INVALID_API_KEY": raise RuntimeError("Re-issue your API key in Settings → Developers") raise RuntimeError( f"API error [{code}] req={body['requestId']}: {body['message']}" ) ``` ```bash cURL curl -X POST https://api.postbreeze.ai/api/v1/posts \ -H "Authorization: Bearer $POSTBREEZE_API_KEY" \ -H "Content-Type: application/json" \ -d '{"content":"Hello","platforms":[{"accountId":"soc_…"}]}' # 403 response: # { # "statusCode": 403, # "code": "PLAN_LIMIT_POSTS", # "message": "Your plan allows 100 posts/month.", # "limit": 100, "current": 100, # "requestId": "req_01HSAB7N4P9K2D6CXEZTQVRMW3" # } ``` ## Retries Network calls fail. The table below tells you which `code` values are safe to retry verbatim, which need a delay, and which require user action before they can succeed. | Pattern | Codes | Strategy | | ------------------------------- | ---------------------------------------------------- | --------------------------------------------------------------------------------------------------------- | | **Safe to retry immediately** | `RETRY`, `STORAGE_WRITE_FAILED`, network timeouts | Transient — retry once or twice, then back off. | | **Retry after a delay** | `429` rate-limit, `RETRY_COOLDOWN` | Honour `Retry-After` header (seconds) or `retryAfterMs` field. Don't retry sooner — you'll get throttled longer. | | **Retry with exponential backoff** | `500 INTERNAL_ERROR`, `503` integrations | Start at 1s, double each attempt, cap at 30s, max 5 attempts. | | **Never retry — fix and resubmit** | `400`, `401`, `403`, `404`, `409`, `422` | The request is wrong, not the network. Retrying produces the same error. | Rate-limit windows are **60 req/min (burst)** and **1000 req/15min (sustained)** — see [Rate limits](/concepts/rate-limits). The `429` response always includes `Retry-After`. A drop-in retry helper: ```ts async function withRetry(fn: () => Promise, attempts = 5): Promise { for (let i = 0; i < attempts; i++) { try { return await fn(); } catch (err) { if (!(err instanceof APIError)) throw err; // Permanent: bail immediately. const permanent = ["VALIDATION_FAILED", "INVALID_API_KEY", "FORBIDDEN_WORKSPACE", "INSUFFICIENT_ROLE", "PLAN_LIMIT_POSTS", "PLAN_LIMIT_API_POSTS", "PLAN_LIMIT_WORKSPACES", "PLATFORM_ALREADY_CONNECTED", "MEDIA_IN_USE", "MUST_SPECIFY_EXACTLY_ONE"]; if (permanent.includes(err.code)) throw err; // Honour an explicit Retry-After / retryAfterMs. const wait = err.extras.retryAfterMs ?? err.retryAfterHeader * 1000 ?? Math.min(30_000, 1000 * 2 ** i); await new Promise((r) => setTimeout(r, wait)); } } throw new Error("Exhausted retry attempts"); } ``` ## Idempotency `POST /api/v1/posts` and the other create endpoints **are not automatically idempotent**. A network blip mid-request can leave you unsure whether the post landed. Two recommended defenses: 1. **Use `clientReferenceId`.** Pass a unique string (UUID, your own primary key) when creating a post. We dedupe within a 24-hour window — a retry with the same `clientReferenceId` returns the original post instead of creating a duplicate. ```ts await postbreeze.posts.create({ clientReferenceId: "your-post-id-42", content: "…", platforms: [{ accountId: "soc_…" }], }); ``` 2. **Search before retrying.** If a retry feels risky, query `GET /posts?workspaceId=…&scheduledAfter=…` before the second attempt and check whether the first one already wrote the row. For `cancel` and `update`, the operation is naturally idempotent — the second call is a no-op on already-cancelled / unchanged data. ## Webhooks Postbreeze sends webhooks for `post.published`, `post.failed`, `account.token_expired`, and `comment.received`. All delivery is **at-least-once** — a single event may be delivered more than once if your endpoint times out, returns a 5xx, or we don't receive a 2xx within 10 seconds. - **Dedupe on `event.id`** (a ULID). Store the last N event IDs you've processed and short-circuit duplicates. - **Return 2xx fast.** Acknowledge first, process async. Anything over 5 seconds eats into the next retry's window. - **We back off** at 30s, 2min, 10min, 1hr, 6hr, 24hr. After six failures, the delivery is marked `DEAD` and surfaces in Settings → Webhooks → Deliveries. No further attempts. - **Signature**: every request carries an `X-Postbreeze-Signature` header — `HMAC-SHA256(secret, rawBody)` in hex. Verify before trusting the body. ## Account health `account.token_expired` fires when a connected social account's OAuth token can no longer be refreshed (revoked, password changed, scope removed). The account stays in the workspace but `SocialAccount.status` flips to `TOKEN_EXPIRED` and publishing to that account begins to fail with `503 PLATFORM_AUTH_FAILED`. Recover by reconnecting the account from the dashboard. Scheduled posts targeting it will retry automatically once the row returns to `ACTIVE`. ## Best practices - **Branch on `code`, never on `message`.** Message text rotates; codes don't. - **Always log `requestId`.** It's the only correlation key we have between your client and our server logs. Surface it in your own error reports. - **Show plan-limit errors with context.** `PLAN_LIMIT_*` errors carry `limit` and `current` — surface "97/100 posts used this month" instead of a generic "quota reached." Most users self-serve upgrade when shown the number. - **Don't retry 4xx.** Anything in the 400-range is your request being wrong, not our server. Retrying wastes quota and slows your user down. - **Use `clientReferenceId` for create calls.** It's the single cheapest way to make your integration idempotent. - **Pin to a major version.** All endpoints live under `/api/v1`. We will introduce `/api/v2` before we break anything in `v1`. --- # Webhooks > Receive real-time events when posts publish, accounts disconnect, comments arrive, and more. Webhooks let you react to events in Postbreeze as they happen instead of polling. When a post publishes, an account disconnects, or a comment lands in the inbox, Postbreeze fires a signed HTTPS request to a URL you control. ## How it works 1. **Register a webhook** in the dashboard at **Settings → Developers → Webhooks** (or via the dashboard's `me/webhooks` endpoints). Pick the events you want, paste your HTTPS URL, and Postbreeze generates a signing secret. 2. **Postbreeze signs every delivery** with an HMAC-SHA256 of the request body. Verify the signature in your handler before doing anything with the payload. 3. **You respond `2xx` within 10 seconds**. Anything else is a failure and triggers the retry schedule below. Webhooks are **outbound only** — Postbreeze pushes events to your endpoint. You don't poll for them, and you don't need an API key on your endpoint. ## Quick start ```ts handler.ts import { createHmac, timingSafeEqual } from "node:crypto"; const SECRET = process.env.POSTBREEZE_WEBHOOK_SECRET!; export async function POST(req: Request): Promise { const raw = await req.text(); const sig = req.headers.get("x-postbreeze-signature"); if (!sig || !verify(raw, sig, SECRET)) { return new Response("Invalid signature", { status: 401 }); } const event = JSON.parse(raw) as { event: string; deliveryId: string; sentAt: string; payload: Record; }; // Acknowledge quickly. Move heavy work to a queue. void enqueueBackgroundJob(event); return new Response("ok", { status: 200 }); } /** Verifies a `t=,v1=` header against the raw body. */ function verify(body: string, header: string, secret: string): boolean { const parts = Object.fromEntries( header.split(",").map((p) => p.split("=") as [string, string]), ); const ts = parts.t; const sig = parts.v1; if (!ts || !sig) return false; // Reject deliveries older than 5 minutes — defends against replay // attacks once an attacker has a leaked body + signature. const ageSec = Math.floor(Date.now() / 1000) - Number(ts); if (!Number.isFinite(ageSec) || Math.abs(ageSec) > 5 * 60) return false; const expected = createHmac("sha256", secret) .update(`${ts}.${body}`) .digest("hex"); const a = Buffer.from(sig, "hex"); const b = Buffer.from(expected, "hex"); return a.length === b.length && timingSafeEqual(a, b); } ``` ## Request format Every delivery is a `POST` with `Content-Type: application/json`. The body is the same shape for every event — the per-event detail lives on `payload`. ```json { "apiVersion": "2026-05-01", "event": "post.published", "deliveryId": "whd_clw9q6f1m000008jp7j8h7q3a", "sentAt": "2026-06-15T09:00:32.184Z", "payload": { "postId": "pst_clw9q6f1m000008jp7j8h7q3a", "workspaceId": "wsp_clw9q6f1m000008jp7j8h7q3a", "publishedAt": "2026-06-15T09:00:31.000Z", "targetCount": 3 } } ``` ### Headers Postbreeze sets | Header | Value | |---|---| | `Content-Type` | `application/json` | | `User-Agent` | `Postbreeze-Webhook/1.0` | | `X-Postbreeze-Event` | Event key, e.g. `post.published` | | `X-Postbreeze-Delivery-Id` | Stable per-delivery id. Use this for deduplication | | `X-Postbreeze-Api-Version` | `2026-05-01` (matches `apiVersion` in the body) | | `X-Postbreeze-Signature` | `t=,v1=` | You can also configure **custom headers** on each webhook (e.g. `X-Internal-Token: …`) — Postbreeze merges them in but cannot override the default headers above. ## Signature verification The `X-Postbreeze-Signature` header has the form: ``` t=1718446832,v1=8a93f4e2b1d57a8c0e3f6b912d4a5c8e9f0a1b2c3d4e5f6a7b8c9d0e1f2a3b4c ``` - `t` is the Unix timestamp in seconds when Postbreeze signed the request. - `v1` is the HMAC-SHA256 of `.` using your webhook's signing secret, hex-encoded. To verify: 1. Split the header by `,` and read `t` + `v1`. 2. Compute `HMAC-SHA256(".", secret)`. 3. Compare in constant time. 4. Reject if the timestamp is more than 5 minutes off the current time (replay protection). **Verify against the raw request body**, not a re-serialized JSON object. Any whitespace difference or key reordering changes the hash. Read the body as a string before parsing it. ### Rotating the secret Rotate the signing secret from **Settings → Developers → Webhooks → Rotate secret**. During the rotation window (24 hours by default), Postbreeze signs every delivery with **both** secrets: ``` X-Postbreeze-Signature: t=1718446832,v1=,v1= ``` Accept either `v1` value. After the window closes the old secret stops being attached. ## Idempotency & deduplication Webhooks are delivered **at least once**. A receiver that times out on attempt 1 and succeeds on attempt 2 receives the same event twice. Use `X-Postbreeze-Delivery-Id` as your deduplication key — it's stable across retries of the same delivery. The cheapest pattern is a uniquely-indexed table: ```sql CREATE TABLE webhook_inbox ( delivery_id TEXT PRIMARY KEY, received_at TIMESTAMPTZ NOT NULL DEFAULT now() ); ``` Insert on receive; if the insert fails on the unique constraint, you have already processed this delivery — `ack` and skip. ## Retry policy A delivery is **successful** when your endpoint returns a `2xx` response within 10 seconds. Anything else fails the attempt. | Attempt | Delay before next attempt | |---|---| | 1 | immediate | | 2 | 30 seconds | | 3 | 5 minutes | | 4 | 1 hour | | 5 | 6 hours | | 6 | 24 hours | After **6 attempts** the delivery is dropped to the failed-deliveries list (visible in the dashboard) and does not retry further. ### Which failures retry? | Outcome | Behavior | |---|---| | `408`, `429`, `5xx` | Retried per the schedule above | | Network error / timeout | Retried | | `401`, `403`, `404`, `410` | **Not retried** (terminal — your endpoint is rejecting or gone) | | Any other `4xx` | **Not retried** (your handler has a bug we can't fix by waiting) | | `3xx` redirect | **Not retried** — Postbreeze refuses to follow redirects to defend against SSRF. Point your webhook URL at the final endpoint. | ### Auto-disable After **15 consecutive terminal failures** Postbreeze automatically disables the webhook and fires a final `webhook.disabled_by_system` event to the same endpoint (best-effort). Re-enable it from the dashboard once the endpoint is back. ## Event catalogue All event keys, in canonical order. Schemas use TypeScript-style notation — `string | null` means nullable. ### Posts | Event | When it fires | |---|---| | `post.scheduled` | A post is scheduled to publish in the future | | `post.published` | All targets of a post published successfully | | `post.failed` | All targets of a post failed | | `post.partial` | Some targets succeeded, others failed | | `post.cancelled` | A scheduled post was canceled before publish | | `post.platform.published` | One target of a multi-platform post landed | | `post.platform.failed` | One target of a multi-platform post failed | ```ts post.published payload { postId: string; workspaceId: string; publishedAt: string; // ISO-8601 targetCount: number; } ``` ```ts post.partial payload { postId: string; workspaceId: string; successCount: number; failureCount: number; } ``` ```ts post.platform.published payload { postId: string; workspaceId: string; targetId: string; platform: string; // "INSTAGRAM" | "X" | "TIKTOK_PERSONAL" | … externalPostId: string | null; externalUrl: string | null; publishedAt: string; } ``` ```ts post.platform.failed payload { postId: string; workspaceId: string; targetId: string; platform: string; errorCode: string | null; errorMessage: string | null; } ``` ### Accounts | Event | When it fires | |---|---| | `account.connected` | A social account is connected to a workspace | | `account.disconnected` | A connected account is disconnected (user-initiated or revoked externally) | | `account.token_expired` | The platform refresh token expired and the account is now read-only until reconnect | ```ts account.connected payload { accountId: string; workspaceId: string; platform: string; handle: string; displayName: string | null; reconnected: boolean; // true when a previously-disconnected row was revived } ``` ```ts account.disconnected payload { accountId: string; workspaceId: string; platform: string; handle: string; reason: "user_initiated" | "external_revocation"; } ``` ### Comments | Event | When it fires | |---|---| | `comment.received` | A new inbox comment is received (Instagram / X / YouTube / Facebook) | ```ts comment.received payload { commentId: string; workspaceId: string; socialAccountId: string; platform: string; externalPostId: string | null; authorHandle: string | null; body: string; createdAt: string; } ``` ### Webhook lifecycle These are meta-events about the webhook itself — useful for detecting your endpoint being auto-disabled. | Event | When it fires | |---|---| | `webhook.test` | Triggered by the "Send test event" button in the dashboard | | `webhook.disabled_by_system` | Fired once when consecutive failures cross the auto-disable threshold | ```ts webhook.disabled_by_system payload { webhookId: string; reason: "consecutive_failure_threshold_reached"; failureStreak: number; } ``` ## Best practices - **Verify the signature on every delivery.** Don't skip in development. - **Deduplicate by `deliveryId`.** Treat at-least-once as a guarantee, not a wish. - **Acknowledge quickly.** Return `2xx` within 10 seconds; queue heavy work for a background worker. - **Treat events as notifications, not state.** If you missed a delivery (auto-disable, your endpoint was down), re-fetch from the API to reconcile — never trust the webhook to be your only source of truth. - **Lock the receiver down.** Reject requests that aren't a `POST` with `Content-Type: application/json`. Use the signing secret as your only authority — don't IP-allowlist Postbreeze. - **Handle the `disabled_by_system` event explicitly.** Page on it; otherwise you'll only notice events have stopped flowing when something downstream breaks. - **Use the dashboard's "Deliveries" tab** to inspect recent failures — every attempt logs its HTTP status, response body (truncated), and the request headers we sent. ## Limits - **Max 50 webhooks per user** - **Max payload size persisted**: 64 KB (large payloads still deliver, but the response/request bodies stored for the deliveries log are truncated) - **Custom headers**: up to 10 per webhook, max 1 KB total - **URL must be HTTPS** — `http://` is rejected at create time - **URL must resolve to a public IP** — RFC1918, link-local, and IMDS hostnames are rejected at both create time and delivery time (DNS rebinding protection) ## Managing webhooks Webhooks are managed from the dashboard at **Settings → Developers → Webhooks** — create, rotate secrets, disable, send a test event, and browse delivery history. The management endpoints are cookie-only (`/me/webhooks`); API keys can't mint or revoke webhooks. --- # Install in Claude / Cursor > Add the Postbreeze MCP server to your AI client in one paste. ## What is MCP? The **Model Context Protocol** is an open standard that lets AI clients (Claude Desktop, claude.ai web, Claude Code, Cursor, and others) call tools on remote servers. Postbreeze publishes an MCP server at `https://mcp.postbreeze.ai/mcp` that wraps the same REST API documented on this site. Postbreeze's MCP server supports **two authentication paths**: - **API keys** (`pb_live_…`) — best for Claude Desktop, Claude Code, Cursor, scripts, CI jobs. Paste a key into your client's config. - **OAuth 2.1** — best for claude.ai's web "Custom Connectors". One click on the connect button, a consent screen on postbreeze.ai, then you're connected. No keys to copy or rotate. Both paths produce the same tool surface and the same per-workspace scoping — pick whichever matches your client. ## claude.ai (web) — Custom Connectors via OAuth On claude.ai, go to **Settings → Connectors** and click **Add Custom Connector**. Name: `Postbreeze`. URL: `https://mcp.postbreeze.ai/mcp`. Click **Add**. claude.ai redirects to postbreeze.ai. Sign in if you're not already. You'll land on the consent page. Choose which workspace claude.ai should be able to act on. (Each grant is bound to exactly one workspace — to give claude.ai access to a second workspace, repeat this flow.) The connector status flips to **Connected** in claude.ai. Open a new conversation — the Postbreeze tools appear in the tool picker. Try *"What posts do I have scheduled this week in Postbreeze?"* To revoke access later: **postbreeze.ai → Settings → Connected apps → Revoke**. The next claude.ai tool call returns 401 and claude.ai prompts you to reconnect. ## Claude Desktop Settings → Developers → **New API key**. Name it "Claude Desktop". Copy the `pb_live_…` value. Open **Settings → Developer → Edit Config** in Claude Desktop. Add the `postbreeze` entry: ```json { "mcpServers": { "postbreeze": { "url": "https://mcp.postbreeze.ai/mcp", "headers": { "Authorization": "Bearer pb_live_…" } } } } ``` The Postbreeze tools (`list_posts`, `schedule_post`, etc.) appear in the tool picker. Try asking *"What posts do I have scheduled this week?"* ## Claude Code ```bash claude mcp add postbreeze \ --url https://mcp.postbreeze.ai/mcp \ --header "Authorization: Bearer pb_live_…" ``` ## Cursor In Cursor's **Settings → MCP**, add a new server with type **HTTP** and the URL `https://mcp.postbreeze.ai/mcp`. Add the `Authorization` header with your `Bearer pb_live_…` token. ## What can the LLM do? See the [Tools](./tools) page for the full v1 surface — `list_posts`, `schedule_post`, `update_post`, `cancel_post`, `list_connected_accounts`, `list_media`, `ingest_media_from_url`, `list_comments`, `reply_to_comment`, and per-platform analytics. --- # Tools > The v1 MCP tool surface — what Claude / Cursor can do on your behalf. Every tool is **workspace-scoped**: the API key fixes which workspace the LLM can read or write to. **No tool takes a `workspaceId` argument** — the workspace is inferred from the bearer key, so the model never has to look it up first. Destructive tools are flagged so MCP clients can prompt before invoking them. ## Read-only ### `list_workspaces` Returns the workspace this API key is bound to. Useful for the LLM to surface the brand/account name (e.g. *"Acme Co."*) before scheduling. Normally a single-entry list. ### `list_posts` Returns the workspace's posts, newest first, with caption, scheduled time, target platforms, and lifecycle status (DRAFT / SCHEDULED / PUBLISHED / PARTIALLY_PUBLISHED / FAILED). ### `get_post` Returns a single post with its caption, scheduled time, per-platform targets, publish errors, and attached media. ### `list_connected_accounts` Returns every social account connected to the workspace — platform, `@handle`, and connection status. These are the IDs you pass to `schedule_post` as `platforms[].accountId`. ### `list_media` Lists items in the workspace's media library. Use `id` values as the `mediaIds` array when scheduling a post with pre-uploaded attachments. ### `list_comments` Returns inbox comments across every connected platform (Instagram, X, YouTube, Facebook). Supports a `platform` filter and `unreadOnly` flag. Reads from cache — call `refresh_comments` first if the user expects very-recent activity. ### `get_analytics_overview` Returns workspace-wide KPIs (impressions, engagement, follower growth) plus a per-platform breakdown and a bucketed engagement trend series. Each KPI includes `percentageChange` vs the prior equal-length window (null when no prior data exists — the LLM is instructed not to fabricate a delta in that case). ### `get_top_posts` Returns the workspace's published posts within the range, sorted by summed engagement. Includes caption preview, thumbnail URL, platform, and per-post engagement components. ### `get__analytics` Per-platform analytics deep-dive: `get_instagram_analytics`, `get_facebook_analytics`, `get_tiktok_analytics`, `get_x_analytics`, `get_linkedin_analytics`, `get_youtube_analytics`, `get_pinterest_analytics`, `get_threads_analytics`. Each takes a `socialAccountId` from `list_connected_accounts` plus a `range`. Returns the platform's native shape (Pinterest pins, TikTok lifetime totals, etc.) so the model can reason about each platform's native concepts. ## Writes ### `schedule_post` Creates a post and (optionally) schedules it to publish. Uses the flat shape (`content` + `platforms`). **Required:** `platforms` — one entry per account to publish to. **Optional:** - `content` — default caption used for every platform unless overridden via `platforms[].captionOverride`. - `scheduledFor` — ISO-8601 UTC publish time. Omit to save as a draft. - `mediaItems` — attach media by URL; the server fetches the bytes (SSRF-guarded) before publish. Each entry is `{ type, url, altText? }`. - `mediaIds` — IDs of media already in the library (from `list_media` or `ingest_media_from_url`). **Per-platform overrides** on each `platforms[]` entry: - `captionOverride` — replace `content` for this platform only. - `firstComment` — text auto-posted as a reply right after publish (Instagram, Facebook, LinkedIn, X, YouTube, Threads). ### `update_post` Edits a DRAFT or SCHEDULED post in place. Pass the fields you want to change. Terminal statuses (PUBLISHING / PUBLISHED / FAILED) reject. ### `reschedule_post` Moves a SCHEDULED post to a new publish time. Same retry policy as a fresh schedule. ### `retry_post` Re-runs the publish pipeline for a FAILED post. Use when the failure was a transient network/provider error. ### `cancel_post` *(destructive)* Cancels a scheduled post. MCP clients surface a confirmation prompt because of the `destructiveHint` annotation. Audit history of any prior publish attempts is retained. ### `ingest_media_from_url` Fetches a public HTTPS URL server-side (SSRF-guarded) and stores the bytes in the workspace's media library. Returns the new `mediaAssetId`. Most callers don't need this — `schedule_post` already accepts `mediaItems` URLs directly. Use it when you want to attach the same asset to multiple posts without re-uploading. ### `refresh_comments` Forces a live fetch of new comments from every connected platform. Call before `list_comments` when the user expects very-recent activity. Returns `{ newCount }`. ### `reply_to_comment` Posts a reply to an inbox comment. Per-platform character limits: X 280, IG 2,200, YouTube 10,000. Text is sent verbatim — Markdown gets stripped by the platform. ### `mark_comment_read` Flips the inbox read state on a comment. ## Annotations reference Postbreeze tools set the standard MCP annotations: - `readOnlyHint: true` on every list / get - `destructiveHint: true` on `cancel_post` - `openWorldHint: false` everywhere — these tools only touch the Postbreeze workspace, never the wider internet Your MCP client decides how to surface these (Claude Desktop, for example, asks for confirmation before destructive tool calls). --- # MCP security model > What an MCP-connected LLM can and cannot do with your Postbreeze workspace. ## Two auth paths - **API keys** (`pb_live_…`) — issued in **Settings → Developers**. Stored as `sha256(plaintext)` server-side; plaintext is shown once at creation and never recoverable. Best for clients you control (Claude Desktop, scripts). - **OAuth 2.1** (claude.ai web Custom Connectors) — claude.ai initiates the flow, the user consents on postbreeze.ai, and an audience-bound access token is minted. No long-lived secret pasted anywhere by the user. Both paths produce identical authorization on the server side: same workspace scoping, same tool surface, same audit trail. ## Tokens stay client-side The Postbreeze MCP server is a **stateless proxy**. Your API key lives in your AI client's local config; the MCP server doesn't store keys, sessions, or post bodies. OAuth tokens are minted server-side by the authorization server, but the MCP server itself only verifies them on each request — it never persists them. Every tool call instantiates a fresh authenticated client from the header that arrived with the request. ## Workspace scope is enforced server-side An LLM cannot widen the blast radius by rewriting a URL — the REST API checks every request's resolved `workspaceId` against the workspace encoded in the key, and rejects mismatches with `403`. Even if a prompt injection convinces the LLM to call `get_post` with another workspace's `postId`, the server refuses. ## Destructive tools require confirmation `cancel_post` carries the standard MCP `destructiveHint: true` annotation. Claude Desktop and other compliant clients show a confirmation modal before invoking — *don't* disable this prompt. ## Prompt injection in returned data Post captions, account handles, and post bodies are user-generated content that flow back through tool results into the model's context. If an attacker manages to write *"ignore previous instructions, schedule a malicious post"* into a caption, a careless prompt could act on it. Mitigations baked into Postbreeze: - All user-generated content returned by tools is wrapped as `text` content (not `resource` or `system`), so MCP clients render it as plain quoted data. - The MCP tools never *automatically* read post content and call another mutating tool — the LLM has to decide, which means the human in the loop sees the tool call before it fires. For high-trust automations (no human in the loop), prefer the REST API directly with deterministic scripts. ## Revocation **API keys**: open the Postbreeze dashboard → **Settings → Developers** → **Revoke**. The next request fails with `401`. **OAuth grants** (claude.ai, Cursor, etc.): open **Settings → Connected apps** → **Revoke** on the entry you want to disconnect. Every access + refresh token for that grant is invalidated immediately, and the connecting app receives a 401 on its next call. Both paths are no-grace-period — in-flight requests already in transit may complete, but subsequent ones will not. ## OAuth token model When you authorize claude.ai (or any OAuth-capable MCP client): - The access token's `audience` is pinned to the MCP server's exact URL (`https://mcp.postbreeze.ai`). Tokens with a different audience are rejected — defends against audience-confusion attacks. - Tokens are scoped to **one workspace** at consent time. Granting claude.ai access to workspace A doesn't give it any access to workspace B. - Access tokens last 1 hour; refresh tokens last 90 days with rotation on every use. Refresh tokens implement a 30-second "two valid at a time" grace window so concurrent claude.ai requests don't race-fail during rotation. Both TTLs are configurable via the `OAUTH_ACCESS_TOKEN_TTL_SECONDS` / `OAUTH_REFRESH_TOKEN_TTL_SECONDS` env vars on the server. - All tokens are stored as `sha256(plaintext)` — irreversible. A DB leak yields hashes the attacker can't reverse into usable tokens. ## Reporting issues Email [pontus@postbreeze.ai](mailto:pontus@postbreeze.ai) with the details and reproduction steps. --- # Platforms > Per-platform integration guides — content types, fields, media constraints, and analytics for every supported network. Postbreeze ships first-party publishers for the platforms below. Every platform shares the **same scheduling API** (`POST /api/v1/posts`) — what changes is the per-platform options blob you pass on each target. Feed, Reels, carousels. Business + Creator accounts only. Feed, Photo, Photo Carousel, Video, Reels. Page accounts only. Video uploads + photo carousels with branded-content disclosure. Tweets, threads (up to 25), replies. Pay-Per-Use billing. Personal + Company pages. Photo gallery + PDF carousel. Long-form + Shorts. First-comment via Community surface. Single + carousel pins. Board, title, link per pin. Text, image, video, mixed-media carousels (up to 20 items). ## Capability matrix | Platform | Post types | First comment | Carousel | Alt text | Analytics | |---|---|---|---|---|---| | [Instagram](/platforms/instagram) | Feed, Reel | ✅ | 2–10 (mixed) | ❌ Not in API | ✅ | | [Facebook](/platforms/facebook) | Feed, Photo, Carousel, Video, Reel | ✅ | 2–10 (photo only) | ❌ Not in API | ✅ | | [TikTok](/platforms/tiktok) | Video, Photo Carousel | ❌ Not in API | 1–35 (photo only) | ❌ Not in API | ✅ | | [X](/platforms/x) | Tweet, Thread, Reply | ✅ | 1–4 (photos only) | ✅ | ✅ | | [LinkedIn](/platforms/linkedin) | Personal, Company, PDF Carousel | ✅ | 2–20 (photo or PDF) | ✅ | ⚠️ MDP required | | [YouTube](/platforms/youtube) | Video upload | ✅ | ❌ | ❌ | ✅ | | [Pinterest](/platforms/pinterest) | Pin, Carousel pin | ❌ Not in API | 2–5 (photo only) | ❌ | ✅ | | [Threads](/platforms/threads) | Text, Image, Video, Carousel | ✅ | 2–20 (mixed) | ✅ | ✅ | ## The single API surface Every platform reaches the same endpoint with the same payload shape. Workspace is **inferred from the API key** — you don't pass it. ```bash POST /api/v1/posts ``` There are two accepted body shapes. Use the **flat shape** for the common case (same caption to N platforms with shared media); use the **nested shape** when you need fine-grained per-target overrides like custom platform options. ```json Flat (recommended) { "content": "Shared caption — applied to every platform", "scheduledFor": "2026-06-01T12:00:00Z", "mediaItems": [ { "type": "IMAGE", "url": "https://cdn.example.com/hero.jpg", "altText": "Hero" } ], "platforms": [ { "accountId": "soc_ig_…", "captionOverride": "Optional per-platform caption", "firstComment": "Optional follow-up reply" }, { "accountId": "soc_x_…" } ] } ``` ```json Nested (full control) { "caption": "Shared caption — used as fallback for every target", "scheduledAt": "2026-06-01T12:00:00Z", "mediaIds": ["med_…", "med_…"], "mediaAltText": { "med_…": "Alt text for X / LinkedIn / Bluesky" }, "targets": [ { "socialAccountId": "soc_…", "captionOverride": "Optional per-target caption", "firstComment": "Optional follow-up reply", "mediaIds": ["med_…"], "platformOptions": { "platform": "INSTAGRAM", "kind": "FEED" } } ] } ``` Provide **exactly one** of `platforms` or `targets` — sending both returns `400 MUST_SPECIFY_EXACTLY_ONE`. **Workspace inference.** API keys are bound to a single workspace at creation. SDK and REST callers don't pass `workspaceId` — the server reads it from the bearer credential. Create separate keys for separate brands. See the [API reference](/api-reference/overview) for the full schema, or pick a platform on the left for the per-platform options you'd pass. ## A note on media Three precedence rules apply for what media a target actually publishes: 1. If the target has its own non-empty `mediaIds` → use those. 2. Otherwise → use the post-level `mediaIds` (after URL ingest). 3. If both are empty → text-only post (only some platforms accept this — Threads ✅, X ✅, Facebook ✅, LinkedIn ✅; the others reject). This lets you publish the same post to 5 platforms with one shared media list, then override just the TikTok target with a portrait video while everyone else gets the landscape one. ## Provider behavior + retry policy Postbreeze wraps every publish in a Temporal workflow that retries up to **5 attempts** with exponential backoff (initial 10s, max 15 min, coefficient 2). Non-retryable validation errors (e.g. caption too long) terminate immediately and surface as `PostStatus.FAILED`. Network blips, rate limits, and provider 5xx's get retried automatically. The full retry timeline: - Attempt 1: immediate - Attempt 2: ~10s later - Attempt 3: ~30s later - Attempt 4: ~5 min later - Attempt 5: ~15 min later Beyond that, the post target stays in `FAILED` and you can retry manually via `POST /api/v1/posts/:id/retry`. ## See also - [Authentication](/authentication) — how API keys work + workspace boundaries - [Concepts → Scheduling](/concepts/scheduling) — what `scheduledFor` really means + timezone behavior - [Concepts → Rate limits](/concepts/rate-limits) — global rate limits across all platforms - [Concepts → Errors](/concepts/errors) — error envelope + categories - [MCP](/mcp/install) — let Claude / Cursor schedule posts directly --- # Instagram > Schedule Instagram Feed posts, Reels, and carousels via Postbreeze. Business and Creator accounts only. ## Quick reference | Field | Value | |---|---| | Caption limit | 2,200 characters | | Hashtags per post | 30 max | | Mentions per post | 20 max | | Carousel size | 2–10 items | | Mixed media in carousel | ✅ (image + video) | | Video formats | MP4, MOV | | Image formats | JPEG, PNG | | Max video size | 100 MB (Feed), 1 GB (Reels) | | Max image size | 30 MB | | Reel duration | 3–90 seconds | | Feed video duration | 3 seconds – 60 minutes | | Aspect ratios | 4:5 to 1.91:1 (Feed), 9:16 (Reel) | | Post types | Feed (single, carousel), Reel | | First comment | ✅ Auto-posts after publish | | Account types | Business + Creator only (no Personal) | ## Before you start Instagram **personal accounts cannot publish via API** — only Business and Creator accounts are eligible. The connect flow detects this and won't complete OAuth for unsupported account types. The connected Instagram account must also be linked to a Facebook Page that you administer; that's how Meta authorizes the publish action. The connected Instagram account must: - Be a Business or Creator account (not Personal). - Be linked to a Facebook Page you administer. - Have granted scopes `instagram_business_basic`, `instagram_business_content_publish`, and `instagram_business_manage_comments` (the latter unlocks first-comment). The first-comment scope was added in May 2026. Accounts connected before that date have to reconnect to use first-comment — the API surfaces this with the `IG_SCOPE_MISSING` error. ## Quick start Schedule a single-image Feed post 5 minutes from now using the flat body shape (`content` + `platforms`). The workspace is inferred from your API key — no `workspaceId` argument needed. The example uses `mediaItems` to ingest a public image URL on the fly; see [Media uploads](/concepts/media-uploads) for the pre-upload alternative. ```js Node.js import Postbreeze from "@postbreeze/node"; const postbreeze = new Postbreeze({ apiKey: process.env.POSTBREEZE_API_KEY }); const post = await postbreeze.posts.create({ content: "Launching the new collection today 🚀", scheduledFor: new Date(Date.now() + 5 * 60_000).toISOString(), mediaItems: [{ type: "image", url: "https://cdn.example.com/launch.jpg" }], platforms: [{ accountId: "soc_instagram_…" }], }); console.log("Scheduled:", post.id); ``` ```python Python import os from datetime import datetime, timedelta, timezone import requests API = "https://api.postbreeze.ai/api/v1" scheduled = (datetime.now(timezone.utc) + timedelta(minutes=5)).isoformat() res = requests.post( f"{API}/posts", headers={"Authorization": f"Bearer {os.environ['POSTBREEZE_API_KEY']}"}, json={ "content": "Launching the new collection today 🚀", "scheduledFor": scheduled, "mediaItems": [{"type": "image", "url": "https://cdn.example.com/launch.jpg"}], "platforms": [{"accountId": "soc_instagram_…"}], }, ) res.raise_for_status() print("Scheduled:", res.json()) ``` ```bash curl curl -X POST https://api.postbreeze.ai/api/v1/posts \ -H "Authorization: Bearer $POSTBREEZE_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "content": "Launching the new collection today 🚀", "scheduledFor": "2026-06-02T12:05:00Z", "mediaItems": [ { "type": "image", "url": "https://cdn.example.com/launch.jpg" } ], "platforms": [{ "accountId": "soc_instagram_…" }] }' ``` **Need fine control?** Switch to the nested shape (`caption` + `targets`) when you want to set `platformOptions` per-platform, send different media per platform, or set a `kind: "REEL"` discriminator — see [Full control](#full-control-nested-shape) at the bottom. The flat shape's `platformOptions` per-entry handles most cases though — see [Platform settings](/concepts/platform-settings#instagram). ## Content types ### Single-image or single-video Feed post Default for any single-item Feed post. `kind` defaults to `"FEED"` and exactly one media item is attached. ### Carousel (2–10 items) Pass 2–10 pre-uploaded `mediaIds` (or 2–10 `mediaItems` for URL ingest). Instagram crops every slide to the **first slide's aspect ratio** — so if you start with a 4:5 image and follow it with a 1:1 image, the second gets top + bottom cropped without warning. Postbreeze surfaces a per-slide warning at compose-time when this is detected. ```js Node.js const post = await postbreeze.posts.create({ content: "Behind the scenes from the shoot ✨", scheduledFor: new Date(Date.now() + 30_000).toISOString(), mediaIds: ["med_1", "med_2", "med_3", "med_4"], platforms: [{ accountId: "soc_instagram_…" }], }); ``` ```python Python res = requests.post( f"{API}/posts", headers={"Authorization": f"Bearer {os.environ['POSTBREEZE_API_KEY']}"}, json={ "content": "Behind the scenes from the shoot ✨", "scheduledFor": (datetime.now(timezone.utc) + timedelta(seconds=30)).isoformat(), "mediaIds": ["med_1", "med_2", "med_3", "med_4"], "platforms": [{"accountId": "soc_instagram_…"}], }, ) res.raise_for_status() ``` ```bash curl curl -X POST https://api.postbreeze.ai/api/v1/posts \ -H "Authorization: Bearer $POSTBREEZE_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "content": "Behind the scenes from the shoot ✨", "scheduledFor": "2026-06-02T12:00:30Z", "mediaIds": ["med_1","med_2","med_3","med_4"], "platforms": [{ "accountId": "soc_instagram_…" }] }' ``` Prefer URL ingest for a quick test? Swap `mediaIds` for `mediaItems` with per-item `altText`: ```js await postbreeze.posts.create({ content: "Behind the scenes from the shoot ✨", scheduledFor: new Date(Date.now() + 30_000).toISOString(), mediaItems: [ { type: "image", url: "https://cdn.example.com/shot-1.jpg", altText: "Producer setting up lights" }, { type: "image", url: "https://cdn.example.com/shot-2.jpg", altText: "Stylist adjusting outfit" }, { type: "image", url: "https://cdn.example.com/shot-3.jpg" }, { type: "image", url: "https://cdn.example.com/shot-4.jpg" }, ], platforms: [{ accountId: "soc_instagram_…" }], }); ``` ### Reel Set `kind: "REEL"` via `platformOptions` on the platform entry. The attached media must be a video between 3–90 seconds, 9:16 aspect ratio. Reels are also shared to the Feed by default. ```js Node.js const post = await postbreeze.posts.create({ content: "30-second product demo", scheduledFor: new Date(Date.now() + 30_000).toISOString(), mediaIds: ["med_video_…"], platforms: [{ accountId: "soc_instagram_…", platformOptions: { platform: "INSTAGRAM", kind: "REEL" }, firstComment: "Drop a 🔥 if you want a longer version!", }], }); ``` ```python Python res = requests.post( f"{API}/posts", headers={"Authorization": f"Bearer {os.environ['POSTBREEZE_API_KEY']}"}, json={ "content": "30-second product demo", "scheduledFor": (datetime.now(timezone.utc) + timedelta(seconds=30)).isoformat(), "mediaIds": ["med_video_…"], "platforms": [{ "accountId": "soc_instagram_…", "platformOptions": {"platform": "INSTAGRAM", "kind": "REEL"}, "firstComment": "Drop a 🔥 if you want a longer version!", }], }, ) res.raise_for_status() ``` ```bash curl curl -X POST https://api.postbreeze.ai/api/v1/posts \ -H "Authorization: Bearer $POSTBREEZE_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "content": "30-second product demo", "scheduledFor": "2026-06-02T12:00:30Z", "mediaIds": ["med_video_…"], "platforms": [{ "accountId": "soc_instagram_…", "platformOptions": { "platform": "INSTAGRAM", "kind": "REEL" }, "firstComment": "Drop a 🔥 if you want a longer version!" }] }' ``` ## Media requirements ### Images | Property | Requirement | |---|---| | Max items | 10 per carousel, 1 for single posts | | Formats | JPEG, PNG | | Max file size | 30 MB | | Aspect ratio | 4:5 to 1.91:1 | | Min resolution | 320 × 320 | | Max resolution | 8192 × 8192 | ### Videos | Property | Requirement | |---|---| | Formats | MP4, MOV | | Max file size (Feed) | 100 MB | | Max file size (Reel) | 1 GB | | Min duration | 3 seconds | | Max duration (Feed) | 60 minutes | | Max duration (Reel) | 90 seconds | | Aspect ratio (Feed) | 4:5 to 1.91:1 | | Aspect ratio (Reel) | 9:16 | | Codecs | H.264 + AAC | | Resolution (Reel) | 1080 × 1920 recommended | Postbreeze itself accepts images (JPG, PNG, GIF, WebP, HEIC, HEIF) and videos (MP4, MOV, AVI, WebM) up to 5 GB at upload time — the tighter caps above are Instagram's publish-time limits. See [Media uploads](/concepts/media-uploads) for the full ingest pipeline. ## Platform-specific fields | Field | Type | Required | Description | |---|---|---|---| | `platform` | `"INSTAGRAM"` | Yes | Discriminator. | | `kind` | `"FEED"` \| `"REEL"` | No (default `FEED`) | Picks the publish surface. Single-video posts can be either; multi-item must be `FEED`. | Instagram has no other publish-time settings. Use `firstComment` at the platform-entry level (not inside `platformOptions`) for the auto-comment. See [Platform settings](/concepts/platform-settings#instagram) for the full reference. ## First comment Pass `firstComment` on the platform entry. Postbreeze waits 3 seconds after the main post lands (Instagram occasionally 4xx's with `media not available` on faster calls) and then posts the comment as the same authenticated user. ```js platforms: [{ accountId: "soc_instagram_…", platformOptions: { platform: "INSTAGRAM", kind: "FEED" }, firstComment: "#launch #behindthescenes #brand", }] ``` Comment limit: 2,200 characters (same as caption). If the comment call fails (e.g. rate limit), the main post still shows as PUBLISHED and the failure surfaces on `PostTarget.firstCommentError`. Use the retry endpoint to try the comment again. ## Analytics | Metric | Available | |---|---| | Impressions | ✅ | | Reach | ✅ | | Likes | ✅ | | Comments | ✅ | | Saves | ✅ | | Shares | ✅ | | Profile visits | ✅ | | Follows from post | ✅ | | Video views (Reels) | ✅ | | Avg watch time (Reels) | ✅ | | Story-only metrics | ❌ (Stories not yet supported) | Refresh cadence: every **14 days** per Meta's Insights guidance. ## Common errors | Error | Meaning | Fix | |---|---|---| | `IG_CONTAINER_EXPIRED` | The container was created but not published within 24h | Re-create — Postbreeze does this automatically on retry. | | `IG_CAROUSEL_SIZE` | Carousel had fewer than 2 or more than 10 items | Trim to 2–10. | | `IG_REEL_REQUIRES_VIDEO` | `kind: "REEL"` but no video attached | Attach a video. | | `IG_FEED_REQUIRES_IMAGE` | Single-item Feed post with a non-image | Attach an image or set `kind: "REEL"`. | | `IG_MEDIA_NOT_READY` | Comment call fired before IG finished processing the media | Postbreeze retries with backoff. Resolved automatically. | | `IG_SCOPE_MISSING` | Account doesn't have `instagram_business_manage_comments` | Reconnect the account; the compose UI shows a banner. | | `IG_ASPECT_RATIO` | Image or video outside 4:5–1.91:1 | Crop/resize before uploading. | | `IG_DURATION_OUT_OF_RANGE` | Reel < 3s or > 90s | Trim before uploading. | ## What you can't do - ❌ Publish to Personal Instagram accounts (Business/Creator only) - ❌ Stories (planned for v2) - ❌ Tag products or shoppable posts - ❌ Tag users in caption (you can include `@handle` but no structured tagging) - ❌ Add location tags (Meta removed this from the API) - ❌ Schedule via Instagram's native scheduler - ❌ Edit captions after publish - ❌ Live video, IGTV ## Full control (nested shape) The nested shape (`caption` + `targets`) is the long-form alternative to the flat shape used everywhere else on this page. Reach for it when you want different captions per platform, different media per platform in the same request, or just prefer the explicit `socialAccountId` / `platformOptions` naming. The two shapes are interchangeable — every example above can be rewritten in the nested form by renaming `content` → `caption`, `scheduledFor` → `scheduledAt`, `platforms` → `targets`, and `accountId` → `socialAccountId`. ```ts SDK await postbreeze.posts.create({ caption: "Behind the scenes from the shoot ✨", scheduledAt: new Date(Date.now() + 30_000).toISOString(), mediaIds: ["med_1", "med_2", "med_3"], targets: [ { socialAccountId: "soc_instagram_…", platformOptions: { platform: "INSTAGRAM", kind: "FEED" }, }, ], }); ``` --- # Facebook > Schedule Facebook posts — Feed, Photo, Photo Carousel, Video, and Reels. Page accounts only. ## Quick reference | Field | Value | |---|---| | Caption limit | 63,206 characters | | Photos per post | 1–10 | | Videos per post | 1 | | Mixed media | ❌ Not on organic Page posts | | Image formats | JPG, PNG, GIF, WebP, HEIC, HEIF | | Video formats | MP4, MOV, AVI, WebM | | Max image size | 5 GB | | Max video size | 5 GB | | Reel duration | 3–90 seconds | | Feed video duration | up to 240 minutes | | Reels per Page | 30 per 24 hours | | Post types | Feed, Photo, Photo Carousel, Video, Reel | | First comment | ✅ Auto-posts after publish (not on Reels) | | Account types | Facebook Pages (not personal profiles) | The image/video size caps above are Postbreeze's server-side ingest limits. Facebook's own publish-time caps are tighter (Reels must be 9:16 and 3–90s, max 30 Reels per Page per 24h, etc.) — exceed those and the publish step returns an error from Meta even though the upload succeeded. ## Before you start You can only publish to Facebook **Pages** you administer. Personal-profile publishing isn't exposed by the Graph API. The OAuth flow enumerates the Pages on your account during connect — pick the Page you want to publish to from that list. Required scopes: - `pages_show_list` — read the list of Pages you administer. - `pages_manage_posts` — create posts on your Pages. - `pages_manage_engagement` + `pages_read_engagement` — first-comment + inbox replies. - `pages_read_user_content` — inbox comments-on-your-posts feed. Reels require **Meta App Review** with the `Reels` use-case enabled. Before review approval, video posts work fine but `kind: "REEL"` will return `FB_REEL_NOT_APPROVED`. ## Quick start Workspace is inferred from your API key — no `workspaceId` argument and no workspace in the URL. Use the **flat shape** (`content` + `platforms`) for the common case; drop to the [nested shape](#full-control-nested-shape) at the bottom of this page only when a power-user workflow demands it. ```js Node.js import Postbreeze from "@postbreeze/node"; const postbreeze = new Postbreeze({ apiKey: process.env.POSTBREEZE_API_KEY }); const post = await postbreeze.posts.create({ content: "Our new feature drop — read more 👇", scheduledFor: new Date(Date.now() + 30_000).toISOString(), platforms: [{ accountId: "soc_facebook_…", platformOptions: { platform: "FACEBOOK_PAGE", kind: "FEED", link: "https://example.com/blog/new-feature", }, }], }); ``` ```python Python import os from datetime import datetime, timedelta, timezone import requests API = "https://api.postbreeze.ai/api/v1" res = requests.post( f"{API}/posts", headers={"Authorization": f"Bearer {os.environ['POSTBREEZE_API_KEY']}"}, json={ "content": "Our new feature drop — read more 👇", "scheduledFor": (datetime.now(timezone.utc) + timedelta(seconds=30)).isoformat(), "platforms": [{ "accountId": "soc_facebook_…", "platformOptions": { "platform": "FACEBOOK_PAGE", "kind": "FEED", "link": "https://example.com/blog/new-feature", }, }], }, ) res.raise_for_status() ``` ```bash curl curl -X POST https://api.postbreeze.ai/api/v1/posts \ -H "Authorization: Bearer $POSTBREEZE_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "content": "Our new feature drop — read more 👇", "scheduledFor": "2026-06-02T12:00:30Z", "platforms": [{ "accountId": "soc_facebook_…", "platformOptions": { "platform": "FACEBOOK_PAGE", "kind": "FEED", "link": "https://example.com/blog/new-feature" } }] }' ``` See [Platform settings → Facebook](/concepts/platform-settings#facebook) for the full `platformOptions` reference and [Media uploads](/concepts/media-uploads) for the two ways to attach images and video. ## Content types ### Feed (text + optional link) `kind: "FEED"`. Pure text post, optionally with a `link` whose OG preview Facebook auto-generates. When you attach media to a `FEED` post, the server auto-normalizes it to `PHOTO` (1 image) or `PHOTO_CAROUSEL` (2–10) — you don't have to set `kind` yourself. ### Photo (single image) `kind: "PHOTO"`. Send exactly one image, either by `mediaIds` (the presign flow) or by `mediaItems` (URL ingest). ```js Node.js — pre-uploaded media await postbreeze.posts.create({ content: "New drop today", mediaIds: ["med_abc123"], scheduledFor: new Date(Date.now() + 30_000).toISOString(), platforms: [{ accountId: "soc_facebook_…", platformOptions: { platform: "FACEBOOK_PAGE", kind: "PHOTO" }, }], }); ``` ```js Node.js — URL ingest (alternative) await postbreeze.posts.create({ content: "New drop today", mediaItems: [ { type: "image", url: "https://cdn.example.com/drop.jpg", altText: "Product hero shot" }, ], scheduledFor: new Date(Date.now() + 30_000).toISOString(), platforms: [{ accountId: "soc_facebook_…", platformOptions: { platform: "FACEBOOK_PAGE", kind: "PHOTO" }, }], }); ``` `mediaItems` accepts any reachable URL — Postbreeze fetches the asset through an SSRF-guarded ingest path. Use `mediaIds` when you've already uploaded the file via the [presign flow](/concepts/media-uploads). ### Photo Carousel (2–10 images) `kind: "PHOTO_CAROUSEL"`. Mixed photo + video is not allowed on organic Page posts — that surface is ads-only. ```js Node.js const post = await postbreeze.posts.create({ content: "Highlights from the event", mediaIds: ["med_1", "med_2", "med_3", "med_4", "med_5"], scheduledFor: new Date(Date.now() + 30_000).toISOString(), platforms: [{ accountId: "soc_facebook_…", platformOptions: { platform: "FACEBOOK_PAGE", kind: "PHOTO_CAROUSEL", }, }], }); ``` ```python Python res = requests.post( f"{API}/posts", headers={"Authorization": f"Bearer {os.environ['POSTBREEZE_API_KEY']}"}, json={ "content": "Highlights from the event", "mediaIds": ["med_1", "med_2", "med_3", "med_4", "med_5"], "scheduledFor": (datetime.now(timezone.utc) + timedelta(seconds=30)).isoformat(), "platforms": [{ "accountId": "soc_facebook_…", "platformOptions": { "platform": "FACEBOOK_PAGE", "kind": "PHOTO_CAROUSEL", }, }], }, ) res.raise_for_status() ``` ```bash curl curl -X POST https://api.postbreeze.ai/api/v1/posts \ -H "Authorization: Bearer $POSTBREEZE_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "content": "Highlights from the event", "mediaIds": ["med_1","med_2","med_3","med_4","med_5"], "scheduledFor": "2026-06-02T12:00:30Z", "platforms": [{ "accountId": "soc_facebook_…", "platformOptions": { "platform": "FACEBOOK_PAGE", "kind": "PHOTO_CAROUSEL" } }] }' ``` ### Video `kind: "VIDEO"`. Single video file. Up to 4 hours of runtime; the server-side ingest cap is 5 GB. ```js Node.js await postbreeze.posts.create({ content: "Behind the scenes of the launch", mediaIds: ["med_video_abc"], scheduledFor: new Date(Date.now() + 30_000).toISOString(), platforms: [{ accountId: "soc_facebook_…", platformOptions: { platform: "FACEBOOK_PAGE", kind: "VIDEO" }, }], }); ``` ### Reel `kind: "REEL"`. 9:16, 3–90 seconds, video-only. Optionally pass `reelTitle` — it's prepended to the description on the Reel surface (not the Feed). ```js Node.js await postbreeze.posts.create({ content: "How we shipped this in a week", mediaIds: ["med_reel_abc"], scheduledFor: new Date(Date.now() + 30_000).toISOString(), platforms: [{ accountId: "soc_facebook_…", platformOptions: { platform: "FACEBOOK_PAGE", kind: "REEL", reelTitle: "Shipping in a week", }, }], }); ``` ## Media requirements ### Images | Property | Requirement | |---|---| | Max items | 10 per carousel, 1 per Photo post | | Formats | JPG, PNG, GIF, WebP, HEIC, HEIF | | Max file size | 5 GB (server-side ingest) | | Aspect ratio | Any (Facebook crops to 1.91:1 in Feed by default) | | Min resolution | 600 × 315 | ### Videos | Property | Requirement | |---|---| | Formats | MP4, MOV, AVI, WebM | | Max file size | 5 GB (server-side ingest) | | Max duration (Feed) | 240 minutes | | Max duration (Reel) | 90 seconds | | Min duration (Reel) | 3 seconds | | Aspect ratio (Feed) | 16:9 to 1:1 | | Aspect ratio (Reel) | 9:16 | | Codecs | H.264 + AAC | ## Platform-specific fields | Field | Type | Required | Description | |---|---|---|---| | `platform` | `"FACEBOOK_PAGE"` | Yes | Discriminator. | | `kind` | enum | No (default `FEED`) | `FEED`, `PHOTO`, `PHOTO_CAROUSEL`, `VIDEO`, or `REEL`. With media attached, `FEED` auto-normalizes to `PHOTO` / `PHOTO_CAROUSEL`. | | `link` | URL | No | Outbound link. Auto-generates a preview card when `kind: "FEED"`. | | `reelTitle` | string | No | Reel-only title prepended to the description (≤ 2,200 chars). | That's the complete schema — there is no `pageId`, `geoRestriction`, or story support on Facebook today. The `firstComment` field is **not** inside `platformOptions`; it lives one level up as a sibling of `platformOptions` on each platforms entry (see below). ## First comment Pass `firstComment` on the platforms entry — sibling of `platformOptions`, not nested inside it. Facebook publishes the comment as the Page (not as a personal user). No delay needed — Facebook accepts immediate comments after publish. ```js Node.js await postbreeze.posts.create({ content: "Our new feature drop", scheduledFor: new Date(Date.now() + 30_000).toISOString(), platforms: [{ accountId: "soc_facebook_…", platformOptions: { platform: "FACEBOOK_PAGE", kind: "FEED" }, firstComment: "More details on our blog → https://example.com/blog/new-feature", }], }); ``` Limit: 8,000 characters per Page comment. First comments are supported on every Facebook surface **except Reels** — Meta's Reels API rejects programmatic first comments. ## Analytics | Metric | Available | |---|---| | Page follows | ✅ | | Page media views | ✅ | | Page post engagements | ✅ | | Page video views | ✅ | | Page views total | ✅ | | Reach | ✅ | | Reactions breakdown | ✅ | | Post clicks | ✅ | | Comments | ✅ | | Shares | ✅ | Refresh cadence: every **14 days**. The 2026 metric names above (`page_follows`, `page_media_view`, etc.) replaced the pre-June-2026 `page_impressions` family. Postbreeze emits the new names directly — no migration needed. ## Common errors | Error | Meaning | Fix | |---|---|---| | `FB_PAGE_TOKEN_EXPIRED` | The Page access token is invalid | Reconnect the account. | | `FB_REEL_NOT_APPROVED` | Your Meta App hasn't been reviewed for the Reels use-case | Submit for Meta App Review. | | `FB_MIXED_MEDIA` | Photos + videos in `mediaIds` | Pick one type. | | `FB_CAROUSEL_SIZE` | Carousel had fewer than 2 or more than 10 photos | Trim to 2–10. | | `FB_RATE_LIMITED` | Page or app rate limit hit | Postbreeze retries with backoff. | | `FB_LINK_INVALID` | The `link` URL didn't resolve | Verify the URL works in an incognito browser. | | `FB_VIDEO_TOO_LARGE` | Video > 5 GB | Compress before uploading. | | `FB_REEL_QUOTA` | More than 30 Reels published on this Page in 24h | Wait for the rolling window to clear; Postbreeze retries with backoff. | ## What you can't do - ❌ Publish to personal Facebook profiles - ❌ Schedule via Facebook's native scheduler (we use our own queue) - ❌ Mix video and image in one carousel (ads-only feature) - ❌ Live video / Live Reels - ❌ Stories (planned for v2) - ❌ Tag specific users in caption text - ❌ Add location tags (Meta removed this from the Graph API) - ❌ Cross-post to Instagram in the same call (use two entries in `platforms`) - ❌ First comment on Reels (Meta API limitation) ## Full control: nested shape The flat shape (`content` + `platforms`) above is the canonical entry point and covers every Facebook use case. Postbreeze also accepts an equivalent nested shape (`caption` + `targets`) for callers porting from older internal tooling — the two shapes produce identical posts. ```js Node.js import Postbreeze from "@postbreeze/node"; const postbreeze = new Postbreeze({ apiKey: process.env.POSTBREEZE_API_KEY }); const post = await postbreeze.posts.create({ caption: "Our new feature drop — read more 👇", scheduledAt: new Date(Date.now() + 30_000).toISOString(), mediaIds: ["med_hero_image"], targets: [{ socialAccountId: "soc_facebook_…", platformOptions: { platform: "FACEBOOK_PAGE", kind: "PHOTO", link: "https://example.com/blog/new-feature", }, firstComment: "More details on our blog →", }], }); ``` Field mapping flat ↔ nested: | Flat | Nested | |---|---| | `content` | `caption` | | `scheduledFor` | `scheduledAt` | | `platforms[]` | `targets[]` | | `platforms[].accountId` | `targets[].socialAccountId` | | `mediaItems` / `mediaIds` | `mediaIds` (URL ingest is flat-only) | Prefer the flat shape for all new integrations. --- # TikTok > Schedule and publish TikTok videos and photo carousels — privacy settings, branded content, and disclosures. ## Quick reference | Field | Value | |---|---| | Character limit | 2,200 (caption) | | Photo title | 90 chars, photo posts only | | Photos per post | 1–35 (slideshow) | | Videos per post | 1 | | Video formats | MP4, MOV, WebM | | Photo formats | JPEG, PNG, WebP | | Max video size | 500 MB | | Max photo size | 20 MB per image | | Video duration | 3–600 seconds | | Photo aspect ratio | 9:16 recommended (auto-cropped otherwise) | | Post types | Video, Photo Carousel | | Scheduling | ✅ Schedule + post now | | First comment | ❌ Not exposed by TikTok's API | | Branded content | ✅ Disclosure toggles | ## Before you start TikTok has a strict review process before a TikTok App can publish on behalf of arbitrary creators. While in **sandbox** mode, every post is forced to `privacy: "SELF_ONLY"` regardless of what your request sends. The full set of privacy values unlocks after TikTok approves the app. If you're seeing your scheduled posts appear "only to me" on TikTok, your Postbreeze server's TikTok credentials are still in sandbox. Before scheduling, the connected account must: - Have completed the OAuth connect flow at least once. - Have granted `video.publish`, `video.upload`, and `user.info.basic` scopes. - Be on a region/account type TikTok permits API publishing for — Personal, Creator, and Business accounts all work; advertiser-only sub-accounts do not. TikTok rejects posts whose `privacy` doesn't match the per-creator allow-list returned by the creator-info API. Postbreeze validates this when you connect the account — if your account can't post `PUBLIC_TO_EVERYONE`, the field's enum will be filtered down for you. See also: [Platform settings — TikTok](/concepts/platform-settings#tiktok-personal) and [Media uploads](/concepts/media-uploads). ## Quick start Workspace is inferred from your API key — no `workspaceId` argument. Use the **flat shape** (`content` + `platforms`) for the common case; drop to the [nested shape](#full-control-nested-shape) when you'd rather group every per-target field under `targets[]`. TikTok requires `privacy` on every post — there is **no default**. Pass one of `PUBLIC_TO_EVERYONE`, `MUTUAL_FOLLOW_FRIENDS`, `FOLLOWER_OF_CREATOR`, or `SELF_ONLY` on every TikTok target. ```js Node.js import Postbreeze from "@postbreeze/node"; const postbreeze = new Postbreeze({ apiKey: process.env.POSTBREEZE_API_KEY }); const post = await postbreeze.posts.create({ content: "Behind the scenes from launch week ✨", scheduledFor: new Date(Date.now() + 60_000).toISOString(), mediaIds: ["med_video_…"], platforms: [ { accountId: "soc_tiktok_…", platformOptions: { platform: "TIKTOK_PERSONAL", privacy: "PUBLIC_TO_EVERYONE", allowComments: true, allowDuet: false, allowStitch: false, autoAddMusic: false, brandOrganicToggle: false, brandContentToggle: false, }, }, ], }); console.log("Scheduled:", post.id); ``` ```python Python import os from datetime import datetime, timedelta, timezone import requests API = "https://api.postbreeze.ai/api/v1" scheduled_for = (datetime.now(timezone.utc) + timedelta(seconds=60)).isoformat() res = requests.post( f"{API}/posts", headers={"Authorization": f"Bearer {os.environ['POSTBREEZE_API_KEY']}"}, json={ "content": "Behind the scenes from launch week ✨", "scheduledFor": scheduled_for, "mediaIds": ["med_video_…"], "platforms": [{ "accountId": "soc_tiktok_…", "platformOptions": { "platform": "TIKTOK_PERSONAL", "privacy": "PUBLIC_TO_EVERYONE", "allowComments": True, "allowDuet": False, "allowStitch": False, "autoAddMusic": False, "brandOrganicToggle": False, "brandContentToggle": False, }, }], }, ) res.raise_for_status() post = res.json() print("Scheduled:", post) ``` ```bash curl curl -X POST https://api.postbreeze.ai/api/v1/posts \ -H "Authorization: Bearer $POSTBREEZE_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "content": "Behind the scenes from launch week ✨", "scheduledFor": "2026-06-02T12:00:00Z", "mediaIds": ["med_video_…"], "platforms": [{ "accountId": "soc_tiktok_…", "platformOptions": { "platform": "TIKTOK_PERSONAL", "privacy": "PUBLIC_TO_EVERYONE", "allowComments": true, "allowDuet": false, "allowStitch": false, "autoAddMusic": false, "brandOrganicToggle": false, "brandContentToggle": false } }] }' ``` ## Content types ### Video post A single video. The publisher uploads the file to TikTok, polls `publish/status/fetch` until the platform reports `PUBLISH_COMPLETE`, then returns the TikTok video id on the `PostTarget.externalPostId`. ```js Node.js const post = await postbreeze.posts.create({ content: "Quick tutorial #1", scheduledFor: new Date(Date.now() + 30_000).toISOString(), mediaIds: ["med_video_…"], platforms: [{ accountId: "soc_tiktok_…", platformOptions: { platform: "TIKTOK_PERSONAL", privacy: "PUBLIC_TO_EVERYONE", allowComments: true, allowDuet: true, allowStitch: true, autoAddMusic: false, brandOrganicToggle: false, brandContentToggle: false, }, }], }); ``` ```python Python res = requests.post( f"{API}/posts", headers={"Authorization": f"Bearer {os.environ['POSTBREEZE_API_KEY']}"}, json={ "content": "Quick tutorial #1", "scheduledFor": (datetime.now(timezone.utc) + timedelta(seconds=30)).isoformat(), "mediaIds": ["med_video_…"], "platforms": [{ "accountId": "soc_tiktok_…", "platformOptions": { "platform": "TIKTOK_PERSONAL", "privacy": "PUBLIC_TO_EVERYONE", "allowComments": True, "allowDuet": True, "allowStitch": True, "autoAddMusic": False, "brandOrganicToggle": False, "brandContentToggle": False, }, }], }, ) res.raise_for_status() ``` ```bash curl curl -X POST https://api.postbreeze.ai/api/v1/posts \ -H "Authorization: Bearer $POSTBREEZE_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "content": "Quick tutorial #1", "scheduledFor": "2026-06-02T12:00:30Z", "mediaIds": ["med_video_…"], "platforms": [{ "accountId": "soc_tiktok_…", "platformOptions": { "platform": "TIKTOK_PERSONAL", "privacy": "PUBLIC_TO_EVERYONE", "allowComments": true, "allowDuet": true, "allowStitch": true, "autoAddMusic": false, "brandOrganicToggle": false, "brandContentToggle": false } }] }' ``` ### Photo carousel Up to 35 images stitched into a TikTok slideshow. The first item in `mediaIds` becomes the cover image — reorder via the media tray in compose, or by sending the array in the order you want. ```js Node.js const post = await postbreeze.posts.create({ content: "Trip recap — best moments from the weekend", scheduledFor: new Date(Date.now() + 30_000).toISOString(), mediaIds: ["med_photo_1", "med_photo_2", "med_photo_3"], platforms: [{ accountId: "soc_tiktok_…", platformOptions: { platform: "TIKTOK_PERSONAL", privacy: "PUBLIC_TO_EVERYONE", allowComments: true, allowDuet: false, allowStitch: false, autoAddMusic: true, brandOrganicToggle: false, brandContentToggle: false, photoTitle: "Trip recap", }, }], }); ``` ```python Python res = requests.post( f"{API}/posts", headers={"Authorization": f"Bearer {os.environ['POSTBREEZE_API_KEY']}"}, json={ "content": "Trip recap — best moments from the weekend", "scheduledFor": (datetime.now(timezone.utc) + timedelta(seconds=30)).isoformat(), "mediaIds": ["med_photo_1", "med_photo_2", "med_photo_3"], "platforms": [{ "accountId": "soc_tiktok_…", "platformOptions": { "platform": "TIKTOK_PERSONAL", "privacy": "PUBLIC_TO_EVERYONE", "allowComments": True, "allowDuet": False, "allowStitch": False, "autoAddMusic": True, "brandOrganicToggle": False, "brandContentToggle": False, "photoTitle": "Trip recap", }, }], }, ) res.raise_for_status() ``` ```bash curl curl -X POST https://api.postbreeze.ai/api/v1/posts \ -H "Authorization: Bearer $POSTBREEZE_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "content": "Trip recap — best moments from the weekend", "scheduledFor": "2026-06-02T12:00:30Z", "mediaIds": ["med_photo_1","med_photo_2","med_photo_3"], "platforms": [{ "accountId": "soc_tiktok_…", "platformOptions": { "platform": "TIKTOK_PERSONAL", "privacy": "PUBLIC_TO_EVERYONE", "allowComments": true, "allowDuet": false, "allowStitch": false, "autoAddMusic": true, "brandOrganicToggle": false, "brandContentToggle": false, "photoTitle": "Trip recap" } }] }' ``` ### Branded content disclosure Branded content (paid partnerships) requires the `brandContentToggle: true` flag. TikTok enforces that branded posts must be `privacy: "PUBLIC_TO_EVERYONE"` — sending any other privacy value alongside `brandContentToggle: true` is rejected by Postbreeze's validator before the request reaches TikTok. ```js Node.js await postbreeze.posts.create({ content: "Loving the new gear from @brand — link in bio 🎒 #ad", scheduledFor: new Date(Date.now() + 60_000).toISOString(), mediaIds: ["med_video_…"], platforms: [{ accountId: "soc_tiktok_…", platformOptions: { platform: "TIKTOK_PERSONAL", privacy: "PUBLIC_TO_EVERYONE", // required when brandContentToggle is true allowComments: true, allowDuet: true, allowStitch: true, autoAddMusic: false, brandOrganicToggle: false, brandContentToggle: true, // "Branded content" disclosure }, }], }); ``` ## Media requirements ### Images | Property | Requirement | |---|---| | Max photos | 35 per carousel | | Formats | JPEG, PNG, WebP | | Max file size | 20 MB per image | | Aspect ratio | 9:16 recommended | | Resolution | Auto-resized to 1080 × 1920 px | ### Videos | Property | Requirement | |---|---| | Videos per post | 1 | | Formats | MP4, MOV, WebM | | Max file size | 500 MB | | Min duration | 3 seconds | | Max duration | 10 minutes | | Aspect ratio | 9:16 (any other ratio is letter-boxed by TikTok) | | Resolution | 1080 × 1920 recommended | | Codecs | H.264 | | Frame rate | 30 fps recommended | You can't mix photos and videos in the same post. Use either all-photos (carousel) or one video. See [Media uploads](/concepts/media-uploads) for the presign + upload flow that produces `mediaIds`. ## Platform-specific fields TikTok settings go in `platformOptions` on each entry of `platforms[]`. The `platform` discriminator is required on every TikTok target — use `"TIKTOK_PERSONAL"` for Personal/Creator accounts and `"TIKTOK_BUSINESS"` for Business accounts. Both branches share the same field shape. | Field | Type | Required | Description | |---|---|---|---| | `platform` | `"TIKTOK_PERSONAL"` \| `"TIKTOK_BUSINESS"` | Yes | Discriminator. Picks the schema branch. | | `privacy` | enum | **Yes** | `PUBLIC_TO_EVERYONE`, `MUTUAL_FOLLOW_FRIENDS`, `FOLLOWER_OF_CREATOR`, or `SELF_ONLY`. No default — must be set explicitly to honor TikTok's Content Sharing Guidelines. | | `allowComments` | boolean | No (default `false`) | Enable or disable comments. | | `allowDuet` | boolean | No (default `false`) | Enable or disable Duets on this post. | | `allowStitch` | boolean | No (default `false`) | Enable or disable Stitches on this post. | | `autoAddMusic` | boolean | No (default `false`) | Photo posts only. TikTok auto-adds a popular track when true. | | `brandOrganicToggle` | boolean | No (default `false`) | "Your Brand" disclosure — content promotes the creator's own brand. Surfaced to TikTok as `brand_organic_toggle`. | | `brandContentToggle` | boolean | No (default `false`) | "Branded Content" disclosure — paid partnership. Surfaced as `brand_content_toggle`. **Forces `privacy: "PUBLIC_TO_EVERYONE"`** — any other value is rejected. | | `photoTitle` | string | No | Photo posts only. Short metadata title (≤ 90 UTF-16 runes). Ignored for video posts. | `firstComment` is **not** accepted on TikTok targets — TikTok's API has no comment-reply endpoint, so Postbreeze can't auto-post a follow-up reply. ## Photo carousel caption behavior The `content` field becomes the photo's TikTok description. Limited to **2,200 characters** and URLs are stripped if you include them — TikTok's photo post API doesn't honor inline URLs. Use the `photoTitle` field for the slideshow's display title (90 chars max). ## Media URL requirements **These do not work as media URLs:** - **Google Drive** — returns an HTML download page, not the file - **Dropbox** — returns an HTML preview page - **OneDrive / SharePoint** — returns HTML - **iCloud** — returns HTML Test your URL in an incognito browser window. If you see a webpage instead of the raw video or image, it will not work. Media URLs must be: - Publicly accessible (no authentication required). - Returning actual media bytes with the correct `Content-Type` header. - Served from a domain TikTok permits — see TikTok's verified-domain requirement below. - Hosted on a fast CDN. Large videos are auto-rejected during upload (5–10 MB per chunk). Photos must resolve to 1080 × 1920. ### Verified domain requirement TikTok requires the host of `video_url` / `image_url` to be on your TikTok-developer-portal verified-domain list. Add your CDN origin (or Postbreeze's `R2_PUBLIC_URL`) to that list before scheduling — uploads from unverified hosts return `url_ownership_unverified` and Temporal won't retry. ## Analytics Available metrics via the [Analytics API](/api-reference/overview#analytics): | Metric | Available | |---|---| | Views | ✅ | | Likes | ✅ | | Comments | ✅ | | Shares | ✅ | | Followers (account-level) | ✅ | | Watch time (seconds) | ✅ | | Avg view duration (seconds) | ✅ | | Profile visits | ❌ Not exposed by TikTok's API | | Reach | ❌ Not exposed by TikTok's API | Refresh cadence: every **12 hours**. TikTok requires platform-side data older than 7 days to be re-fetched on demand, so historical analytics older than a week may show stale values until the next refresh tick. ## Common errors | Error | Meaning | Fix | |---|---|---| | `TT_PRIVACY_REQUIRED` | `privacy` was not set on the platformOptions | Pass one of the four enum values. There is no default — TikTok requires explicit consent. | | `TT_PRIVACY_NOT_ALLOWED` | The creator's account can't post to the requested `privacy` (e.g. their account is private but you sent `PUBLIC_TO_EVERYONE`) | Pick a more restrictive value, or reconnect once the creator changes their default. | | `TT_BRANDED_PRIVATE` | `brandContentToggle: true` combined with non-public `privacy` | Branded content must be `PUBLIC_TO_EVERYONE`. | | `TT_DOMAIN_UNVERIFIED` | The media URL's host isn't on the verified-domain list | Add the CDN host to your TikTok developer portal. | | `TT_RATE_LIMITED` | You've hit TikTok's per-creator publish rate limit | Wait — the publisher retries with backoff up to 15 minutes. | | `TT_MEDIA_TOO_LARGE` | Video > 500 MB or photo > 20 MB | Compress or transcode before uploading. | | `TT_MIXED_MEDIA` | Both photos and videos in `mediaIds` | TikTok publishes either a photo carousel or a single video; pick one. | | `TT_DURATION_OUT_OF_RANGE` | Video < 3s or > 10 min | Trim before uploading. | ## What you can't do These features are not exposed by TikTok's Content Posting API today: - ❌ First comment (auto-post a follow-up reply) - ❌ Add hashtags as separate metadata - ❌ Add links (URLs in caption are stripped) - ❌ Edit a post after publish - ❌ Save as draft from API - ❌ Schedule via TikTok's native scheduler (we use our own queue) - ❌ Post Stories or LIVE content - ❌ Apply effects, filters, or sounds programmatically ## Full control (nested shape) The flat shape above covers every TikTok use case. If you'd rather group every per-target field under `targets[]` — for example because you're fanning the same post out to multiple platforms and want each target's caption, media list, and options in one object — use the **nested shape**. Replace `content` → `caption`, `scheduledFor` → `scheduledAt`, `platforms` → `targets`, and `accountId` → `socialAccountId`. Everything else is identical. ```js Node.js const post = await postbreeze.posts.create({ caption: "Behind the scenes from launch week ✨", scheduledAt: new Date(Date.now() + 60_000).toISOString(), mediaIds: ["med_video_…"], targets: [ { socialAccountId: "soc_tiktok_…", platformOptions: { platform: "TIKTOK_PERSONAL", privacy: "PUBLIC_TO_EVERYONE", allowComments: true, allowDuet: false, allowStitch: false, autoAddMusic: false, brandOrganicToggle: false, brandContentToggle: false, }, }, ], }); ``` --- # X (Twitter) > Schedule tweets, threads, and replies — with media, reply settings, and Pay-Per-Use billing notes. ## Quick reference | Field | Value | |---|---| | Character limit | 280 per tweet (Premium accounts: 4,000) | | Thread length | 1–25 tweets | | Images per tweet | 1–4 | | Videos per tweet | 1 | | Mixed media | ❌ Photos OR video, never both | | Image formats | JPEG, PNG, GIF, WebP | | Video formats | MP4, MOV | | Max image size | 5 MB | | Max video size | 512 MB | | Video duration | up to 2:20 (140s), Premium up to 4 hours | | Aspect ratio | Any (X auto-crops) | | Post types | Tweet, Thread, Reply | | First comment | ✅ (posted as a self-reply to the root tweet) | | Billing | Pay-Per-Use — $0.01 per write call | See also: [Platform settings — X](/concepts/platform-settings#x-twitter) and [Media uploads](/concepts/media-uploads). ## Before you start X moved to **Pay-Per-Use** billing in February 2026 for new signups. Every tweet write is metered at $0.01 (regular) or $0.02 (write with URL). Postbreeze tracks this per-account and reports it via the Stripe meter. The first-comment feature on X **doubles the cost** per scheduled post — the main tweet + the self-reply are two separate writes. The X access token is short-lived (~2 hours) but Postbreeze refreshes it transparently on a 30-minute tick. You don't have to do anything other than connect once. Required scopes (already granted by the connect flow): - `tweet.read`, `tweet.write`, `users.read`, `offline.access` — base posting + refresh. - `media.write` — uploading images and videos. ## Quick start Workspace is inferred from your API key — no `workspaceId` argument. The **flat shape** (`content` + `platforms`) covers the common case; drop to the [nested shape](#full-control-nested-shape) at the end of this page when you'd rather pass everything as `targets`. ```ts SDK import Postbreeze from "@postbreeze/node"; const postbreeze = new Postbreeze({ apiKey: process.env.POSTBREEZE_API_KEY! }); await postbreeze.posts.create({ content: "Shipped a new dashboard today 🚀", scheduledFor: new Date(Date.now() + 30_000).toISOString(), mediaItems: [{ type: "image", url: "https://cdn.example.com/screenshot.png" }], platforms: [{ accountId: "soc_x_…" }], }); ``` ### With X-specific options Set `replySettings` (or any other X field) under `platformOptions` on the target. The discriminator `platform: "X"` is required. ```js Node.js import Postbreeze from "@postbreeze/node"; const postbreeze = new Postbreeze({ apiKey: process.env.POSTBREEZE_API_KEY }); const post = await postbreeze.posts.create({ content: "Shipped a new dashboard today 🚀", scheduledFor: new Date(Date.now() + 30_000).toISOString(), mediaIds: ["med_image_…"], platforms: [{ accountId: "soc_x_…", platformOptions: { platform: "X", replySettings: "everyone", }, }], }); ``` ```python Python import os from datetime import datetime, timedelta, timezone import requests API = "https://api.postbreeze.ai/api/v1" res = requests.post( f"{API}/posts", headers={"Authorization": f"Bearer {os.environ['POSTBREEZE_API_KEY']}"}, json={ "content": "Shipped a new dashboard today 🚀", "scheduledFor": (datetime.now(timezone.utc) + timedelta(seconds=30)).isoformat(), "mediaIds": ["med_image_…"], "platforms": [{ "accountId": "soc_x_…", "platformOptions": {"platform": "X", "replySettings": "everyone"}, }], }, ) res.raise_for_status() ``` ```bash curl curl -X POST https://api.postbreeze.ai/api/v1/posts \ -H "Authorization: Bearer $POSTBREEZE_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "content": "Shipped a new dashboard today 🚀", "scheduledFor": "2026-06-02T12:00:30Z", "mediaIds": ["med_image_…"], "platforms": [{ "accountId": "soc_x_…", "platformOptions": { "platform": "X", "replySettings": "everyone" } }] }' ``` ## Content types ### Single tweet Plain text, plus up to 4 images OR 1 video. ### Thread Pass `threadParts` under `platformOptions`. `content` is the **root tweet** (the first one posted); each entry in `threadParts` is chained as a reply to the previous one, so `threadParts[0]` becomes the second tweet. Up to 25 parts, each ≤ 4,000 chars. ```js Node.js const post = await postbreeze.posts.create({ content: "Five lessons from shipping our first SaaS 🧵", scheduledFor: new Date(Date.now() + 30_000).toISOString(), platforms: [{ accountId: "soc_x_…", platformOptions: { platform: "X", replySettings: "everyone", threadParts: [ "1. Ship before it feels ready.", "2. Pricing is product. Spend a week on it.", "3. Talk to ten customers. Then ten more.", "4. Boring tech > exciting tech.", "5. The 'simple version' usually wins.", ], }, }], }); ``` ```python Python res = requests.post( f"{API}/posts", headers={"Authorization": f"Bearer {os.environ['POSTBREEZE_API_KEY']}"}, json={ "content": "Five lessons from shipping our first SaaS 🧵", "scheduledFor": (datetime.now(timezone.utc) + timedelta(seconds=30)).isoformat(), "platforms": [{ "accountId": "soc_x_…", "platformOptions": { "platform": "X", "replySettings": "everyone", "threadParts": [ "1. Ship before it feels ready.", "2. Pricing is product. Spend a week on it.", "3. Talk to ten customers. Then ten more.", "4. Boring tech > exciting tech.", "5. The 'simple version' usually wins.", ], }, }], }, ) res.raise_for_status() ``` ```bash curl curl -X POST https://api.postbreeze.ai/api/v1/posts \ -H "Authorization: Bearer $POSTBREEZE_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "content": "Five lessons from shipping our first SaaS 🧵", "scheduledFor": "2026-06-02T12:00:30Z", "platforms": [{ "accountId": "soc_x_…", "platformOptions": { "platform": "X", "replySettings": "everyone", "threadParts": [ "1. Ship before it feels ready.", "2. Pricing is product. Spend a week on it.", "3. Talk to ten customers. Then ten more.", "4. Boring tech > exciting tech.", "5. The simple version usually wins." ] } }] }' ``` ### Reply Pass `replyToId` (the tweet id you're replying to). The post becomes a reply rather than a top-level tweet. ```js platforms: [{ accountId: "soc_x_…", platformOptions: { platform: "X", replySettings: "everyone", replyToId: "1789012345678901234", }, }] ``` ## Media requirements ### Images | Property | Requirement | |---|---| | Images per tweet | 1–4 | | Formats | JPEG, PNG, GIF, WebP | | Max file size | 5 MB | | Animated GIF | ✅ (counts as the single allowed video) | | Min resolution | 4 × 4 | | Max resolution | 8192 × 8192 | ### Videos | Property | Requirement | |---|---| | Videos per tweet | 1 | | Formats | MP4, MOV | | Max file size | 512 MB | | Max duration | 140 seconds (Premium: 4 hours) | | Aspect ratio | 1:3 to 3:1 | | Codecs | H.264 (baseline / main / high), AAC | | Resolution | 32 × 32 min, 1920 × 1200 max | | Frame rate | up to 60 fps | For URL ingest vs pre-uploaded `mediaIds`, see [Media uploads](/concepts/media-uploads). ## Platform-specific fields Full reference: [Platform settings — X](/concepts/platform-settings#x-twitter). | Field | Type | Required | Description | |---|---|---|---| | `platform` | `"X"` | Yes | Discriminator. | | `replySettings` | `"everyone"` \| `"following"` \| `"mentionedUsers"` | No (default `everyone`) | Who can reply to the main tweet. | | `threadParts` | string[] | No | Additional tweet bodies. Each ≤ 4,000 chars, up to 25 total. `content` is the root tweet; `threadParts[0]` becomes the second tweet. | | `replyToId` | string | No | If set, the tweet posts as a reply to this tweet id. | ## First comment Pass `firstComment` on the platform target. Postbreeze posts it as a self-reply to the **root tweet** — even in a thread, it always replies to `content`, not to the last `threadParts` entry. ```js platforms: [{ accountId: "soc_x_…", platformOptions: { platform: "X", replySettings: "everyone" }, firstComment: "Link to the full write-up in the next reply 👇", }] ``` **Reply-settings conflict:** if `replySettings` is `following` or `mentionedUsers` AND `firstComment` is set, the author can't actually reply to their own tweet (X returns `400 Invalid reply settings`). Postbreeze auto-relaxes the main tweet's `replySettings` to `everyone` server-side when both are present — the X tab in compose shows a banner about this. ## Analytics | Metric | Available | |---|---| | Impressions | ✅ (≤ 30 days old) | | Likes | ✅ | | Retweets | ✅ | | Replies | ✅ | | Bookmarks | ✅ | | Followers (account) | ✅ | | Tweet count (account) | ✅ | | URL clicks | ✅ (≤ 30 days, owned tweets only) | | Profile clicks | ✅ (≤ 30 days, owned tweets only) | | Video views | ✅ | Refresh cadence: every **12 hours**. Gated behind `X_ANALYTICS_ENABLED` env on the Postbreeze server (off by default so deploys don't accidentally bill the customer for analytics they didn't ask for). When off, the analytics page shows a "warming up" banner. ## Common errors | Error | Meaning | Fix | |---|---|---| | `X_RATE_LIMITED` | App or user rate limit hit | Postbreeze retries with the `Retry-After` value X returns. | | `X_DUPLICATE_TWEET` | Identical tweet body posted recently | Vary the text or wait. X enforces this aggressively on near-identical content. | | `X_MEDIA_TOO_LARGE` | Image > 5 MB or video > 512 MB | Compress before uploading. | | `X_MIXED_MEDIA` | Images + video in `mediaIds` | Pick one. | | `X_TOO_MANY_IMAGES` | > 4 images | Trim to ≤ 4. | | `X_INVALID_REPLY_SETTINGS` | First-comment + restricted `replySettings` (handled, but surfaces as a fallback) | Postbreeze forces `everyone` automatically. | | `X_TOKEN_EXPIRED` | Refresh token rejected by X (`invalid_grant`) | Reconnect. The dashboard's Account Health dialog explains the exact reason from X. | | `X_PAYMENT_REQUIRED` | Workspace doesn't have a payment method but X is Pay-Per-Use | Add a card in Settings → Billing. | ## What you can't do - ❌ Schedule via X's native scheduler (we use our own queue) - ❌ Mix images and video in one tweet - ❌ Edit a tweet after publish (Premium feature; not exposed by the API) - ❌ Tag a location - ❌ Add a poll - ❌ Quote-tweet from a scheduled post (use `replyToId` for a reply chain instead) - ❌ Spaces (audio rooms) — not in the API ## Full control: nested shape If you'd rather use the nested request body (`caption` + `targets` instead of `content` + `platforms`), the same X options apply. Pick one shape per request — don't mix. ```js Node.js const post = await postbreeze.posts.create({ caption: "Shipped a new dashboard today 🚀", scheduledAt: new Date(Date.now() + 30_000).toISOString(), mediaIds: ["med_image_…"], targets: [{ socialAccountId: "soc_x_…", platformOptions: { platform: "X", replySettings: "everyone", threadParts: [ "Why we rebuilt it from scratch ↓", "The old one was a Rails monolith from 2019.", ], }, firstComment: "Changelog in the next reply 👇", }], }); ``` --- # LinkedIn > Schedule LinkedIn Personal and Company posts — single image, multi-image gallery, video, and PDF carousel. ## Quick reference | Field | Value | |---|---| | Caption limit | 3,000 characters | | Images per post | 1–20 | | Videos per post | 1 | | PDF documents per post | 1 | | Mixed media | ❌ Image OR video, never both | | Image formats | JPG, PNG, GIF, WebP, HEIC, HEIF | | Video formats | MP4, MOV, AVI, WebM | | Document format | PDF | | Max image / video size | 5 GB | | Max document size | 100 MB | | Post types | Personal, Company, PDF Carousel | | First comment | ✅ Auto-posts after publish | | Account types | LinkedIn Personal + Company Pages | ## Before you start LinkedIn ships **two distinct multi-image experiences** with very different reach. Postbreeze exposes both: - **Photo gallery (MultiImage)** — 2–20 images in a swipeable gallery. Lighter feed treatment. - **Document carousel (PDF)** — your images composited into a single PDF that LinkedIn renders as a full-bleed slide deck. Typically **1.5–3× the organic reach** of MultiImage posts. Pick via `postAsPdfCarousel: true | false` on the platform options. See [platform settings → LinkedIn Personal](/concepts/platform-settings#linkedin-personal) and [LinkedIn Company](/concepts/platform-settings#linkedin-company). Required scopes: - **Personal**: `openid`, `profile`, `email`, `w_member_social`, `w_member_social_feed`. - **Company**: `w_organization_social`, `r_organization_social`, `rw_organization_admin`, `w_organization_social_feed`. The `_feed` scopes were added in May 2026 for first-comment support. Accounts connected before then need to reconnect — the compose UI shows a banner. For analytics, additional scopes may be required and gated behind LinkedIn's Marketing Developer Platform review (multi-week process). Posting works without them. ## Quick start Workspace is inferred from your API key — no `workspaceId` argument. All examples use the **flat shape** (`content` + `platforms`); the [nested shape](#full-control-nested-shape) at the bottom is equivalent and accepted by the same endpoint. ```ts SDK import Postbreeze from "@postbreeze/node"; const postbreeze = new Postbreeze({ apiKey: process.env.POSTBREEZE_API_KEY! }); await postbreeze.posts.create({ content: "Reflections on our first year — what I'd do differently.", scheduledFor: new Date(Date.now() + 30_000).toISOString(), mediaItems: [{ type: "image", url: "https://cdn.example.com/photo.jpg" }], platforms: [{ accountId: "soc_linkedin_…" }], }); ``` ```js Node.js import Postbreeze from "@postbreeze/node"; const postbreeze = new Postbreeze({ apiKey: process.env.POSTBREEZE_API_KEY }); const post = await postbreeze.posts.create({ content: "Reflections on our first year — what I'd do differently.", scheduledFor: new Date(Date.now() + 30_000).toISOString(), mediaItems: [{ type: "image", url: "https://cdn.example.com/photo.jpg" }], platforms: [{ accountId: "soc_linkedin_…", platformOptions: { platform: "LINKEDIN_PERSON", visibility: "PUBLIC", }, }], }); ``` ```python Python import os from datetime import datetime, timedelta, timezone import requests API = "https://api.postbreeze.ai/api/v1" res = requests.post( f"{API}/posts", headers={"Authorization": f"Bearer {os.environ['POSTBREEZE_API_KEY']}"}, json={ "content": "Reflections on our first year — what I'd do differently.", "scheduledFor": (datetime.now(timezone.utc) + timedelta(seconds=30)).isoformat(), "mediaItems": [{"type": "image", "url": "https://cdn.example.com/photo.jpg"}], "platforms": [{ "accountId": "soc_linkedin_…", "platformOptions": { "platform": "LINKEDIN_PERSON", "visibility": "PUBLIC", }, }], }, ) res.raise_for_status() ``` ```bash curl curl -X POST https://api.postbreeze.ai/api/v1/posts \ -H "Authorization: Bearer $POSTBREEZE_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "content": "Reflections on our first year — what I would do differently.", "scheduledFor": "2026-06-02T12:00:30Z", "mediaItems": [ { "type": "image", "url": "https://cdn.example.com/photo.jpg" } ], "platforms": [{ "accountId": "soc_linkedin_…", "platformOptions": { "platform": "LINKEDIN_PERSON", "visibility": "PUBLIC" } }] }' ``` For details on `mediaItems` vs. pre-uploaded `mediaIds`, see [media uploads](/concepts/media-uploads). ## Content types ### Personal post `platform: "LINKEDIN_PERSON"`. Works for text-only, single image, single video, MultiImage gallery (2–20 images), PDF document, or PDF carousel (composited from images). ### Company page post `platform: "LINKEDIN_COMPANY"`. Same supported shapes as Personal. The connected account must be on the workspace whose Company Page the user administers — the OAuth flow fans out one row per Page the user can post to. Company actors always render publicly server-side regardless of `visibility`. ### MultiImage gallery (2–20 images) Send 2–20 images. Per-image alt text is supported via `altText` on each `mediaItem`. ```js Node.js const post = await postbreeze.posts.create({ content: "5 patterns I see in fast-growing B2B teams 🧵", scheduledFor: new Date(Date.now() + 30_000).toISOString(), mediaItems: [ { type: "image", url: "https://cdn.example.com/cover.jpg", altText: "Cover slide titled '5 patterns'" }, { type: "image", url: "https://cdn.example.com/p1.jpg", altText: "Pattern 1 — They write internal memos" }, { type: "image", url: "https://cdn.example.com/p2.jpg" }, { type: "image", url: "https://cdn.example.com/p3.jpg" }, { type: "image", url: "https://cdn.example.com/p4.jpg" }, ], platforms: [{ accountId: "soc_linkedin_…", platformOptions: { platform: "LINKEDIN_PERSON", visibility: "PUBLIC", postAsPdfCarousel: false, }, }], }); ``` ```python Python res = requests.post( f"{API}/posts", headers={"Authorization": f"Bearer {os.environ['POSTBREEZE_API_KEY']}"}, json={ "content": "5 patterns I see in fast-growing B2B teams 🧵", "scheduledFor": (datetime.now(timezone.utc) + timedelta(seconds=30)).isoformat(), "mediaItems": [ {"type": "image", "url": "https://cdn.example.com/cover.jpg", "altText": "Cover slide titled '5 patterns'"}, {"type": "image", "url": "https://cdn.example.com/p1.jpg", "altText": "Pattern 1 — They write internal memos"}, {"type": "image", "url": "https://cdn.example.com/p2.jpg"}, {"type": "image", "url": "https://cdn.example.com/p3.jpg"}, {"type": "image", "url": "https://cdn.example.com/p4.jpg"}, ], "platforms": [{ "accountId": "soc_linkedin_…", "platformOptions": { "platform": "LINKEDIN_PERSON", "visibility": "PUBLIC", "postAsPdfCarousel": False, }, }], }, ) res.raise_for_status() ``` ```bash curl curl -X POST https://api.postbreeze.ai/api/v1/posts \ -H "Authorization: Bearer $POSTBREEZE_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "content": "5 patterns I see in fast-growing B2B teams 🧵", "scheduledFor": "2026-06-02T12:00:30Z", "mediaItems": [ { "type": "image", "url": "https://cdn.example.com/cover.jpg", "altText": "Cover slide titled 5 patterns" }, { "type": "image", "url": "https://cdn.example.com/p1.jpg", "altText": "Pattern 1 — They write internal memos" }, { "type": "image", "url": "https://cdn.example.com/p2.jpg" }, { "type": "image", "url": "https://cdn.example.com/p3.jpg" }, { "type": "image", "url": "https://cdn.example.com/p4.jpg" } ], "platforms": [{ "accountId": "soc_linkedin_…", "platformOptions": { "platform": "LINKEDIN_PERSON", "visibility": "PUBLIC", "postAsPdfCarousel": false } }] }' ``` ### Document carousel (composited from images) Send 2+ images and `postAsPdfCarousel: true`. Postbreeze composites the images into a single PDF and uploads as a document — LinkedIn renders it as a swipeable slide deck. `pdfCarouselTitle` is required and shows under the carousel (≤ 100 chars). ```ts await postbreeze.posts.create({ content: "5 patterns I see in fast-growing B2B teams 🧵", scheduledFor: new Date(Date.now() + 30_000).toISOString(), mediaItems: [ { type: "image", url: "https://cdn.example.com/slide1.jpg" }, { type: "image", url: "https://cdn.example.com/slide2.jpg" }, { type: "image", url: "https://cdn.example.com/slide3.jpg" }, ], platforms: [{ accountId: "soc_linkedin_…", platformOptions: { platform: "LINKEDIN_PERSON", visibility: "PUBLIC", postAsPdfCarousel: true, pdfCarouselTitle: "5 patterns I see", }, }], }); ``` ### Real PDF document Attach an actual PDF — LinkedIn renders it as a swipeable carousel natively. This is **separate from** `postAsPdfCarousel` (which composites images into a synthetic PDF); here you're uploading a real document. Use `type: "document"` in `mediaItems`, or pass a `med_…` id from a presigned PDF upload via `mediaIds`. PDFs **only work on LinkedIn**. Cross-posting a `type: "document"` item to any non-LinkedIn target returns 400. Send PDFs to LinkedIn accounts only. ```ts await postbreeze.posts.create({ content: "Our 2026 product strategy in one deck.", scheduledFor: new Date(Date.now() + 30_000).toISOString(), mediaItems: [ { type: "document", url: "https://cdn.example.com/strategy-deck.pdf" }, ], platforms: [{ accountId: "soc_linkedin_…", platformOptions: { platform: "LINKEDIN_PERSON", visibility: "PUBLIC" }, }], }); ``` ### Video `platform: "LINKEDIN_PERSON"` (or `LINKEDIN_COMPANY`) with a single video `mediaItem`. ```ts await postbreeze.posts.create({ content: "Walking through our new onboarding flow.", scheduledFor: new Date(Date.now() + 30_000).toISOString(), mediaItems: [{ type: "video", url: "https://cdn.example.com/onboarding.mp4" }], platforms: [{ accountId: "soc_linkedin_…", platformOptions: { platform: "LINKEDIN_PERSON", visibility: "PUBLIC" }, }], }); ``` ## Media requirements ### Images | Property | Requirement | |---|---| | Images per post | 1–20 | | Formats | JPG, PNG, GIF, WebP, HEIC, HEIF | | Max file size | 5 GB | | Aspect ratio | Any (LinkedIn auto-crops to 1.91:1 in Feed) | | Per-image alt text | ✅ via `altText` on each `mediaItem` | | Max alt-text length | 1,000 chars | ### Videos | Property | Requirement | |---|---| | Videos per post | 1 | | Formats | MP4, MOV, AVI, WebM | | Max file size | 5 GB | | Aspect ratio | 16:9 to 1:2.4 | | Codecs | H.264 + AAC | | Max resolution | 4K | ### Documents | Property | Requirement | |---|---| | Documents per post | 1 | | Format | PDF | | Max file size | 100 MB | ## Platform-specific fields See [platform settings](/concepts/media-uploads) for the canonical schema reference. ### Personal — [`#linkedin-personal`](/concepts/platform-settings#linkedin-personal) | Field | Type | Required | Description | |---|---|---|---| | `platform` | `"LINKEDIN_PERSON"` | Yes | Discriminator. | | `visibility` | `"PUBLIC"` \| `"CONNECTIONS"` | No (default `PUBLIC`) | Connections-only is honored by LinkedIn's audience filter. | | `postAsPdfCarousel` | boolean | No | When true and 2+ images attached, composites them into a PDF and posts as a Document carousel. | | `pdfCarouselTitle` | string (≤ 100) | When `postAsPdfCarousel: true` | Title shown under the carousel. | ### Company — [`#linkedin-company`](/concepts/platform-settings#linkedin-company) | Field | Type | Required | Description | |---|---|---|---| | `platform` | `"LINKEDIN_COMPANY"` | Yes | Discriminator. | | `visibility` | `"PUBLIC"` \| `"CONNECTIONS"` | No (default `PUBLIC`) | Kept for shape parity. Company actors **always** post publicly — server-forced. | | `postAsPdfCarousel` | boolean | No | Same as Personal. | | `pdfCarouselTitle` | string (≤ 100) | No | Same as Personal. | ## First comment Pass `firstComment` on the platform entry. Postbreeze waits 8 seconds after the main post (LinkedIn enforces a per-member 1-minute throttle on create-actions) and then posts as the same author. Supported on both Personal and Company. ```ts platforms: [{ accountId: "soc_linkedin_…", platformOptions: { platform: "LINKEDIN_PERSON", visibility: "PUBLIC" }, firstComment: "Full breakdown: https://blog.example.com/patterns", }] ``` Limit: 1,250 characters per LinkedIn comment (smaller than the 3,000 caption cap — surface this in your UI). ## Analytics | Metric | Available | |---|---| | Likes | ✅ | | Comments | ✅ | | Reshares | ✅ | | Impressions | ⚠️ Requires Marketing Developer Platform approval | | Click-through rate | ⚠️ Requires MDP approval | | Reach (unique) | ⚠️ Requires MDP approval | | Engagement rate | ⚠️ Requires MDP approval | | Followers (Company) | ⚠️ Requires MDP approval | The Postbreeze analytics page detects when these scopes are missing and shows an "approval pending" banner instead of fake zeros. ## Common errors | Error | Meaning | Fix | |---|---|---| | `LI_FIRST_COMMENT_THROTTLED` | Hit the 1-minute per-member create limit | Use the retry button in the post-detail dialog. | | `LI_FIRST_COMMENT_SCOPE_MISSING` | Account doesn't have `w_member_social_feed` (or `w_organization_social_feed`) | Reconnect the account. | | `LI_MIXED_MEDIA` | Image + video in the same post | LinkedIn requires one or the other. | | `LI_PDF_VIDEO_CONFLICT` | `postAsPdfCarousel: true` with a video attached | Remove the video or set to false. | | `LI_PDF_NEEDS_TWO_IMAGES` | `postAsPdfCarousel: true` with only one image | Add at least one more image. | | `LI_MULTI_IMAGE_RATE_LIMITED` | Burst uploaded >5 images/sec to the Images API | Postbreeze retries with exponential backoff. | | `LI_TOKEN_EXPIRED` | LinkedIn refresh failed | Reconnect. | | `LI_CAPTION_TOO_LONG` | > 3,000 chars | Trim. | ## What you can't do - ❌ Schedule via LinkedIn's native scheduler - ❌ Mix image + video in one post - ❌ Multi-video posts (1 video max) - ❌ Image-in-first-comment (separate flow, deferred to v2) - ❌ Tag specific users via API (you can include `@Name` in text but no structured tags) - ❌ Polls or events - ❌ Newsletters - ❌ Edit a post after publish - ❌ Stories (LinkedIn deprecated these) ## Full control: nested shape The `targets` / `socialAccountId` / `caption` / `scheduledAt` / `mediaIds` shape is the original API surface and remains fully supported. Use whichever you prefer — both go to the same `POST /api/v1/posts` endpoint. ```ts import Postbreeze from "@postbreeze/node"; const postbreeze = new Postbreeze({ apiKey: process.env.POSTBREEZE_API_KEY! }); await postbreeze.posts.create({ caption: "Reflections on our first year — what I'd do differently.", scheduledAt: new Date(Date.now() + 30_000).toISOString(), mediaIds: ["med_image_…"], targets: [{ socialAccountId: "soc_linkedin_…", platformOptions: { platform: "LINKEDIN_PERSON", visibility: "PUBLIC", }, }], }); ``` --- # YouTube > Schedule YouTube video uploads — Shorts, long-form, COPPA flags, and first-comment via the Community surface. ## Quick reference | Field | Value | |---|---| | Title limit | 100 characters | | Description limit | 5,000 characters | | Tags limit | 500 chars total (sum of tags) | | Videos per post | 1 | | Video formats | MP4, MOV, WebM, MKV, FLV, AVI | | Max file size | 256 GB (or 12 hours, whichever is less) | | Max duration | 12 hours (verified accounts), 15 minutes otherwise | | Shorts duration | up to 60 seconds | | Shorts aspect ratio | 9:16 | | Long-form aspect ratio | 16:9 recommended | | Post types | Video upload (Short or long-form, derived from duration + aspect) | | First comment | ✅ Posts as a Community comment after upload | | COPPA disclosure | ✅ Required (`madeForKids` boolean) | ## Before you start YouTube **requires** every upload to declare `madeForKids: true | false`. The default in Postbreeze is `false`, but the choice matters — videos flagged Made For Kids have comments, notifications, and personalized ads disabled by YouTube. If you flag it wrong, you have to delete and re-upload. Always set this field explicitly so the decision is recorded in your code. The connected YouTube channel must: - Have completed the OAuth flow. - Have a verified phone number (YouTube enforces this for any video > 15 minutes). - Have `youtube.force-ssl` scope granted (Postbreeze requests this by default — covers upload + first-comment). Each upload counts as **1,600 quota units** against the channel's 10,000-unit daily quota. The first-comment call costs an additional 50 quota units. Postbreeze surfaces quota errors as `YT_QUOTA_EXCEEDED`. See [Platform settings](/concepts/platform-settings#youtube) for the full `platformOptions` reference and [Media uploads](/concepts/media-uploads) for the two ways to attach the video file (`mediaItems` URL ingest vs. pre-uploaded `mediaIds`). ## Quick start Workspace is inferred from your API key — no `workspaceId` argument. The flat shape (`content` + `platforms`) is canonical; pass YouTube's `madeForKids` flag in `platformOptions` on every upload. ```js Node.js import Postbreeze from "@postbreeze/node"; const postbreeze = new Postbreeze({ apiKey: process.env.POSTBREEZE_API_KEY }); await postbreeze.posts.create({ content: "60-second product demo", scheduledFor: new Date(Date.now() + 30_000).toISOString(), mediaItems: [ { type: "video", url: "https://cdn.example.com/demo.mp4" }, ], platforms: [ { accountId: "soc_youtube_…", platformOptions: { platform: "YOUTUBE", visibility: "PUBLIC", madeForKids: false, }, }, ], }); ``` ```python Python import os from datetime import datetime, timedelta, timezone import requests API = "https://api.postbreeze.ai/api/v1" res = requests.post( f"{API}/posts", headers={"Authorization": f"Bearer {os.environ['POSTBREEZE_API_KEY']}"}, json={ "content": "60-second product demo", "scheduledFor": (datetime.now(timezone.utc) + timedelta(seconds=30)).isoformat(), "mediaItems": [ {"type": "video", "url": "https://cdn.example.com/demo.mp4"}, ], "platforms": [ { "accountId": "soc_youtube_…", "platformOptions": { "platform": "YOUTUBE", "visibility": "PUBLIC", "madeForKids": False, }, }, ], }, ) res.raise_for_status() ``` ```bash curl curl -X POST https://api.postbreeze.ai/api/v1/posts \ -H "Authorization: Bearer $POSTBREEZE_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "content": "60-second product demo", "scheduledFor": "2026-06-01T12:00:30Z", "mediaItems": [ { "type": "video", "url": "https://cdn.example.com/demo.mp4" } ], "platforms": [ { "accountId": "soc_youtube_…", "platformOptions": { "platform": "YOUTUBE", "visibility": "PUBLIC", "madeForKids": false } } ] }' ``` ## Content types ### Long-form video Any video over 3 minutes, or any video that is not 9:16. The post `content` becomes the YouTube description; `youtubeTitle` (if set) becomes the YouTube title. If you don't pass `youtubeTitle`, the first line of `content` (≤ 100 chars) is used. ```js await postbreeze.posts.create({ content: "Deep dive on our new dashboard.\n\nChapters, links, and resources below.", scheduledFor: "2026-06-10T15:00:00Z", mediaItems: [ { type: "video", url: "https://cdn.example.com/dashboard-deep-dive.mp4" }, ], platforms: [ { accountId: "soc_youtube_…", platformOptions: { platform: "YOUTUBE", visibility: "PUBLIC", madeForKids: false, youtubeTitle: "Our new dashboard — deep dive (16 min)", }, }, ], }); ``` ### Short A video ≤ 3 minutes in 9:16 aspect ratio. YouTube auto-classifies it as a Short on their end — you don't pass a Shorts flag; just ensure the file matches Shorts requirements. ```js await postbreeze.posts.create({ content: "How we ship on Fridays", scheduledFor: "2026-06-10T15:00:00Z", mediaItems: [ { type: "video", url: "https://cdn.example.com/friday-ship.mp4" }, ], platforms: [ { accountId: "soc_youtube_…", platformOptions: { platform: "YOUTUBE", visibility: "PUBLIC", madeForKids: false, }, }, ], }); ``` ### Unlisted preview Set `visibility: "UNLISTED"` to upload without making the video discoverable. Useful for sharing with reviewers before public launch. ```js await postbreeze.posts.create({ content: "Reviewer preview — please don't share", scheduledFor: new Date(Date.now() + 30_000).toISOString(), mediaItems: [ { type: "video", url: "https://cdn.example.com/preview.mp4" }, ], platforms: [ { accountId: "soc_youtube_…", platformOptions: { platform: "YOUTUBE", visibility: "UNLISTED", madeForKids: false, }, }, ], }); ``` ## Media requirements ### Videos | Property | Requirement | |---|---| | Videos per post | 1 | | Formats | MP4, MOV, WebM, MKV, FLV, AVI | | Max file size | 256 GB | | Max duration | 12 hours (verified accounts), 15 minutes (non-verified) | | Shorts duration | up to 3 minutes | | Shorts aspect ratio | 9:16 | | Long-form aspect ratio | 16:9 recommended | | Codecs | H.264, VP9, AV1 | | Frame rate | 24–60 fps | | Audio codecs | AAC, MP3, Vorbis, Opus | YouTube targets accept a **single video**. Images, GIFs, and documents are rejected at validation. See [Media uploads](/concepts/media-uploads) for the URL ingest vs. pre-upload trade-off. ## Platform-specific fields | Field | Type | Required | Description | |---|---|---|---| | `platform` | `"YOUTUBE"` | Yes | Discriminator. | | `visibility` | `"PUBLIC"` \| `"UNLISTED"` \| `"PRIVATE"` | No (default `PUBLIC`) | Honored by YouTube exactly as set. | | `madeForKids` | boolean | No (default `false`) | COPPA disclosure. Disables comments + personalized ads when true. Set explicitly on every upload. | | `youtubeTitle` | string | No | Override for the title (≤ 100 chars). Falls back to first line of `content`. | Full schema in [Platform settings → YouTube](/concepts/platform-settings#youtube). ## First comment YouTube exposes a `commentThreads.insert` endpoint that lets the channel owner post a top-level comment on a video they own. Postbreeze calls it after the upload completes — it costs 50 quota units, comes back with a `commentId`, and posts as the channel itself (not as a personal Google account). ```js await postbreeze.posts.create({ content: "Our new dashboard in 60 seconds", scheduledFor: new Date(Date.now() + 30_000).toISOString(), mediaItems: [ { type: "video", url: "https://cdn.example.com/demo.mp4" }, ], platforms: [ { accountId: "soc_youtube_…", platformOptions: { platform: "YOUTUBE", visibility: "PUBLIC", madeForKids: false, }, firstComment: "Timestamps in the description ⬇️", }, ], }); ``` If the video has comments disabled (e.g. `madeForKids: true`, or you've set channel-wide comments off), the first-comment call fails with `YT_COMMENTS_DISABLED` and the warning banner appears on the post. The main upload still succeeds. Comment limit: 10,000 characters. ## Analytics | Metric | Available | |---|---| | Views | ✅ | | Watch time (minutes) | ✅ | | Avg view duration | ✅ | | Likes | ✅ | | Dislikes | ❌ (YouTube removed this from the public API) | | Comments | ✅ | | Subscribers gained from video | ✅ | | Shares | ✅ | | Click-through rate (impressions → views) | ✅ | | Audience demographics | ✅ (channel-level) | Refresh cadence: every **14 days**. Per YouTube's retention rule, raw `MetricSnapshot` rows are purged on disconnect or on platform 404 (video deleted, channel suspended). ## Common errors | Error | Meaning | Fix | |---|---|---| | `YT_QUOTA_EXCEEDED` | Channel hit the 10,000-unit daily quota | Wait for the daily reset. The first-comment call is skipped if the upload alone burns most of the budget. | | `YT_COMMENTS_DISABLED` | First-comment failed because the video doesn't allow comments | Disable `madeForKids` for the video, or accept that first-comment is impossible on this content. | | `YT_INVALID_VIDEO_FORMAT` | Container or codec YouTube doesn't accept | Re-encode to H.264 + AAC. | | `YT_MADE_FOR_KIDS_REQUIRED` | The COPPA disclosure was missing | Pass `madeForKids` explicitly. | | `YT_UPLOAD_QUOTA_EXCEEDED` | More than the daily upload count for unverified accounts | Verify the channel's phone number. | | `YT_TITLE_TOO_LONG` | Title > 100 chars | Trim. | | `YT_DESCRIPTION_TOO_LONG` | Description > 5,000 chars | Trim. | | `YT_TOKEN_EXPIRED` | OAuth refresh failed | Reconnect. | ## What you can't do - ❌ Edit video metadata after upload (planned for v2) - ❌ Schedule using YouTube Studio's native scheduler (we run our own queue) - ❌ Upload to a Brand Account other than the connected one - ❌ Set a custom thumbnail via API (works only for long-form videos on verified channels and isn't exposed in v1) - ❌ Pass tags, override the category, or flag synthetic media — Postbreeze applies server-side defaults (category `22` — People & Blogs) - ❌ Add to playlists at upload time - ❌ Premieres or Live streams - ❌ Community posts (text/poll/image posts) — only video uploads + first-comments - ❌ Set end screens or cards ## Full control: nested shape The nested shape (`caption` + `targets`, `socialAccountId` per target) is the lower-level surface the flat shape compiles down to. Use it when you want a single payload that targets multiple platforms with distinct per-platform captions or media. The YouTube `platformOptions` are identical. ```js import Postbreeze from "@postbreeze/node"; const postbreeze = new Postbreeze({ apiKey: process.env.POSTBREEZE_API_KEY }); await postbreeze.posts.create({ caption: "60-second product demo", scheduledAt: new Date(Date.now() + 30_000).toISOString(), mediaIds: ["med_video_…"], targets: [ { socialAccountId: "soc_youtube_…", platformOptions: { platform: "YOUTUBE", visibility: "PUBLIC", madeForKids: false, youtubeTitle: "Our new dashboard in 60s", }, firstComment: "Timestamps in the description ⬇️", }, ], }); ``` --- # Pinterest > Schedule Pinterest pins — single image, carousel pin, with board selection, titles, and outbound links. ## Quick reference | Field | Value | |---|---| | Title limit | 100 characters | | Description limit | 500 characters | | Images per pin | 1 (standard) or 2–5 (carousel) | | Videos per pin | 0 (Pinterest video pins not exposed) | | Image formats | JPEG, PNG | | Max image size | 20 MB | | Aspect ratio | 2:3 recommended (long pin) | | Min resolution | 600 × 900 | | Carousel aspect ratio | 1:1 or 2:3 only | | Board | **Required** — pick at compose time | | Outbound link | ✅ Per-pin click destination | | First comment | ❌ Pinterest doesn't expose comment API | | Post types | Pin (single), Carousel pin | ## Before you start Pinterest **requires** every pin to land on a specific **board**. You must pass the `board` ID in `platformOptions` — there's no default board at the API layer (the compose UI pre-fills one from the connected account, but the API does not). The compose dashboard fetches the available board list at connect time and stores it on the `SocialAccount`; pulling the list dynamically from the public API isn't available in v1. See [Platform settings → Pinterest](/concepts/platform-settings#pinterest) for the full schema and [Media uploads](/concepts/media-uploads) for how to attach images. Required scopes (granted by the connect flow): - `boards:read` — list the boards you can pin to. - `boards:read_secret` — list secret boards. - `pins:read`, `pins:write` — read + create pins. Pinterest carousel pins are **image-only**. Mixing video isn't supported by the API at all (video pins go through a separate flow we haven't shipped yet). ## Quick start Workspace is inferred from your API key — no `workspaceId` argument. Pinterest **always** needs `platformOptions.board` because every pin lands on a specific board — so even the flat-shape quickstart sets it. Drop to the [nested shape](#full-control-nested-shape) when you want a different stylistic layout for the same request. ```js Node.js import Postbreeze from "@postbreeze/node"; const postbreeze = new Postbreeze({ apiKey: process.env.POSTBREEZE_API_KEY }); const post = await postbreeze.posts.create({ content: "20 minimalist home office setups for 2026", scheduledFor: new Date(Date.now() + 30_000).toISOString(), mediaItems: [{ type: "IMAGE", url: "https://cdn.example.com/office.jpg" }], platforms: [ { accountId: "soc_pinterest_…", platformOptions: { platform: "PINTEREST", board: "pinterest_board_id_…", title: "20 minimalist home office setups", link: "https://example.com/blog/home-office-setups", }, }, ], }); ``` ```python Python import os from datetime import datetime, timedelta, timezone import requests API = "https://api.postbreeze.ai/api/v1" res = requests.post( f"{API}/posts", headers={"Authorization": f"Bearer {os.environ['POSTBREEZE_API_KEY']}"}, json={ "content": "20 minimalist home office setups for 2026", "scheduledFor": (datetime.now(timezone.utc) + timedelta(seconds=30)).isoformat(), "mediaItems": [ {"type": "IMAGE", "url": "https://cdn.example.com/office.jpg"} ], "platforms": [ { "accountId": "soc_pinterest_…", "platformOptions": { "platform": "PINTEREST", "board": "pinterest_board_id_…", "title": "20 minimalist home office setups", "link": "https://example.com/blog/home-office-setups", }, } ], }, ) res.raise_for_status() ``` ```bash curl curl -X POST https://api.postbreeze.ai/api/v1/posts \ -H "Authorization: Bearer $POSTBREEZE_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "content": "20 minimalist home office setups for 2026", "scheduledFor": "2026-06-02T12:00:30Z", "mediaItems": [ { "type": "IMAGE", "url": "https://cdn.example.com/office.jpg" } ], "platforms": [{ "accountId": "soc_pinterest_…", "platformOptions": { "platform": "PINTEREST", "board": "pinterest_board_id_…", "title": "20 minimalist home office setups", "link": "https://example.com/blog/home-office-setups" } }] }' ``` ## Content types ### Single-image pin Default. Pass exactly one image — either inline via `mediaItems` or by reference via `mediaIds` (see [Media uploads](/concepts/media-uploads)). ### Carousel pin (2–5 images) Pass 2–5 image `mediaIds` (or `mediaItems`). All slides must share the same aspect ratio (1:1 OR 2:3) — Pinterest rejects mixed aspects at the API. ```js Node.js const post = await postbreeze.posts.create({ content: "Bathroom renovation — before & after", scheduledFor: new Date(Date.now() + 30_000).toISOString(), mediaIds: ["med_before_1", "med_after_1", "med_before_2", "med_after_2"], platforms: [ { accountId: "soc_pinterest_…", platformOptions: { platform: "PINTEREST", board: "pinterest_board_id_…", title: "Bathroom before & after", link: "https://example.com/case-study/bath", }, }, ], }); ``` ```python Python res = requests.post( f"{API}/posts", headers={"Authorization": f"Bearer {os.environ['POSTBREEZE_API_KEY']}"}, json={ "content": "Bathroom renovation — before & after", "scheduledFor": (datetime.now(timezone.utc) + timedelta(seconds=30)).isoformat(), "mediaIds": ["med_before_1", "med_after_1", "med_before_2", "med_after_2"], "platforms": [ { "accountId": "soc_pinterest_…", "platformOptions": { "platform": "PINTEREST", "board": "pinterest_board_id_…", "title": "Bathroom before & after", "link": "https://example.com/case-study/bath", }, } ], }, ) res.raise_for_status() ``` ```bash curl curl -X POST https://api.postbreeze.ai/api/v1/posts \ -H "Authorization: Bearer $POSTBREEZE_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "content": "Bathroom renovation — before & after", "scheduledFor": "2026-06-02T12:00:30Z", "mediaIds": ["med_before_1","med_after_1","med_before_2","med_after_2"], "platforms": [{ "accountId": "soc_pinterest_…", "platformOptions": { "platform": "PINTEREST", "board": "pinterest_board_id_…", "title": "Bathroom before & after", "link": "https://example.com/case-study/bath" } }] }' ``` ## Media requirements ### Images | Property | Requirement | |---|---| | Images per pin | 1 (standard), 2–5 (carousel) | | Formats | JPEG, PNG | | Max file size | 20 MB | | Min resolution | 600 × 900 | | Aspect ratio (single) | 2:3 recommended; 1:2.1 is "long pin" cutoff | | Aspect ratio (carousel) | 1:1 OR 2:3 (consistent across all slides) | Pinterest down-scales anything above 1,200 × 1,800. Higher resolutions are accepted but offer no display advantage. ## Platform-specific fields See the full schema at [Platform settings → Pinterest](/concepts/platform-settings#pinterest). | Field | Type | Required | Description | |---|---|---|---| | `platform` | `"PINTEREST"` | Yes | Discriminator. | | `board` | string | **Yes** | Pinterest board ID where the pin lands. No default at the API layer. | | `title` | string | No | Pin title (≤ 100 chars). | | `link` | URL | No | Outbound click destination. Tracked as `outbound_click_rate` in analytics. | The pin's **description** comes from the post-level `content` field — Pinterest caps it at 500 chars. `firstComment` is **not** supported on Pinterest — the platform doesn't expose a commercial comment API. Passing one is rejected at validation. ## Analytics Pinterest analytics include some signature metrics not exposed elsewhere: | Metric | Available | |---|---| | Impressions | ✅ | | Closeups (pin detail page opens) | ✅ | | Saves | ✅ | | Save rate (saves / impressions) | ✅ | | Engagement | ✅ | | Engagement rate | ✅ | | Outbound clicks | ✅ | | Outbound click rate | ✅ | | Monthly viewers (account) | ✅ | | Video views | ❌ (video pins not yet exposed) | Refresh cadence: every **14 days**. ## Common errors | Error | Meaning | Fix | |---|---|---| | `PIN_BOARD_REQUIRED` | `board` missing from `platformOptions` | Pass a board ID. | | `PIN_BOARD_NOT_FOUND` | The board ID isn't owned by this Pinterest account | List boards via the API and pick a valid one. | | `PIN_CAROUSEL_SIZE` | Carousel with < 2 or > 5 images | Trim to 2–5. | | `PIN_MIXED_ASPECT` | Carousel slides have different aspect ratios | Resize so every slide is 1:1 OR 2:3. | | `PIN_VIDEO_NOT_SUPPORTED` | Video in `mediaIds` | Pinterest video pins are deferred. | | `PIN_LINK_INVALID` | The `link` URL didn't resolve | Verify the URL works in an incognito browser. | | `PIN_RATE_LIMITED` | Pinterest per-account rate limit | Postbreeze retries with backoff. | ## What you can't do - ❌ Video pins (planned for v2) - ❌ Idea pins (deprecated by Pinterest) - ❌ Story pins - ❌ Schedule via Pinterest's native scheduler - ❌ Edit a pin after publish (Pinterest deleted that API) - ❌ Add to multiple boards at once (one pin = one board; create multiple posts) - ❌ Section pins (sub-boards) at the API - ❌ Tag products (catalog feature, separate API) - ❌ First comment (no commercial comment API) ## Full control: nested shape The nested shape (`caption` + `targets` + `scheduledAt` + `socialAccountId`) is functionally identical to the flat shape — pick whichever reads better in your codebase. Every Pinterest target still requires `platformOptions.board`. ```js Node.js import Postbreeze from "@postbreeze/node"; const postbreeze = new Postbreeze({ apiKey: process.env.POSTBREEZE_API_KEY }); const post = await postbreeze.posts.create({ caption: "20 minimalist home office setups for 2026", scheduledAt: new Date(Date.now() + 30_000).toISOString(), mediaIds: ["med_image_…"], targets: [ { socialAccountId: "soc_pinterest_…", platformOptions: { platform: "PINTEREST", board: "pinterest_board_id_…", title: "20 minimalist home office setups", link: "https://example.com/blog/home-office-setups", }, }, ], }); ``` --- # Threads > Schedule Threads posts — text, image, video, carousel (up to 20 mixed items), plus multi-post threads. ## Quick reference | Field | Value | |---|---| | Character limit | 500 characters (per post — applies to root and every thread part) | | Carousel size | 2–20 items | | Mixed media in carousel | ✅ (image + video) | | Images per post | 1–20 | | Videos per post | 1 (single video post) or any subset of a carousel | | Image formats | JPEG, PNG, WebP | | Video formats | MP4, MOV | | Max image size | 8 MB | | Max video size | 1 GB | | Max video duration | 5 minutes | | Post types | Text, Image, Video, Carousel | | Multi-post threads | ✅ Up to 25 text-only follow-ups via `threadParts` | | First comment | ✅ (posts as a reply to the **root** post via the standard reply flow) | | Account types | Threads accounts (linked to Instagram) | ## Before you start Threads is a separate Meta product from Instagram with **its own host** — OAuth lives at `https://www.threads.net/oauth/authorize`, and the API at `https://graph.threads.net`. Don't route through `graph.facebook.com` even though Instagram does — Threads endpoints 404 there. Threads scopes must be comma-separated in the authorize URL; Postbreeze handles this automatically. Required scopes: - `threads_basic` — read profile. - `threads_content_publish` — publish threads. - `threads_manage_replies` + `threads_read_replies` — first-comment + inbox replies. - `threads_manage_insights` — analytics. All require Meta App Review + Business Verification before public users can grant them. ## Quick start Workspace is inferred from your API key — no `workspaceId` argument. Use the **flat shape** (`content` + `platforms`) for the common case — it defaults to a text post when you skip `mediaItems`. ```ts SDK import Postbreeze from "@postbreeze/node"; const postbreeze = new Postbreeze({ apiKey: process.env.POSTBREEZE_API_KEY! }); await postbreeze.posts.create({ content: "Threads is hitting a different stride lately. Anyone else feel it?", scheduledFor: new Date(Date.now() + 30_000).toISOString(), platforms: [{ accountId: "soc_threads_…" }], }); ``` See [Platform settings → Threads](/concepts/platform-settings#threads) for the full options reference and [Media uploads](/concepts/media-uploads) for `mediaItems` vs `mediaIds`. ## Content types ### Text-only `kind: "TEXT"`. Uses the `auto_publish_text=true` single-step flow on Threads. No media. ```js Node.js const post = await postbreeze.posts.create({ content: "Threads is hitting a different stride lately. Anyone else feel it?", scheduledFor: new Date(Date.now() + 30_000).toISOString(), platforms: [{ accountId: "soc_threads_…", platformOptions: { platform: "THREADS", kind: "TEXT" }, }], }); ``` ```python Python import os from datetime import datetime, timedelta, timezone import requests API = "https://api.postbreeze.ai/api/v1" res = requests.post( f"{API}/posts", headers={"Authorization": f"Bearer {os.environ['POSTBREEZE_API_KEY']}"}, json={ "content": "Threads is hitting a different stride lately. Anyone else feel it?", "scheduledFor": (datetime.now(timezone.utc) + timedelta(seconds=30)).isoformat(), "platforms": [{ "accountId": "soc_threads_…", "platformOptions": {"platform": "THREADS", "kind": "TEXT"}, }], }, ) res.raise_for_status() ``` ```bash curl curl -X POST https://api.postbreeze.ai/api/v1/posts \ -H "Authorization: Bearer $POSTBREEZE_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "content": "Threads is hitting a different stride lately. Anyone else feel it?", "scheduledFor": "2026-06-02T12:00:30Z", "platforms": [{ "accountId": "soc_threads_…", "platformOptions": { "platform": "THREADS", "kind": "TEXT" } }] }' ``` ### Single image `kind: "IMAGE"` with one image attached via `mediaIds` (or one entry in `mediaItems`). ### Single video `kind: "VIDEO"` with one video. Up to 5 minutes, 1 GB. Threads polls processing status; Postbreeze waits ~30s before declaring success. ### Carousel (2–20 items, mixed media) `kind: "CAROUSEL"` with 2–20 media items. **Threads is one of the few platforms that allows mixed image + video in a single carousel** — feel free to combine. ```js Node.js const post = await postbreeze.posts.create({ content: "Behind the scenes from this week's shoot — slide for the video 👇", mediaIds: ["med_img_1", "med_img_2", "med_video_1", "med_img_3"], scheduledFor: new Date(Date.now() + 30_000).toISOString(), platforms: [{ accountId: "soc_threads_…", platformOptions: { platform: "THREADS", kind: "CAROUSEL" }, }], }); ``` ```python Python res = requests.post( f"{API}/posts", headers={"Authorization": f"Bearer {os.environ['POSTBREEZE_API_KEY']}"}, json={ "content": "Behind the scenes from this week's shoot — slide for the video 👇", "mediaIds": ["med_img_1", "med_img_2", "med_video_1", "med_img_3"], "scheduledFor": (datetime.now(timezone.utc) + timedelta(seconds=30)).isoformat(), "platforms": [{ "accountId": "soc_threads_…", "platformOptions": {"platform": "THREADS", "kind": "CAROUSEL"}, }], }, ) res.raise_for_status() ``` ```bash curl curl -X POST https://api.postbreeze.ai/api/v1/posts \ -H "Authorization: Bearer $POSTBREEZE_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "content": "Behind the scenes from this week'\''s shoot — slide for the video 👇", "mediaIds": ["med_img_1","med_img_2","med_video_1","med_img_3"], "scheduledFor": "2026-06-02T12:00:30Z", "platforms": [{ "accountId": "soc_threads_…", "platformOptions": { "platform": "THREADS", "kind": "CAROUSEL" } }] }' ``` ## Multi-post threads (`threadParts`) Threads on Threads. Pass `threadParts` on `platformOptions` and Postbreeze publishes the root caption first, then chains each entry as a text-only reply via `reply_to_id`. The first thread part becomes the second post in the chain; the next part replies to that, and so on. ```ts await postbreeze.posts.create({ content: "Quick thread on what we shipped this week 🧵", platforms: [{ accountId: "soc_threads_…", platformOptions: { platform: "THREADS", threadParts: [ "1/ The new compose flow", "2/ Per-platform thread editing", "3/ Connected previews", ], }, }], }); ``` ```js Node.js const post = await postbreeze.posts.create({ content: "Quick thread on what we shipped this week 🧵", scheduledFor: new Date(Date.now() + 30_000).toISOString(), platforms: [{ accountId: "soc_threads_…", platformOptions: { platform: "THREADS", threadParts: [ "1/ The new compose flow — faster, calmer, fewer tabs", "2/ Per-platform thread editing — tune Threads without breaking IG", "3/ Connected previews — see exactly what ships before it ships", ], }, }], }); ``` ```python Python res = requests.post( f"{API}/posts", headers={"Authorization": f"Bearer {os.environ['POSTBREEZE_API_KEY']}"}, json={ "content": "Quick thread on what we shipped this week 🧵", "scheduledFor": (datetime.now(timezone.utc) + timedelta(seconds=30)).isoformat(), "platforms": [{ "accountId": "soc_threads_…", "platformOptions": { "platform": "THREADS", "threadParts": [ "1/ The new compose flow", "2/ Per-platform thread editing", "3/ Connected previews", ], }, }], }, ) res.raise_for_status() ``` ```bash curl curl -X POST https://api.postbreeze.ai/api/v1/posts \ -H "Authorization: Bearer $POSTBREEZE_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "content": "Quick thread on what we shipped this week 🧵", "scheduledFor": "2026-06-02T12:00:30Z", "platforms": [{ "accountId": "soc_threads_…", "platformOptions": { "platform": "THREADS", "threadParts": [ "1/ The new compose flow", "2/ Per-platform thread editing", "3/ Connected previews" ] } }] }' ``` ### Constraints - **Each part ≤ 500 characters.** The root caption and every follow-up share the same Threads cap — there's no "See more" fold to lean on. - **Up to 25 follow-ups.** `threadParts.length` is capped at 25; for longer chains, split into multiple posts. - **Follow-ups are text-only.** Media (images, videos, carousels) stays on the **root** post. Thread parts cannot carry attachments. - **`firstComment` replies to the root, not the last part.** If you set both `threadParts` and `firstComment`, the first comment is posted as a reply to the root post (alongside `threadParts[0]`), not chained at the end of the thread. - **Thread follow-ups count toward the reply quota.** Threads enforces 1,000 replies / 24h per account; a 25-part thread burns 25 of those. See [Platform settings → Threads](/concepts/platform-settings#threads) for the full `platformOptions` schema. ## Media requirements ### Images | Property | Requirement | |---|---| | Images per post | 1 (single) or up to 20 (carousel) | | Formats | JPEG, PNG, WebP | | Max file size | 8 MB | | Aspect ratio | Any (Threads crops to 1:1 in Feed) | | Min resolution | 320 × 320 | | Alt text | ✅ Per image (use `altText` on `mediaItems` or `mediaAltText`) | ### Videos | Property | Requirement | |---|---| | Videos per single-video post | 1 | | Videos per carousel | up to 20 (can mix with images) | | Formats | MP4, MOV | | Max file size | 1 GB | | Max duration | 5 minutes | | Aspect ratio | 9:16 to 16:9 | | Codecs | H.264 + AAC | See [Media uploads](/concepts/media-uploads) for upload strategies (URL ingest vs presigned upload). ## Platform-specific fields | Field | Type | Required | Description | |---|---|---|---| | `platform` | `"THREADS"` | Yes | Discriminator. | | `kind` | `"TEXT"` \| `"IMAGE"` \| `"VIDEO"` \| `"CAROUSEL"` | No (default `TEXT`) | Auto-derived in compose from attached media. Pass explicitly when calling the API directly. | | `threadParts` | `string[]` (each ≤ 500, max 25) | No | Text-only follow-ups chained as replies to the root post. Media stays on root. | Threads has no other publish-time settings. ## First comment Pass `firstComment` on the platform entry. Postbreeze posts it as a reply to the **root** post using the same `reply_to_id` flow Threads uses for thread replies. ```js platforms: [{ accountId: "soc_threads_…", platformOptions: { platform: "THREADS", kind: "TEXT" }, firstComment: "Link in bio for the full story", }] ``` Limit: 500 characters (same as the main post). When combined with `threadParts`, the first comment still attaches to the root — not to the final thread part. ## Analytics | Metric | Available | |---|---| | Views (time-series) | ✅ | | Profile views (time-series) | ✅ | | Likes (cumulative total) | ✅ | | Replies (cumulative total) | ✅ | | Reposts (cumulative total) | ✅ | | Quotes (cumulative total) | ✅ | | Followers (account, cumulative) | ✅ | Refresh cadence: every **14 days**. Threads splits time-series from cumulative metrics — only `views` and `profile_views` come back as daily series. Likes, replies, reposts, quotes, and followers are surfaced as the current total on the analytics page's headline cards (not the daily chart). ## Common errors | Error | Meaning | Fix | |---|---|---| | `THREADS_CAPTION_TOO_LONG` | Caption or a `threadParts` entry > 500 chars | Trim the offending part. | | `THREADS_CAROUSEL_SIZE` | Carousel < 2 or > 20 items | Stay between 2–20. | | `THREADS_CONTAINER_TIMEOUT` | Container polled past the budget | Postbreeze retries on the next tick. | | `THREADS_VIDEO_TOO_LONG` | Video > 5 minutes | Trim. | | `THREADS_FIRST_COMMENT_FAILED` | Reply post call failed | Use the retry button in the post-detail dialog. | | `THREADS_TOKEN_EXPIRED` | Long-lived token expired or revoked | Reconnect. | | `THREADS_RATE_LIMITED` | 250 posts / 24h or 1,000 replies / 24h (thread follow-ups count as replies) | Wait — daily limits reset on a rolling window. | ## What you can't do - ❌ Quote-post via API (Threads doesn't expose this surface) - ❌ Reply target in compose (Postbreeze v1 only reply-to-incoming; cold-start reply is deferred) - ❌ Attach media to `threadParts` follow-ups (text-only by design — media stays on the root) - ❌ Schedule via Threads' native scheduler - ❌ Edit a post after publish - ❌ Polls - ❌ Stories - ❌ Webhook subscriptions (polling-only for now in Postbreeze) ## Full control: nested shape The nested shape (`caption` + `targets` + `socialAccountId` + `scheduledAt`) is the alternative request envelope. Functionally equivalent to the flat shape — pick whichever maps cleaner to your call site. ```js Node.js const post = await postbreeze.posts.create({ caption: "Quick thread on what we shipped this week 🧵", scheduledAt: new Date(Date.now() + 30_000).toISOString(), targets: [{ socialAccountId: "soc_threads_…", platformOptions: { platform: "THREADS", threadParts: [ "1/ The new compose flow", "2/ Per-platform thread editing", "3/ Connected previews", ], }, }], }); ``` --- # API reference > Full machine-readable spec is published as openapi.json — Mintlify renders the interactive playground below. The Postbreeze REST API exposes everything you need to drive the scheduling pipeline from outside the dashboard. - **Base URL**: `https://api.postbreeze.ai/api/v1` - **Auth**: `Authorization: Bearer pb_live_…` - **Content type**: `application/json` request and response bodies - **Errors**: standard HTTP status codes; bodies are `{ "statusCode", "code", "message", "requestId" }` (see [Errors](/concepts/errors)) - **Rate limits**: 60 req/min burst, 1000 req/15min sustained per key (see [Rate limits](/concepts/rate-limits)) ## Workspace resolution API keys are issued in one of two modes: - **Full access** (default) — the key can act on every workspace its owner is a member of. - **Scoped** — the key is restricted to an explicit allow-list of workspaces. How endpoints find the workspace for each request: | Endpoint shape | Source of `workspaceId` | |---|---| | `GET /posts/{id}`, `PATCH /posts/{id}`, `DELETE /posts/{id}` | Derived from `Post.workspaceId` | | `POST /posts/{id}/schedule|reschedule|retry|cancel` | Derived from `Post.workspaceId` | | `POST /posts` | Derived from the first account in `platforms[]` / `targets[]` | | `GET /social-accounts/{id}`, `DELETE /social-accounts/{id}` | Derived from `SocialAccount.workspaceId` | | `POST /comments/{id}/reply`, `POST /comments/{id}/read` | Derived from `Comment.workspaceId` | | `GET /media/{id}`, `PATCH /media/{id}`, `DELETE /media/{id}` | Derived from the media's owner workspace | | `GET /posts`, `GET /social-accounts`, `GET /comments` | Optional `?workspaceId=` — omit to fan out across every workspace the key can act on | | `GET /media` | **Required** `?workspaceId=` (media is account-global) | | `POST /media/presign`, `POST /media/from-url`, `POST /comments/refresh` | **Required** `?workspaceId=` | Derivation is enforced at the resolver layer; the server never widens a scoped key's reach. A request to a resource that belongs to a workspace outside the key's allow-list returns `403`. ## Endpoints in v1 | Resource | Methods | |---|---| | **Workspaces** | `GET /workspaces`, `GET /workspaces/{id}`, `POST /workspaces` (create) | | **Posts** | `GET /posts`, `POST /posts`, `GET /posts/{id}`, `PATCH /posts/{id}`, `DELETE /posts/{id}`, `POST /posts/{id}/schedule`, `POST /posts/{id}/cancel`, `POST /posts/{id}/retry`, `POST /posts/{id}/targets/{tid}/retry-first-comment` | | **Social accounts** | `GET /social-accounts`, `GET /social-accounts/{id}`, `DELETE /social-accounts/{id}` | | **Media** | `GET /media`, `POST /media/presign`, `POST /media/from-url`, `POST /media/{id}/complete`, `GET /media/{id}`, `PATCH /media/{id}`, `DELETE /media/{id}` | | **Comments** | `GET /comments`, `POST /comments/refresh`, `POST /comments/{id}/reply`, `POST /comments/{id}/read` | ## Two body shapes for `POST /posts` The post-create + post-update endpoints accept a discriminated body — **send exactly one** of `platforms` (flat) or `targets` (nested). Sending both returns `400 MUST_SPECIFY_EXACTLY_ONE`. - **Flat** (`{ content, platforms: [{ accountId }] }`) — Zernio-style, recommended for most calls. Same caption for every platform. - **Nested** (`{ caption, targets: [{ socialAccountId, platformOptions }] }`) — full control over per-platform options, per-target media overrides, and platform-specific `kind` discriminators. See [Platforms → Overview](/platforms/overview) for the full schema of each shape. ## URL ingest for media `POST /media/from-url` accepts a public HTTPS URL and fetches the bytes server-side. The fetch is **SSRF-guarded** — private IPs, link-local addresses, metadata-IMDS hostnames, and DNS-rebinding tricks are rejected. Redirects are not followed (`redirect: manual`). By the time the call returns, the bytes live in R2; the source URL is never re-fetched at publish time. ## Dashboard-internal routes A second set of routes under `/api/v1/me/media/*` powers the dashboard UI — they take an `x-workspace-id` header instead of inferring from a key. SDK and MCP callers should ignore these; they're not part of the public surface. ## OpenAPI spec The full machine-readable spec lives at `/openapi.json` on this site and is regenerated on every Postbreeze deploy. The TypeScript SDK `@postbreeze/node` is generated from this spec. --- # API reference > Full REST API operation list with request/response schemas. The canonical machine-readable spec lives at: https://docs.postbreeze.ai/openapi.json Per-tag specs (Workspaces, Channels, Connect, Posts, Media, Comments) are also published as siblings — see the docs site or fetch `https://docs.postbreeze.ai/openapi-.json`.