{"openapi":"3.1.0","info":{"title":"PostLake API","version":"1.0.0","description":"Unified social media API. One API key, 10 platforms, normalised responses. Built for agents and developers.","contact":{"url":"https://postlake.dev"}},"servers":[{"url":"https://api.postlake.dev"}],"security":[{"bearerAuth":[]}],"tags":[{"name":"Account","description":"API key identity, permissions, usage and account info"},{"name":"Social Accounts","description":"Connect, list, disconnect social accounts"},{"name":"Profiles","description":"Named groupings of social accounts"},{"name":"Posts","description":"Create, list, edit, cancel, schedule posts"},{"name":"Reading","description":"Notifications, comments and followers, normalised across networks"},{"name":"Engaging","description":"Like, repost, follow, block and mute"},{"name":"Messages","description":"Direct message threads"},{"name":"Analytics","description":"Per-post and cross-platform metrics"},{"name":"Media","description":"Upload media for posts"},{"name":"Live Video","description":"Create and manage Facebook Page live broadcasts"},{"name":"Webhooks","description":"Register and manage webhook endpoints"},{"name":"Credentials","description":"BYO platform app credentials (BYOK)"},{"name":"Connect","description":"Hosted connect/manage page links"},{"name":"Platforms","description":"Machine-readable platform capabilities (public)"},{"name":"MCP","description":"Model Context Protocol JSON-RPC endpoint"}],"paths":{"/v1/me/limits":{"get":{"tags":["Account"],"summary":"What this key may do, and what it can spend","description":"The authenticated key's machine identity, enforced publishing policy, connected channels, credit balance, and rate limits. rateLimits.apiPerMinute and postsPerMinute are the workspace ceiling. rateLimits.profile is the per-Channels-profile cap, so one end user cannot starve the others. Networks still apply their own caps (TikTok is about 6 requests a minute per account). MCP agents receive the same view through whoami. Read this before planning a batch rather than discovering a limit by being refused.","responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"account":{"type":"string"},"email":{"type":"string"},"name":{"type":"string"},"credits":{"type":"object","properties":{"total":{"type":"integer"},"monthly":{"type":"integer"},"pack":{"type":"integer"},"monthlyAllowance":{"type":"integer"},"plan":{"type":"string"},"blockedPlatforms":{"type":"array","items":{"type":"string"}}}},"billing":{"type":"object","description":"Billing state separate from the entitlement tier. `payg` means the Free plan with a positive balance of purchased, non-renewing credits, not a subscription.","properties":{"mode":{"type":"string","enum":["free","payg","subscription"]},"isSubscriber":{"type":"boolean"},"hasPurchasedCredits":{"type":"boolean"}},"required":["mode","isSubscriber","hasPurchasedCredits"]},"connected":{"type":"array","items":{"type":"object","properties":{"account":{"type":"string"},"platform":{"type":"string"},"handle":{"type":"string"},"status":{"type":"string"}}}},"rateLimits":{"type":"object","properties":{"apiPerMinute":{"type":"integer","description":"Authenticated /v1 and /mcp requests per 60-second window for this workspace. The abuse ceiling shared by every profile."},"postsPerMinute":{"type":"integer","description":"POST /v1/posts, /v1/posts/{id}/publish, and MCP create_post / publish_draft per 60-second window for this workspace."},"profile":{"type":"object","description":"Per Channels profile (one end user). Applied when the request names profile or the post's accounts resolve to one.","properties":{"apiPerMinute":{"type":"integer","description":"Authenticated requests per 60-second window for this profile."},"postsPerMinute":{"type":"integer","description":"Publishes per minute for this profile. A noisy customer cannot spend the workspace envelope alone."}},"required":["apiPerMinute","postsPerMinute"]}},"required":["apiPerMinute","postsPerMinute","profile"]}}}}}}}}},"/v1/me/agent":{"patch":{"tags":["Account"],"summary":"Set this agent's own display name","description":"Changes only the authenticated API key's display nickname. It does not rename the key, change its permissions, or affect another agent. The account owner can lock the display name in Agent Control. Pass null to return to the key's original name.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["nickname"],"additionalProperties":false,"properties":{"nickname":{"type":["string","null"],"maxLength":60}}}}}},"responses":{"200":{"description":"Updated identity","content":{"application/json":{"schema":{"type":"object","required":["id","name","clientName","nickname","nicknameLocked"],"properties":{"id":{"type":"string"},"name":{"type":"string"},"clientName":{"type":"string"},"nickname":{"type":["string","null"]},"nicknameLocked":{"type":"boolean"}}}}}},"403":{"description":"The account owner locked this display name"}}}},"/v1/notifications":{"get":{"tags":["Reading"],"summary":"What happened across every network","description":"Likes, replies, mentions, follows, reposts and quotes, newest first, in one normalised shape. `problems` names any network that could not be read, so an empty `items` never silently means 'we could not look'.","parameters":[{"name":"account","in":"query","required":false,"schema":{"type":"string"},"description":"One connected account id (acc_…). Omit for every connected network."},{"name":"cursor","in":"query","required":false,"schema":{"type":"string"},"description":"Opaque cursor from a previous page"},{"name":"limit","in":"query","required":false,"schema":{"type":"integer"},"description":"1-100"}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"items":{"type":"array","items":{"$ref":"#/components/schemas/Notification"}},"cursor":{"type":"string","nullable":true,"description":"Opaque. Pass it back verbatim for the next page; null means the end. Never parse it: on cross-network reads it encodes a position per network."},"problems":{"type":"array","items":{"$ref":"#/components/schemas/ReadProblem"}}}}}}}}}},"/v1/notifications/seen":{"post":{"tags":["Reading"],"summary":"Mark notifications as seen","description":"On the networks that track it. Networks with no concept of 'seen' are skipped.","parameters":[{"name":"account","in":"query","required":false,"schema":{"type":"string"},"description":"One connected account id (acc_…). Omit for every connected network."}],"responses":{"200":{"description":"Success"}}}},"/v1/posts/{id}/comments":{"get":{"tags":["Reading"],"summary":"Replies on a post you published","description":"Across every network the post went to.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}},{"name":"nested","in":"query","required":false,"schema":{"type":"boolean"},"description":"Read the whole thread including replies to replies. Networks that cannot go deeper answer with the top level rather than refusing."},{"name":"cursor","in":"query","required":false,"schema":{"type":"string"},"description":"Opaque cursor from a previous page"},{"name":"limit","in":"query","required":false,"schema":{"type":"integer"},"description":"1-100"}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"items":{"type":"array","items":{"$ref":"#/components/schemas/Comment"}},"cursor":{"type":"string","nullable":true,"description":"Opaque. Pass it back verbatim for the next page; null means the end. Never parse it: on cross-network reads it encodes a position per network."},"problems":{"type":"array","items":{"$ref":"#/components/schemas/ReadProblem"}}}}}}}}}},"/v1/discover/posts":{"get":{"tags":["Discovery"],"summary":"Search public posts","description":"Search the network itself rather than your own posts, so an agent can see what is already being said before it writes. Runs on every connected network that supports searching; the ones that cannot are named in `problems`. Two limits decide whether a result means anything on Instagram, and neither is a rate limit you can retry past. Instagram has NO keyword search: a multi-word query cannot match a hashtag and comes back as a `problems` entry rather than an empty list. And Meta caps this at 30 UNIQUE hashtags per account per 7 days, so a scouting run has a weekly budget. `sort=recent` reads Meta's recent_media edge, which only covers the last 24 hours, so an empty recent result means nothing was posted today rather than that the tag is unused. Instagram results also carry `authorHidden: true` and can never name their author: Meta strips personally identifiable information from them, so post search cannot be used to find someone to contact.","parameters":[{"name":"q","in":"query","required":true,"schema":{"type":"string"},"description":"What to search for"},{"name":"mode","in":"query","required":false,"schema":{"type":"string","enum":["keyword","tag"]},"description":"Search the words (default) or a topic tag"},{"name":"sort","in":"query","required":false,"schema":{"type":"string","enum":["top","recent"]},"description":"Best match (default) or newest first"},{"name":"mediaType","in":"query","required":false,"schema":{"type":"string","enum":["text","image","video"]}},{"name":"author","in":"query","required":false,"schema":{"type":"string"},"description":"Only this handle's posts"},{"name":"since","in":"query","required":false,"schema":{"type":"string","format":"date-time"}},{"name":"until","in":"query","required":false,"schema":{"type":"string","format":"date-time"}},{"name":"account","in":"query","required":false,"schema":{"type":"string"},"description":"Search one connection only. Omit to search them all."},{"name":"cursor","in":"query","required":false,"schema":{"type":"string"}},{"name":"limit","in":"query","required":false,"schema":{"type":"integer"},"description":"1-100"}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"items":{"type":"array","items":{"$ref":"#/components/schemas/DiscoveredPost"}},"cursor":{"type":"string","nullable":true,"description":"Opaque. Pass it back verbatim; null means the end."},"problems":{"type":"array","items":{"$ref":"#/components/schemas/ReadProblem"}}}}}}}}}},"/v1/discover/profiles/{handle}":{"get":{"tags":["Discovery"],"summary":"Look someone up","description":"One network only: the same handle on two networks is usually two different people, so merging them would invent someone who does not exist. Counts are null where the network does not publish them, which is not the same as zero.","parameters":[{"name":"handle","in":"path","required":true,"schema":{"type":"string"},"description":"With or without the @"},{"name":"account","in":"query","required":true,"schema":{"type":"string"},"description":"Which connection to look on"}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PublicProfile"}}}}}}},"/v1/discover/profiles/{handle}/posts":{"get":{"tags":["Discovery"],"summary":"Someone else's public posts","parameters":[{"name":"handle","in":"path","required":true,"schema":{"type":"string"}},{"name":"account","in":"query","required":true,"schema":{"type":"string"}},{"name":"cursor","in":"query","required":false,"schema":{"type":"string"}},{"name":"limit","in":"query","required":false,"schema":{"type":"integer"},"description":"1-100"}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"items":{"type":"array","items":{"$ref":"#/components/schemas/DiscoveredPost"}},"cursor":{"type":"string","nullable":true},"problems":{"type":"array","items":{"$ref":"#/components/schemas/ReadProblem"}}}}}}}}}},"/v1/discover/creators":{"get":{"tags":["Discovery"],"summary":"Find creators to work with","description":"Search Meta creator discovery for people a brand could partner with. A Facebook Page searches Facebook Creator Discovery directly. An Instagram account connected through Facebook searches Instagram Creator Marketplace. Entries marked `sample: true` are test data, not real outreach targets.","parameters":[{"name":"account","in":"query","required":true,"schema":{"type":"string"},"description":"A Facebook Page for Facebook Creator Discovery, or an Instagram account connected through Facebook for Instagram Creator Marketplace."},{"name":"q","in":"query","required":false,"schema":{"type":"string"},"description":"Free-text search."},{"name":"countries","in":"query","required":false,"schema":{"type":"string"},"description":"Comma-separated country codes."},{"name":"limit","in":"query","required":false,"schema":{"type":"integer","minimum":1,"maximum":50}},{"name":"cursor","in":"query","required":false,"schema":{"type":"string"}}],"responses":{"200":{"description":"Creators","content":{"application/json":{"schema":{"type":"object","properties":{"items":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"handle":{"type":"string"},"displayName":{"type":"string","nullable":true},"avatarUrl":{"type":"string","nullable":true},"bio":{"type":"string","nullable":true},"categories":{"type":"array","items":{"type":"string"}},"country":{"type":"string","nullable":true},"portfolioUrl":{"type":"string","nullable":true,"format":"uri","description":"Individual creator profile URL. Null for Meta test results."},"platforms":{"type":"array","items":{"type":"string"}},"verified":{"type":"boolean","nullable":true},"hasBrandPartnershipExperience":{"type":"boolean","nullable":true},"followsBrand":{"type":"boolean","nullable":true},"sample":{"type":"boolean","description":"True until PostLake has Meta Advanced Access configured. Never use a sample result for outreach."}},"required":["id","handle","sample"]}},"cursor":{"type":"string","nullable":true},"platform":{"$ref":"#/components/schemas/Platform"}},"required":["items","cursor","platform"]}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"409":{"description":"The network refused, and retrying will not help. A brand that has not accepted the relevant Meta creator-product terms answers `creator_marketplace_not_onboarded`. `retryable` is false and `fix` carries the exact Facebook or Instagram click path. Check `discovers.creators` and `blocked` on GET /v1/social-accounts/{id} first and you can avoid this call entirely.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/v1/discover/places":{"get":{"tags":["Discovery"],"summary":"Find a place to tag on a post","description":"Pass a name, or a latitude and longitude together. The id returned goes in the post's `locationId` option, and only works on the network it came from.","parameters":[{"name":"account","in":"query","required":true,"schema":{"type":"string"}},{"name":"q","in":"query","required":false,"schema":{"type":"string"}},{"name":"latitude","in":"query","required":false,"schema":{"type":"number"},"description":"Use with longitude"},{"name":"longitude","in":"query","required":false,"schema":{"type":"number"},"description":"Use with latitude"}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"items":{"type":"array","items":{"$ref":"#/components/schemas/Place"}},"platform":{"type":"string"}}}}}}}}},"/v1/comments/{id}/hide":{"post":{"tags":["Reading"],"summary":"Hide or unhide a reply","description":"The other half of moderating a comment section. Without it the only answer to an abusive reply is to reply to it. A failure means the reply is still visible, so treat an error as \"still there\" rather than assuming it worked.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"},"description":"The comment id"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["account"],"properties":{"account":{"type":"string","description":"The connection the post is on"},"hidden":{"type":"boolean","default":true,"description":"false puts it back"}}}}}},"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"account":{"type":"string"},"platform":{"type":"string"},"hidden":{"type":"boolean"},"ok":{"type":"boolean"}}}}}}}}},"/v1/comments/{id}":{"delete":{"tags":["Reading"],"summary":"Delete a comment","description":"Take a comment down for good. Facebook cannot hide a Page's own comment, so this is how you retract one you posted. A failure means it is still there.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"},"description":"The comment id"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["account"],"properties":{"account":{"type":"string","description":"The connection the post is on"}}}}}},"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"account":{"type":"string"},"platform":{"type":"string"},"ok":{"type":"boolean"}}}}}}}}},"/v1/social-accounts/{id}/following":{"get":{"tags":["Reading"],"summary":"Who a connected account follows","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}},{"name":"cursor","in":"query","required":false,"schema":{"type":"string"}},{"name":"limit","in":"query","required":false,"schema":{"type":"integer"}}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"items":{"type":"array","items":{"$ref":"#/components/schemas/SocialActor"}},"cursor":{"type":"string","nullable":true},"problems":{"type":"array","items":{"$ref":"#/components/schemas/ReadProblem"}}}}}}}}}},"/v1/social-accounts/{id}/profile":{"patch":{"tags":["Account"],"summary":"Edit a connected account's own profile","description":"Change the display name, bio, avatar or banner. Only the fields you send are changed. Images are given as public URLs and uploaded for you. A network that does not allow this refuses.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"displayName":{"type":"string"},"bio":{"type":"string"},"avatarUrl":{"type":"string"},"bannerUrl":{"type":"string"}}}}}},"responses":{"200":{"description":"Updated","content":{"application/json":{"schema":{"type":"object","properties":{"account":{"type":"string"},"platform":{"type":"string"},"ok":{"type":"boolean"}}}}}}}}},"/v1/social-accounts/{id}/followers":{"get":{"tags":["Reading"],"summary":"Who follows a connected account","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}},{"name":"cursor","in":"query","required":false,"schema":{"type":"string"},"description":"Opaque cursor from a previous page"},{"name":"limit","in":"query","required":false,"schema":{"type":"integer"},"description":"1-100"}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"items":{"type":"array","items":{"$ref":"#/components/schemas/SocialActor"}},"cursor":{"type":"string","nullable":true,"description":"Opaque. Pass it back verbatim for the next page; null means the end. Never parse it: on cross-network reads it encodes a position per network."},"problems":{"type":"array","items":{"$ref":"#/components/schemas/ReadProblem"}}}}}}}}}},"/v1/comments/{id}/replies":{"post":{"tags":["Engaging"],"summary":"Reply to a comment","description":"Answer a comment someone left, by that comment's id (from GET /v1/posts/{id}/comments). Distinct from publishing: this acts on someone else's content. Free everywhere except X, where a reply is billed as a post.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"},"description":"The comment id"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["account","text"],"properties":{"account":{"type":"string","description":"The connected account id (acc_…) replying"},"text":{"type":"string","description":"The reply"}}}}}},"responses":{"200":{"description":"Posted","content":{"application/json":{"schema":{"type":"object","properties":{"account":{"type":"string"},"platform":{"type":"string"},"id":{"type":"string"},"ok":{"type":"boolean"}}}}}}}}},"/v1/engagements":{"post":{"tags":["Engaging"],"summary":"Like, repost, follow, block or mute","description":"One endpoint for every engagement. Free: engaging never spends credits. A network that does not support the action refuses and names what it does support, so nothing silently does nothing.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["account","action","target"],"properties":{"account":{"type":"string","description":"The connected account id (acc_…) acting"},"action":{"type":"string","enum":["like","unlike","repost","unrepost","follow","unfollow","block","unblock","mute","unmute"]},"target":{"type":"string","description":"A post uri/url for like and repost; a handle for follow, block and mute"}}}}}},"responses":{"200":{"description":"Done","content":{"application/json":{"schema":{"type":"object","properties":{"account":{"type":"string"},"platform":{"type":"string"},"action":{"type":"string"},"ok":{"type":"boolean"},"id":{"type":"string"}}}}}}}}},"/v1/conversations":{"get":{"tags":["Messages"],"summary":"Direct message threads","parameters":[{"name":"account","in":"query","required":false,"schema":{"type":"string"},"description":"One connected account id (acc_…). Omit for every connected network."},{"name":"cursor","in":"query","required":false,"schema":{"type":"string"},"description":"Opaque cursor from a previous page"},{"name":"limit","in":"query","required":false,"schema":{"type":"integer"},"description":"1-100"}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"items":{"type":"array","items":{"$ref":"#/components/schemas/Conversation"}},"cursor":{"type":"string","nullable":true,"description":"Opaque. Pass it back verbatim for the next page; null means the end. Never parse it: on cross-network reads it encodes a position per network."},"problems":{"type":"array","items":{"$ref":"#/components/schemas/ReadProblem"}}}}}}}}},"post":{"tags":["Messages"],"summary":"Open a conversation with someone","description":"Finds or starts the thread with a handle, so a first message does not need an id that does not exist yet. Sends nothing.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["account","handle"],"properties":{"account":{"type":"string"},"handle":{"type":"string","description":"e.g. alice.bsky.social"}}}}}},"responses":{"200":{"description":"The conversation","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Conversation"}}}}}}},"/v1/conversations/{id}/messages":{"get":{"tags":["Messages"],"summary":"Messages in one thread","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}},{"name":"account","in":"query","required":true,"schema":{"type":"string"},"description":"Required: a conversation id only means something on one connection"},{"name":"cursor","in":"query","required":false,"schema":{"type":"string"},"description":"Opaque cursor from a previous page"},{"name":"limit","in":"query","required":false,"schema":{"type":"integer"},"description":"1-100"}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"items":{"type":"array","items":{"$ref":"#/components/schemas/Message"}},"cursor":{"type":"string","nullable":true}}}}}}}},"post":{"tags":["Messages"],"summary":"Send a direct message","description":"Free on supported networks except X, where sending a direct message costs 6 credits.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["account","text"],"properties":{"account":{"type":"string"},"text":{"type":"string"},"humanAgent":{"type":"boolean","description":"Assert that a PERSON wrote this reply. Meta allows a reply within 24 hours of someone's last message; the Human Agent tag extends that to 7 days and is granted only for replies a human composed. Never set it for an automated reply: the account carries the penalty, not the caller."}}}}}},"responses":{"200":{"description":"Sent","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Message"}}}}}}},"/v1/conversations/{id}/read":{"post":{"tags":["Messages"],"summary":"Mark a conversation read","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["account"],"properties":{"account":{"type":"string"}}}}}},"responses":{"200":{"description":"Success"}}}},"/v1/me":{"get":{"tags":["Account"],"summary":"Get current account","description":"Validate API key and echo account info, including the optional default timezone.","responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"id":{"type":"string"},"email":{"type":"string"},"platforms":{"type":"array","items":{"$ref":"#/components/schemas/Platform"}},"timezone":{"type":"string","description":"IANA default for naive scheduledAt values, when set."}},"required":["id","email","platforms"]}}}},"401":{"$ref":"#/components/responses/Unauthorized"}}},"patch":{"tags":["Account"],"summary":"Update current account","description":"Set or clear the account default timezone used for naive scheduledAt values.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"timezone":{"type":"string","description":"IANA name such as Europe/London. Empty string or null clears the default.","nullable":true}}}}}},"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"id":{"type":"string"},"email":{"type":"string"},"platforms":{"type":"array","items":{"$ref":"#/components/schemas/Platform"}},"timezone":{"type":"string"}},"required":["id","email","platforms"]}}}},"400":{"description":"Invalid timezone"},"401":{"$ref":"#/components/responses/Unauthorized"}}}},"/v1/account/export":{"get":{"tags":["Account"],"summary":"Export account data","description":"GDPR data export. All data for the account (no secrets).","responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"account":{"type":"object","properties":{"id":{"type":"string"},"email":{"type":"string"}},"required":["id","email"]},"profiles":{"type":"array","items":{"$ref":"#/components/schemas/Profile"}},"socialAccounts":{"type":"array","items":{"$ref":"#/components/schemas/SocialAccount"}},"posts":{"type":"array","items":{"$ref":"#/components/schemas/Post"}},"auditLog":{"type":"array","items":{"$ref":"#/components/schemas/AuditEvent"}},"exportedAt":{"type":"string","format":"date-time"}},"required":["account","profiles","socialAccounts","posts","auditLog","exportedAt"]}}}},"401":{"$ref":"#/components/responses/Unauthorized"}}}},"/v1/audit":{"get":{"tags":["Account"],"summary":"Get audit log","description":"Security audit trail (logins, key creation, disconnects). Returns up to 100 events.","responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"events":{"type":"array","items":{"$ref":"#/components/schemas/AuditEvent"}}},"required":["events"]}}}},"401":{"$ref":"#/components/responses/Unauthorized"}}}},"/v1/social-accounts/connect":{"post":{"tags":["Social Accounts"],"summary":"Connect social account","description":"Connect a social account to PostLake with supported credentials. X requires the hosted POST /v1/connect-link OAuth flow and always uses PostLake's shared app. External X tokens are not accepted here.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"platform":{"$ref":"#/components/schemas/Platform"},"profile":{"type":"string","description":"Optional profile username to attach to"},"handle":{"type":"string","description":"Platform-specific handle"},"appPassword":{"type":"string","description":"Platform-specific password (e.g. for Bluesky)"}},"required":["platform"]}}}},"responses":{"201":{"description":"Created","content":{"application/json":{"schema":{"type":"object","properties":{"id":{"type":"string"},"platform":{"$ref":"#/components/schemas/Platform"},"handle":{"type":"string"},"profileId":{"oneOf":[{"type":"string"},{"type":"null"}]},"status":{"type":"string"}},"required":["id","platform","handle","profileId","status"]}}}},"400":{"description":"Invalid input"},"401":{"$ref":"#/components/responses/Unauthorized"},"402":{"description":"Entitlement exceeded"},"502":{"description":"Unsupported platform"}}}},"/v1/social-accounts":{"get":{"tags":["Social Accounts"],"summary":"List social accounts","description":"List connected social accounts. Cursor-paginated.","parameters":[{"name":"profile","in":"query","description":"Filter by profile username","schema":{"type":"string"}},{"name":"limit","in":"query","description":"Page size, 1-100 (default 50).","schema":{"type":"integer","minimum":1,"maximum":100,"default":50}},{"name":"cursor","in":"query","description":"Opaque cursor from the previous response's `nextCursor`.","schema":{"type":"string"}}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"accounts":{"type":"array","items":{"$ref":"#/components/schemas/SocialAccount"}},"nextCursor":{"type":["string","null"],"description":"Pass this back as `cursor` to fetch the next page. `null` when there are no more results."}},"required":["accounts","nextCursor"]}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"description":"Profile not found"}}}},"/v1/social-accounts/{id}/disconnect-preview":{"get":{"tags":["Social Accounts"],"summary":"Preview channel disconnect","description":"Read-only, on-demand preview of at most 100 affected scheduled posts. Returns a full-state snapshot. Queued posts, schedules due within 30 seconds, or more than 100 schedules block disconnect. Larger selections must first use bounded bulk destination edits. No polling or historical-post scan is required.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Preview","content":{"application/json":{"schema":{"type":"object","properties":{"affectedSchedules":{"type":"integer","description":"Count, capped at 100. Check hasMore."},"hasMore":{"type":"boolean"},"snapshot":{"type":"string"},"blocked":{"type":"boolean"},"reason":{"type":"string"}},"required":["affectedSchedules","hasMore","snapshot","blocked"]}}}},"404":{"description":"Unknown connected account"}}}},"/v1/social-accounts/{id}":{"get":{"tags":["Social Accounts"],"summary":"Retrieve a social account","description":"One connected account by id. Same shape as a row in the list, so anything holding an acc_… can ask about it directly instead of paging the list and filtering.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SocialAccount"}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"description":"Unknown connected account"}}},"delete":{"tags":["Social Accounts"],"summary":"Disconnect a social account","description":"Disconnect at the owner's request. Published posts stay untouched. scheduledPosts=keep (default) preserves schedules and stable identity for reconnection. remove removes this destination from scheduled posts; posts with no destinations left become drafts with their content preserved. remove requires expectedSnapshot from disconnect-preview. Confirmation is fenced against full-post edits, new affected schedules and publishing claims in one bounded transaction. Provider revocation is best effort after the guarded local transaction. Maximum 100 schedules; larger selections require bulk destination edits first. No credits are charged.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}},{"name":"scheduledPosts","in":"query","schema":{"type":"string","enum":["keep","remove"],"default":"keep"}},{"name":"expectedSnapshot","in":"query","schema":{"type":"string"},"description":"Full-state snapshot from disconnect-preview. Required for remove."}],"responses":{"200":{"description":"Disconnected","content":{"application/json":{"schema":{"type":"object","properties":{"id":{"type":"string"},"disconnected":{"type":"boolean"},"platform":{"type":"string"},"handle":{"type":"string"},"profileId":{"type":"string"}},"required":["id","disconnected","platform","handle"]}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"description":"Unknown connected account"}}}},"/v1/social-accounts/{id}/targets":{"get":{"tags":["Social Accounts"],"summary":"List social account targets","description":"List postable destinations within an account (Pinterest boards, Facebook Pages). This is also how you turn a board id from a post record back into a name: a post stores the id it published to, and this is the lookup.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"targets":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"},"url":{"type":"string","description":"A link a person can open. Only present when the platform publishes one we can trust."},"owner":{"type":"string","description":"Who owns the destination, where one login reaches several owners."},"privacy":{"type":"string","description":"The platform's own word for who can see it, e.g. public, secret, protected. Check this before posting: a secret board accepts the post and shows it to nobody."},"description":{"type":"string"},"itemCount":{"type":"integer","description":"How much is already there, e.g. a Pinterest board's pin count."},"parent":{"type":"string","description":"The destination this one sits inside, where a platform has two levels. A Pinterest board section names its board; a top-level destination has none."}},"required":["id","name"]}}},"required":["targets"]}}}},"401":{"$ref":"#/components/responses/Unauthorized"}}}},"/v1/social-accounts/{id}/facebook-pages/search":{"get":{"tags":["Social Accounts"],"summary":"Search Facebook Pages for a mention","description":"Resolve an eligible Facebook Page name to the numeric ID needed by platformOptions.facebook.mentionPages. The connected account must be Facebook. If Meta does not permit Page lookup on that connection, the successful response explains that callers need the Page ID instead.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"},"description":"Connected Facebook account ID."},{"name":"q","in":"query","required":true,"schema":{"type":"string","minLength":2},"description":"At least two characters from the Page name."}],"responses":{"200":{"description":"Matching Pages or a lookup warning","content":{"application/json":{"schema":{"type":"object","properties":{"pages":{"type":"array","items":{"type":"object","required":["id","name"],"properties":{"id":{"type":"string","description":"Numeric Facebook Page ID."},"name":{"type":"string"},"subtitle":{"type":"string","description":"Page username, when Meta returns one."}}}},"warning":{"type":"string","description":"Why no lookup result could be returned, with the Page ID fallback."}},"required":["pages"]}}}},"400":{"description":"The account is not Facebook or cannot be used for Page lookup"},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"description":"Unknown connected account"}}}},"/v1/profiles/{id}":{"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"patch":{"tags":["Profiles"],"summary":"Rename profile","description":"Rename a profile. The id is unchanged, so connected channels and posts are untouched, but the slugified identifier moves with the name. The response reports `previousUsername` and `usernameChanged` so anything addressing the profile by its old name can be updated.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"name":{"type":"string"}},"required":["name"]}}}},"responses":{"200":{"description":"Renamed","content":{"application/json":{"schema":{"type":"object","properties":{"profile":{"$ref":"#/components/schemas/Profile"},"previousUsername":{"type":"string"},"usernameChanged":{"type":"boolean"}}}}}},"400":{"description":"Invalid name, no such profile, or the name is taken"},"401":{"$ref":"#/components/responses/Unauthorized"}}},"delete":{"tags":["Profiles"],"summary":"Delete profile","description":"Delete a profile. This ALSO disconnects every channel in it, and reconnecting each one requires its owner to approve access on that network again. It therefore refuses with 400 while channels are attached, naming them, unless `?force=true` is passed.","parameters":[{"name":"force","in":"query","required":false,"schema":{"type":"boolean"},"description":"Go ahead even though channels will be disconnected."}],"responses":{"200":{"description":"Deleted","content":{"application/json":{"schema":{"type":"object","properties":{"deleted":{"type":"boolean"},"disconnected":{"type":"array","items":{"type":"string"},"description":"The channels that went with it."}}}}}},"400":{"description":"No such profile, or channels are attached and force was not set"},"401":{"$ref":"#/components/responses/Unauthorized"}}}},"/v1/email-preferences":{"get":{"tags":["Account"],"summary":"Read email preferences","description":"How often this account receives the attention summary: the one email PostLake sends on its own schedule, when a channel has stopped working or a scheduled post did not go out. Account and security email (verify, password reset, new sign-in, payment failed) always sends and is not covered here.","responses":{"200":{"description":"Current preferences","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EmailPreferences"}}}},"401":{"$ref":"#/components/responses/Unauthorized"}}},"patch":{"tags":["Account"],"summary":"Set email preferences","description":"Set how often the attention summary may arrive and whether optional product emails may send. Send at least one field. `daily` sends at most once a day and only on days something is actually wrong; `weekly` gathers anything still unresolved into at most one email a week; `off` stops it entirely. `onboarding` controls optional product emails. There is deliberately no MCP tool for this: these emails are how a person learns a post did not go out, and an agent that could switch them off could hide a failure it caused.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"digestFrequency":{"type":"string","enum":["off","weekly","daily"]},"onboarding":{"type":"boolean","description":"Whether to receive optional product emails."}}}}}},"responses":{"200":{"description":"Updated preferences","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EmailPreferences"}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"}}}},"/v1/profiles":{"post":{"tags":["Profiles"],"summary":"Create profile","description":"Create a named profile. Send `name` as a person would write it (spaces and capitals are fine); it is slugified into the identifier agents address the profile by, and the original is kept for display. `username` is still accepted as an alias for `name`.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"name":{"type":"string","description":"e.g. \"Otaku Gems News\", stored as otaku-gems-news"},"username":{"type":"string","description":"Alias for name, kept for compatibility."}}}}}},"responses":{"201":{"description":"Created","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Profile"}}}},"400":{"description":"Invalid input"},"401":{"$ref":"#/components/responses/Unauthorized"}}},"get":{"tags":["Profiles"],"summary":"List profiles","description":"List profiles","responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"profiles":{"type":"array","items":{"$ref":"#/components/schemas/Profile"}}},"required":["profiles"]}}}},"401":{"$ref":"#/components/responses/Unauthorized"}}}},"/v1/platforms":{"get":{"tags":["Platforms"],"summary":"Platform capabilities (public)","description":"Machine-readable capabilities per platform: character limits, media rules (required / video-only / max images / formats / sizes), whether publishing is asynchronous, and every platformOptions field with its valid values. No authentication required.","security":[],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"platforms":{"type":"array","items":{"$ref":"#/components/schemas/PlatformCapabilities"}}},"required":["platforms"]}}}}}}},"/v1/platforms/{platform}":{"get":{"tags":["Platforms"],"summary":"One platform's capabilities (public)","security":[],"parameters":[{"name":"platform","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PlatformCapabilities"}}}},"404":{"description":"Unknown platform"}}}},"/v1/posts/validate":{"post":{"tags":["Posts"],"summary":"Validate a post (dry run)","description":"Runs the exact validation a publish would run, covering account resolution, media resolution and capability-registry rules, without touching any platform. Returns per-target errors (blocking) and warnings (advisory). Free to call before POST /v1/posts.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"text":{"type":"string","description":"Caption for feed posts and Reels. May be empty for an Instagram Story-only post; Instagram Stories do not display captions."},"campaign":{"type":"string","description":"Optional lowercase campaign slug, up to 80 characters."},"assetMetadata":{"type":"object","additionalProperties":{"type":"string"},"description":"Up to 16 short caller-defined fields, such as hook, track or source filename."},"profile":{"type":"string","description":"A profile name. Resolves to every account it owns. The simple way to address accounts; combine with `platforms` to narrow it. Provide `profile`, `accounts`, or both."},"platforms":{"type":"array","items":{"$ref":"#/components/schemas/Platform"},"description":"Optional filter. Keeps only these networks from the resolved set."},"accounts":{"type":"array","items":{"type":"string"},"description":"Connected account ids (acc_…). An alternative (or addition) to `profile`."},"media":{"type":"array","items":{"type":"string"}},"scheduledAt":{"type":"string","format":"date-time"},"timezone":{"type":"string","description":"IANA timezone (e.g. Europe/London). Interprets a naive scheduledAt as wall time in that zone. Stored fire time is always UTC."},"platformOptions":{"type":"object","additionalProperties":true}},"required":["text"]}}}},"responses":{"200":{"description":"Validation result","content":{"application/json":{"schema":{"type":"object","properties":{"ok":{"type":"boolean"},"targets":{"type":"array","items":{"type":"object","properties":{"account":{"type":"string"},"platform":{"$ref":"#/components/schemas/Platform"},"ok":{"type":"boolean"},"errors":{"type":"array","items":{"type":"string"},"description":"Blocking problems, as sentences. See `issues` for the actionable form."},"warnings":{"type":"array","items":{"type":"string"},"description":"Advisory: the post publishes, but something changes (e.g. images dropped)."},"issues":{"type":"array","description":"The same code / fix / param a real publish would return, so a dry run teaches exactly what the live call teaches.","items":{"type":"object","properties":{"code":{"type":"string"},"message":{"type":"string"},"fix":{"type":"string"},"param":{"type":"string"},"docs":{"type":"string","format":"uri"}},"required":["code","message","fix","docs"]}}},"required":["account","platform","ok","errors","warnings","issues"]}}},"required":["ok","targets"]}}}},"400":{"description":"Invalid input"},"401":{"$ref":"#/components/responses/Unauthorized"}}}},"/v1/social-accounts/{id}/webhook":{"post":{"tags":["Channels"],"summary":"Subscribe this channel to webhooks","description":"Some networks need the subscription on the ACCOUNT as well as the app. Facebook is the case: subscribing the app is not enough, the Page has to be subscribed too or nothing is ever delivered, however well the callback is configured. Defaults to feed, mention and messages.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"required":false,"content":{"application/json":{"schema":{"type":"object","properties":{"fields":{"type":"array","items":{"type":"string"},"description":"Which events to subscribe to. Defaults to feed, mention, messages."}}}}}},"responses":{"200":{"description":"Subscribed","content":{"application/json":{"schema":{"type":"object","properties":{"subscribed":{"type":"boolean"},"fields":{"type":"array","items":{"type":"string"}}},"required":["subscribed","fields"]}}}}}},"get":{"tags":["Channels"],"summary":"What this channel is subscribed to","description":"The events this channel currently receives, so \"we are not getting webhooks\" has an answer. A network that does not need a per account subscription returns an empty list.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Subscriptions","content":{"application/json":{"schema":{"type":"object","properties":{"fields":{"type":"array","items":{"type":"string"}}},"required":["fields"]}}}}}}},"/v1/social-accounts/{id}/posts":{"get":{"tags":["Channels"],"summary":"This channel's own posts, from the network","description":"The account's own posts read live from the network, including ones published outside PostLake. Different from GET /v1/posts, which lists what PostLake itself published: an account with existing history has posts we never sent, and this is how to reach them.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}},{"name":"cursor","in":"query","required":false,"schema":{"type":"string"}},{"name":"limit","in":"query","required":false,"schema":{"type":"integer","minimum":1,"maximum":50}}],"responses":{"200":{"description":"Posts","content":{"application/json":{"schema":{"type":"object","properties":{"items":{"type":"array","items":{"$ref":"#/components/schemas/DiscoveredPost"}},"cursor":{"type":"string","nullable":true},"platform":{"$ref":"#/components/schemas/Platform"}},"required":["items","cursor","platform"]}}}}}}},"/v1/social-accounts/{id}/tagged":{"get":{"tags":["Channels"],"summary":"Posts other people tagged you in","description":"Media published by SOMEBODY ELSE that tagged this account. Different from a mention, which names you in text, and different from your own posts. This is what other people said about you, which is usually the thing worth watching.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}},{"name":"cursor","in":"query","required":false,"schema":{"type":"string"}},{"name":"limit","in":"query","required":false,"schema":{"type":"integer","minimum":1,"maximum":50}}],"responses":{"200":{"description":"Tagged posts","content":{"application/json":{"schema":{"type":"object","properties":{"items":{"type":"array","items":{"$ref":"#/components/schemas/DiscoveredPost"}},"cursor":{"type":"string","nullable":true},"platform":{"$ref":"#/components/schemas/Platform"}},"required":["items","cursor","platform"]}}}}}}},"/v1/social-accounts/{id}/allowance":{"get":{"tags":["Channels"],"summary":"Publishing headroom left","description":"How many posts this channel has left in the network's own rolling window, read from the network rather than counted locally. Check it before planning a batch: the alternative is discovering the ceiling by being refused partway through one, with some posts already live and some not.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Allowance","content":{"application/json":{"schema":{"type":"object","properties":{"used":{"type":"integer"},"limit":{"type":"integer"},"remaining":{"type":"integer"},"windowHours":{"type":"integer","description":"The window the count covers, so \"5 left\" means something."},"platform":{"$ref":"#/components/schemas/Platform"}},"required":["used","limit","remaining","windowHours","platform"]}}}}}}},"/v1/social-accounts/{id}/ad-accounts":{"get":{"tags":["Channels"],"summary":"Ad account lookup (deferred)","description":"Unavailable for Facebook and Instagram while PostLake defers Meta advertising features. Returns HTTP 501 with code meta_ads_deferred, not an empty account list. Reconnecting or using an older token will not enable it. The former success schema is retained for client compatibility.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Reserved success shape; unavailable for Facebook and Instagram while deferred","content":{"application/json":{"schema":{"type":"object","properties":{"business":{"type":"object","nullable":true,"description":"The business that owns the Page behind this channel. Named because several things hang off it: creator marketplace requires THIS business to be verified, and ad accounts belong to it. Without it, checking the right business in Business Settings is guesswork.","properties":{"id":{"type":"string"},"name":{"type":"string","nullable":true},"verified":{"type":"boolean","nullable":true,"description":"Null when the network does not say."}}},"adAccounts":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"},"currency":{"type":"string","nullable":true},"status":{"type":"string","nullable":true,"description":"Only an active account can be spent against."}},"required":["id","name"]}}},"required":["adAccounts"]}}}},"501":{"description":"Feature deferred. Returns error.type=not_implemented, error.code=meta_ads_deferred and retryable=false. Reconnecting will not enable it.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/v1/social-accounts/{id}/branded-partners":{"get":{"tags":["Channels"],"summary":"Branded partner lookup (deferred)","description":"Unavailable for Facebook and Instagram: these partner lookups depend on deferred Meta advertising permissions. Returns HTTP 501 with code meta_ads_deferred. This does not remove non-ad branded-content publishing permissions or ordinary tagged-post reads. The former success schema is retained for client compatibility.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Reserved success shape; unavailable for Facebook and Instagram while deferred","content":{"application/json":{"schema":{"type":"object","properties":{"partners":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"handle":{"type":"string"},"canPromote":{"type":"boolean","nullable":true}},"required":["id","handle","canPromote"]}}},"required":["partners"]}}}},"501":{"description":"Feature deferred. Returns error.type=not_implemented, error.code=meta_ads_deferred and retryable=false. Reconnecting will not enable it.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/v1/social-accounts/{id}/partnership-permissions":{"get":{"tags":["Channels"],"summary":"Facebook Partnership Ads permissions (deferred)","description":"Unavailable while PostLake defers Meta advertising features. Returns HTTP 501 with code meta_ads_deferred without contacting Meta. Reconnecting will not enable it. The former success schema is retained for client compatibility.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}},{"name":"direction","in":"query","schema":{"type":"string","enum":["sent","received"],"default":"sent"}}],"responses":{"200":{"description":"Reserved success shape; unavailable while deferred","content":{"application/json":{"schema":{"type":"object","properties":{"items":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"partnerPageId":{"type":"string"},"status":{"type":"integer","description":"1 pending; 2 or 7 approved; 3 rejected; 4 revoked; 5 self-removed; 6 cancelled."},"direction":{"type":"string","enum":["sent","received"]},"createdAt":{"type":"string","nullable":true}},"required":["id","partnerPageId","status","direction","createdAt"]}}},"required":["items"]}}}},"501":{"description":"Feature deferred. Returns error.type=not_implemented, error.code=meta_ads_deferred and retryable=false. Reconnecting will not enable it.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}},"post":{"tags":["Channels"],"summary":"Act on a Facebook Partnership Ads permission (deferred)","description":"Unavailable while PostLake defers Meta advertising features. Returns HTTP 501 with code meta_ads_deferred. No request is sent to Meta and no partner is notified. The former success schema is retained for client compatibility.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"partnerPageId":{"type":"string","pattern":"^[0-9]+$"},"action":{"type":"string","enum":["send-request","cancel-request","accept-request","reject-request","remove-permission"]}},"required":["partnerPageId","action"]}}}},"responses":{"200":{"description":"Reserved success shape; unavailable while deferred","content":{"application/json":{"schema":{"type":"object","properties":{"partnerPageId":{"type":"string"},"permissionId":{"type":"string","nullable":true},"status":{"type":"integer","nullable":true},"action":{"type":"string"}},"required":["partnerPageId","permissionId","status","action"]}}}},"501":{"description":"Feature deferred. Returns error.type=not_implemented, error.code=meta_ads_deferred and retryable=false. Reconnecting will not enable it.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/v1/social-accounts/{id}/branded-content-posts":{"get":{"tags":["Channels"],"summary":"Facebook branded-content posts (deferred)","description":"Unavailable because this read requires the deferred facebook_branded_content_ads_brand permission. Returns HTTP 501 with code meta_ads_deferred without contacting Meta. Ordinary tagged-post reads are unaffected. The former success schema is retained for client compatibility.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}},{"name":"startDate","in":"query","schema":{"type":"string","format":"date"}},{"name":"endDate","in":"query","schema":{"type":"string","format":"date"}}],"responses":{"200":{"description":"Reserved success shape; unavailable while deferred","content":{"application/json":{"schema":{"type":"object","properties":{"items":{"type":"array","items":{"type":"object","properties":{"postId":{"type":"string"},"creatorPageId":{"type":"string"},"creatorPageName":{"type":"string"},"createdAt":{"type":"string","nullable":true}},"required":["postId","creatorPageId","creatorPageName","createdAt"]}}},"required":["items"]}}}},"501":{"description":"Feature deferred. Returns error.type=not_implemented, error.code=meta_ads_deferred and retryable=false. Reconnecting will not enable it.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/v1/social-accounts/{id}/events":{"post":{"tags":["Channels"],"summary":"Create a scheduled event","description":"Create an event on the account, where the network allows it through the API. Instagram calls this a reminder and normally expects it to be added while composing a post in the app; this asks the API directly, and a network that refuses says so in its own words. The returned id goes in the `upcomingEventId` post option.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["title","startsAt"],"properties":{"title":{"type":"string"},"startsAt":{"type":"string","format":"date-time"},"endsAt":{"type":"string","format":"date-time"}}}}}},"responses":{"201":{"description":"Created","content":{"application/json":{"schema":{"type":"object","properties":{"id":{"type":"string"},"title":{"type":"string"},"startsAt":{"type":"string","nullable":true},"endsAt":{"type":"string","nullable":true}},"required":["id","title"]}}}}}},"get":{"tags":["Channels"],"summary":"Scheduled events on this channel","description":"Events scheduled on the account (a launch, a drop, a live), whose id goes in the `upcomingEventId` post option to put a reminder button on the post. Events are created in the network's own app; this reads them. Instagram needs a connection made through Facebook. A network without events returns an empty list.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Events","content":{"application/json":{"schema":{"type":"object","properties":{"items":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","description":"Pass this in the upcomingEventId post option."},"title":{"type":"string"},"startsAt":{"type":"string","nullable":true},"endsAt":{"type":"string","nullable":true}},"required":["id","title"]}},"cursor":{"type":"string","nullable":true}},"required":["items","cursor"]}}}}}}},"/v1/social-accounts/{id}/products":{"get":{"tags":["Channels"],"summary":"Shoppable products on this channel","description":"The account's own shop catalogue, so you can find the product ids the `productIds` post option takes. Only networks with a shop answer; anything else returns an empty list rather than an error, because \"nothing to tag\" is a true answer to the question. Instagram needs an approved Shop and a catalogue on the same business.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}},{"name":"q","in":"query","required":false,"schema":{"type":"string"},"description":"Filter by product name."},{"name":"cursor","in":"query","required":false,"schema":{"type":"string"}},{"name":"limit","in":"query","required":false,"schema":{"type":"integer","minimum":1,"maximum":100}}],"responses":{"200":{"description":"Products","content":{"application/json":{"schema":{"type":"object","properties":{"items":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","description":"Pass this in the productIds post option."},"name":{"type":"string"},"imageUrl":{"type":"string","nullable":true},"price":{"type":"string","nullable":true},"status":{"type":"string","nullable":true,"description":"Only approved products can be tagged on a post."}},"required":["id","name"]}},"cursor":{"type":"string","nullable":true}},"required":["items","cursor"]}}}}}}},"/v1/social-accounts/{id}/publish-info":{"get":{"tags":["Social Accounts"],"summary":"Creator-level publish constraints","description":"Live constraints for one connected account (TikTok creator_info): available privacy options, whether comments/duet/stitch are allowed, and this creator's max video duration. Render post UIs from this. 404 for platforms without creator-level constraints.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"platform":{"$ref":"#/components/schemas/Platform"},"username":{"type":"string"},"nickname":{"type":"string"},"avatarUrl":{"type":"string"},"privacyOptions":{"type":"array","items":{"type":"string"}},"commentDisabled":{"type":"boolean"},"duetDisabled":{"type":"boolean"},"stitchDisabled":{"type":"boolean"},"maxVideoDurationSec":{"type":"integer"}},"required":["platform"]}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"description":"Unknown account or no creator-level constraints"}}}},"/v1/social-accounts/{id}/live-broadcasts":{"get":{"tags":["Live Video"],"summary":"List Facebook Page live broadcasts","description":"Lists current and recent broadcasts from Facebook. It never returns an ingest URL or stream key.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}},{"name":"limit","in":"query","schema":{"type":"integer","minimum":1,"maximum":100}}],"responses":{"200":{"description":"Success"}}},"post":{"tags":["Live Video"],"summary":"Create a Facebook Page live broadcast","description":"Prepares a Facebook Page broadcast. Facebook returns a one-time secure ingest URL, and may return a separate stream key, for compatible streaming software. PostLake never stores either value. Start the encoder, then call the start endpoint to make it live. Preparing is free; a credit is used only after Facebook confirms the broadcast is live. Meta independently requires the Facebook account managing the Page to be at least 60 days old, the Page to have at least 100 followers, and the person connecting it to have Page-management permission. PostLake cannot override those checks.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["title"],"properties":{"title":{"type":"string"},"description":{"type":"string"}}}}}},"responses":{"201":{"description":"Created. Save the ingest credentials now: they cannot be read later."}}}},"/v1/social-accounts/{id}/live-broadcasts/{liveId}/start":{"post":{"tags":["Live Video"],"summary":"Make a prepared Facebook Page broadcast live","description":"Makes a prepared Facebook broadcast visible now. Start your encoder before calling this endpoint. Facebook is authoritative for whether the broadcast becomes live.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}},{"name":"liveId","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Facebook's current broadcast status"}}}},"/v1/social-accounts/{id}/live-broadcasts/{liveId}":{"get":{"tags":["Live Video"],"summary":"Read a Facebook live broadcast","description":"Reads Facebook's authoritative broadcast status and final replay URL when available. Ingest credentials are never returned.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}},{"name":"liveId","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Success"}}}},"/v1/social-accounts/{id}/live-broadcasts/{liveId}/end":{"post":{"tags":["Live Video"],"summary":"End a Facebook live broadcast","description":"Ends the Facebook broadcast. A successful response reflects Facebook's final status and replay URL where Facebook provides one.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}},{"name":"liveId","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Ended"}}}},"/v1/posts":{"post":{"tags":["Posts"],"summary":"Publish, schedule or draft a post","description":"Publish or schedule a post. Set `draft: true` to save it instead. A guarded agent's normal publish request is stored in `awaiting_approval` automatically and cannot be approved by that same agent. Neither state costs anything or contacts a network.","parameters":[{"name":"Idempotency-Key","in":"header","description":"Idempotency Key","schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"text":{"type":"string","description":"Caption for feed posts and Reels. May be empty for an Instagram Story-only post; Instagram Stories do not display captions."},"campaign":{"type":"string","description":"Optional lowercase campaign slug, up to 80 characters."},"assetMetadata":{"type":"object","additionalProperties":{"type":"string"},"description":"Up to 16 short caller-defined fields, such as hook, track or source filename."},"profile":{"type":"string","description":"A profile name. Resolves to every account it owns. The simple way to address accounts; combine with `platforms` to narrow it. Provide `profile`, `accounts`, or both."},"platforms":{"type":"array","items":{"$ref":"#/components/schemas/Platform"},"description":"Optional filter. Keeps only these networks from the resolved set (e.g. profile \"my-brand\" + platforms [\"bluesky\"])."},"accounts":{"type":"array","items":{"type":"string"},"description":"Connected account ids (acc_…). An alternative (or addition) to `profile`."},"media":{"type":"array","items":{"type":"string"}},"requireAllTargets":{"type":"boolean","description":"When true, refuse the whole request before contacting a network if any selected destination fails PostLake's deterministic capability preflight. Default false preserves partial fan-out."},"scheduledAt":{"type":"string","format":"date-time","description":"UTC with a trailing Z, or a naive local time together with timezone."},"timezone":{"type":"string","description":"IANA timezone (e.g. Europe/London). Interprets a naive scheduledAt as wall time in that zone."},"draft":{"type":"boolean","description":"Save without sending. Returns `draft`, or `awaiting_approval` when this agent requires approval. Nothing is charged and no network is contacted."},"platformOptions":{"type":"object","properties":{"pinterest":{"type":"object","properties":{"boardId":{"type":"string"},"link":{"type":"string"},"altText":{"type":"string"}}},"tiktok":{"type":"object","description":"See GET /v1/platforms/tiktok for the full option set and valid values.","properties":{"mode":{"type":"string","enum":["direct","inbox"],"description":"direct = publish now; inbox = send to the creator's TikTok inbox as a draft (finished inside the TikTok app)."},"privacyLevel":{"type":"string","enum":["PUBLIC_TO_EVERYONE","MUTUAL_FOLLOW_FRIENDS","FOLLOWER_OF_CREATOR","SELF_ONLY"]},"title":{"type":"string","maxLength":90,"description":"Photo-post title (video posts use `text` as the caption)."},"allowComment":{"type":"boolean"},"allowDuet":{"type":"boolean"},"allowStitch":{"type":"boolean"},"coverTimestampMs":{"type":"integer"},"photoCoverIndex":{"type":"integer"},"autoAddMusic":{"type":"boolean","default":true},"brandContent":{"type":"boolean"},"brandOrganic":{"type":"boolean"},"isAigc":{"type":"boolean"}}},"instagram":{"type":"object","description":"Choose Story explicitly. The default is a feed photo or Reel. Stories support both Instagram Login and Facebook Login, subject to Meta account eligibility. Facebook Login requires an Instagram Business account linked to a Facebook Page.","properties":{"placement":{"type":"string","enum":["feed","story","stories"],"description":"story and stories are equivalent. Exactly one JPEG or PNG image, or MP4/MOV video. PostLake converts PNG to JPEG for Instagram. Story text is not displayed."},"shareToFeed":{"type":"boolean","description":"Reels only; rejected for Stories."}}},"facebook":{"type":"object","description":"Facebook Page publishing options. Page mentions only resolve after Meta approves PostLake's Page Mentioning feature.","properties":{"link":{"type":"string","format":"uri","description":"URL shown as a link preview on text posts."},"locationId":{"type":"string","description":"Facebook Page or place ID to tag as the post location."},"mentionPageIds":{"type":"string","description":"Comma-separated Facebook Page IDs. PostLake appends Facebook's inline @[Page-ID] tokens to the caption. Meta resolves them as Page links only after Page Mentioning approval."},"mentionPages":{"type":"array","description":"Selected Facebook Pages already written into text as @PageName. PostLake replaces each matching display name with Facebook's @[Page-ID] token at the same position.","items":{"type":"object","required":["id","name"],"properties":{"id":{"type":"string","description":"Numeric Facebook Page ID."},"name":{"type":"string","description":"Visible Page name without the leading @."}}}}}}}}},"required":["text"]}}}},"responses":{"201":{"description":"Created","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Post"}}}},"400":{"description":"Invalid input"},"401":{"$ref":"#/components/responses/Unauthorized"},"402":{"description":"Entitlement exceeded"},"429":{"$ref":"#/components/responses/RateLimited"}}},"get":{"tags":["Posts"],"summary":"List posts","description":"List posts by creation time (default), or use state=scheduled and sort=scheduledAt to walk a scheduled calendar chronologically. The scheduled cursor remains valid if earlier posts are cancelled.","parameters":[{"name":"sort","in":"query","schema":{"type":"string","enum":["created","scheduledAt"]},"description":"scheduledAt requires state=scheduled. Return posts in ascending scheduled time with stable keyset pagination."},{"name":"q","in":"query","schema":{"type":"string","maxLength":200},"description":"Case-insensitive literal caption search, applied before pagination."},{"name":"campaign","in":"query","schema":{"type":"string"},"description":"Exact campaign slug."},{"name":"targetCount","in":"query","schema":{"type":"string","enum":["single","shared"]},"description":"Single-destination or shared multi-destination posts."},{"name":"state","in":"query","schema":{"$ref":"#/components/schemas/PostState"}},{"name":"account","in":"query","schema":{"type":"string"},"description":"Filter to posts that targeted this connected account id (acc_…)."},{"name":"profile","in":"query","schema":{"type":"string"},"description":"Filter to posts that targeted a channel in this profile (username)."},{"name":"network","in":"query","schema":{"$ref":"#/components/schemas/Platform"},"description":"Filter to posts that targeted this social network."},{"name":"agent","in":"query","schema":{"type":"string"},"description":"Filter by stable agent id (plc_… or key_…). Use `any` for all machine-created posts or `manual` for dashboard-created posts."},{"name":"surface","in":"query","schema":{"type":"string","enum":["dashboard","api","agent"]},"description":"Filter by creation surface. This distinguishes dashboard work, direct API integrations, and agent tools."},{"name":"approval","in":"query","schema":{"type":"string","enum":["pending","approved","rejected"]},"description":"Filter the human approval workflow."},{"name":"from","in":"query","schema":{"type":"string","format":"date-time"},"description":"Include posts on or after this date. Uses published time, scheduled time, then creation time."},{"name":"to","in":"query","schema":{"type":"string","format":"date-time"},"description":"Include posts on or before this date. Uses published time, scheduled time, then creation time."},{"name":"limit","in":"query","schema":{"type":"integer","minimum":1,"maximum":100,"default":50}},{"name":"cursor","in":"query","schema":{"type":"string"}}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"posts":{"type":"array","items":{"$ref":"#/components/schemas/Post"}},"nextCursor":{"oneOf":[{"type":"string"},{"type":"null"}]}},"required":["posts","nextCursor"]}}}},"401":{"$ref":"#/components/responses/Unauthorized"}}}},"/v1/posts/cadence/preview":{"post":{"tags":["Posts"],"summary":"Preview a timezone-aware publishing cadence","description":"Returns deterministic UTC slots without creating, reserving, or charging for posts. Call POST /v1/posts/validate for each proposed post before creating it. Platform pacing limits remain authoritative at publish time. Ambiguous or nonexistent local times are rejected.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["timezone","startDate","days","localTime","tracks"],"additionalProperties":false,"properties":{"timezone":{"type":"string","description":"IANA timezone, such as Europe/London."},"startDate":{"type":"string","format":"date"},"days":{"type":"integer","minimum":1,"maximum":90},"localTime":{"type":"string","pattern":"^(?:[01]\\d|2[0-3]):[0-5]\\d$","description":"Local HH:mm start time."},"tracks":{"type":"array","minItems":1,"maxItems":10,"items":{"type":"object","required":["id"],"properties":{"id":{"type":"string"},"staggerMinutes":{"type":"integer","minimum":0,"maximum":720}}}},"gapMinutes":{"type":"integer","minimum":0,"maximum":720,"default":30},"occupiedAt":{"type":"array","maxItems":500,"items":{"type":"string","format":"date-time"},"description":"Existing UTC or offset-qualified posts to avoid. No database lookup is performed."}}}}}},"responses":{"200":{"description":"Read-only slot preview","content":{"application/json":{"schema":{"type":"object","required":["timezone","slots"],"properties":{"timezone":{"type":"string"},"slots":{"type":"array","items":{"type":"object","required":["date","track","localTime","scheduledAt"],"properties":{"date":{"type":"string","format":"date"},"track":{"type":"string"},"localTime":{"type":"string"},"scheduledAt":{"type":"string","format":"date-time"}}}}}}}}},"400":{"description":"Invalid date, timezone, size or unavailable local time"}}}},"/v1/posts/calendar":{"post":{"tags":["Posts"],"summary":"Preview or atomically create a scheduled calendar","description":"Validate 1 to 200 scheduled posts as a batch. Preview with dryRun=true, then apply with the returned snapshot and a unique idempotencyKey. Either every post and the receipt are stored or none are. A cadence may assign UTC instants from startAt and everyMinutes; omit it to give each post its own scheduledAt. Credits are charged when posts publish, not when the calendar is created. Timer arming is best-effort, with the due-post runner as a backstop.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["posts","dryRun"],"additionalProperties":false,"properties":{"posts":{"type":"array","minItems":1,"maxItems":200,"description":"Normal POST /v1/posts bodies with a future scheduledAt, unless cadence assigns the times. Drafts and partial destinations are not accepted.","items":{"type":"object","required":["text"],"properties":{"text":{"type":"string"},"accounts":{"type":"array","items":{"type":"string"}},"profile":{"type":"string"},"platforms":{"type":"array","items":{"type":"string"}},"scheduledAt":{"type":"string","format":"date-time"},"timezone":{"type":"string"},"media":{"type":"array","items":{"type":"string"}},"textOverrides":{"type":"object","additionalProperties":{"type":"string"}},"campaign":{"type":"string"},"assetMetadata":{"type":"object","additionalProperties":{"type":"string"}},"platformOptions":{"type":"object"}}}},"cadence":{"type":"object","required":["startAt","everyMinutes"],"additionalProperties":false,"properties":{"startAt":{"type":"string","format":"date-time","description":"Timezone-qualified start instant."},"everyMinutes":{"type":"integer","minimum":1,"maximum":525600,"description":"Elapsed minutes between consecutive posts."}}},"dryRun":{"type":"boolean"},"snapshot":{"type":"string","description":"Required for apply; return value from preview."},"idempotencyKey":{"type":"string","description":"Required for apply; 8 to 128 letters, digits, underscores or hyphens."}}}}}},"responses":{"200":{"description":"Preview with exact UTC slots and snapshot.","content":{"application/json":{"schema":{"type":"object","properties":{"dryRun":{"type":"boolean"},"snapshot":{"type":"string"},"matched":{"type":"integer"},"results":{"type":"array","items":{"type":"object"}}}}}}},"201":{"description":"All posts created, or a safe idempotent replay.","content":{"application/json":{"schema":{"type":"object","properties":{"dryRun":{"type":"boolean"},"idempotencyReplayed":{"type":"boolean"},"posts":{"type":"array","items":{"$ref":"#/components/schemas/Post"}}}}}}},"400":{"description":"A calendar entry or destination is invalid. No posts created."},"409":{"description":"Stale preview or conflicting idempotency key. No new posts created."}}}},"/v1/posts/bulk":{"patch":{"tags":["Posts"],"summary":"Preview or atomically edit scheduled posts","description":"Select 1 to 200 exact post IDs. First send dryRun=true and inspect the per-post before/after diff. Apply with dryRun=false and the returned snapshot. A changed, missing, non-scheduled or imminent post rejects the entire edit. A UTC shift updates all scheduled times atomically in storage; timer re-arming is best-effort, with the due-post cron as a backstop. Cancellation is a separate endpoint.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["ids","patch","dryRun"],"additionalProperties":false,"properties":{"ids":{"type":"array","minItems":1,"maxItems":200,"uniqueItems":true,"items":{"type":"string"}},"patch":{"type":"object","additionalProperties":false,"properties":{"text":{"type":"string"},"textOverrides":{"type":"object","additionalProperties":{"type":"string"}},"accounts":{"type":"array","items":{"type":"string"},"description":"Exact final destination list for every selected post."},"shiftMinutes":{"type":"integer","description":"Shift each scheduled UTC instant by this many minutes; all resulting times must remain in the future."},"campaign":{"type":"string"},"assetMetadata":{"type":"object","additionalProperties":{"type":"string"}}}},"dryRun":{"type":"boolean"},"snapshot":{"type":"string","description":"Required for apply; copy from the preview response."}}}}}},"responses":{"200":{"description":"Preview or full edit, with per-post before/after results and snapshot.","content":{"application/json":{"schema":{"type":"object","required":["dryRun","snapshot","matched","results"],"properties":{"dryRun":{"type":"boolean"},"snapshot":{"type":"string"},"matched":{"type":"integer"},"results":{"type":"array","items":{"type":"object","required":["id","before","after"],"properties":{"id":{"type":"string"},"before":{"type":"object","description":"Previous text, per-platform overrides, destination IDs, campaign, asset metadata and scheduled UTC time."},"after":{"type":"object","description":"Proposed or applied values for those same fields."}}}}}}}}},"400":{"description":"Invalid input or destination."},"409":{"description":"A post changed, started publishing, or is too close to its publish time. None changed."}}}},"/v1/idempotency/{key}":{"get":{"tags":["Posts"],"operationId":"getIdempotencyReceipt","summary":"Recover a creation receipt without replaying a write","description":"Account-scoped, read-only recovery by Idempotency-Key. originalResponse is the immutable create response; post is the current canonical resource, including approval outcomes. Cancellation does not release the key. A pending receipt or missing receipt does not prove an uncertain provider operation is safe to repeat.","parameters":[{"name":"key","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Retained receipt","content":{"application/json":{"schema":{"type":"object","required":["status","postId","originalResponse","post","resourceAvailable"],"properties":{"status":{"type":"string","enum":["pending","completed"]},"postId":{"type":["string","null"]},"originalResponse":{"oneOf":[{"$ref":"#/components/schemas/Post"},{"type":"null"}]},"post":{"oneOf":[{"$ref":"#/components/schemas/Post"},{"type":"null"}]},"resourceAvailable":{"type":"boolean"}}}}}},"404":{"description":"No retained receipt. Not proof that publication did not happen."}}}},"/v1/posts/bulk/cancel":{"post":{"tags":["Posts"],"summary":"Preview or cancel a bounded scheduled batch","description":"Supply 1 to 200 unique IDs, one profile, and an inclusive timezone-qualified scheduled-time window. dryRun returns a snapshot of all stored post fields and the selection scope. Send it on apply to reject any intervening change, including media/options edits. Cancellation is all-or-none, scheduled-only and never retracts published content. Legacy calls without a snapshot retain execution-time compare-and-swap protection only.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["ids","profile","from","to"],"additionalProperties":false,"properties":{"ids":{"type":"array","minItems":1,"maxItems":200,"uniqueItems":true,"items":{"type":"string"},"description":"Exact IDs of scheduled posts to preview or cancel."},"profile":{"type":"string","description":"Every target of every selected post must belong to this profile."},"from":{"type":"string","format":"date-time","description":"Inclusive earliest scheduled time, with Z or an explicit offset."},"to":{"type":"string","format":"date-time","description":"Inclusive latest scheduled time, with Z or an explicit offset."},"dryRun":{"type":"boolean","default":false,"description":"Preview the selection without cancelling anything."},"snapshot":{"type":"string","description":"Full-post and selection snapshot returned by preview. Apply rejects changed content or scope with HTTP 409."}}}}}},"responses":{"200":{"description":"Preview or all posts cancelled","content":{"application/json":{"schema":{"type":"object","required":["dryRun","snapshot","matched","results"],"properties":{"dryRun":{"type":"boolean"},"snapshot":{"type":"string"},"matched":{"type":"integer"},"results":{"type":"array","items":{"type":"object","required":["id","cancelled"],"properties":{"id":{"type":"string"},"cancelled":{"type":"boolean"},"error":{"type":"string"}}}}}}}}},"400":{"description":"Invalid selection"},"409":{"description":"Selection changed or publishing started; no posts cancelled"}}}},"/v1/posts/{id}/cancel":{"post":{"tags":["Posts"],"summary":"Cancel only a scheduled post, with optional preview fencing","description":"Never retracts a published post. Preview with dryRun=true, then apply with the returned snapshot to fence all stored fields. Execution uses a full-post compare-and-swap even when a snapshot is omitted.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","additionalProperties":false,"properties":{"dryRun":{"type":"boolean"},"snapshot":{"type":"string"}}}}}},"responses":{"200":{"description":"Preview snapshot or scheduled post cancelled","content":{"application/json":{"schema":{"type":"object","properties":{"id":{"type":"string"},"cancelled":{"type":"boolean"},"dryRun":{"type":"boolean"},"snapshot":{"type":"string"}},"required":["id","cancelled"]}}}},"400":{"description":"Post is not scheduled or input is invalid. Nothing was retracted."},"404":{"description":"Post not found"},"409":{"description":"Post changed or publishing started. Nothing cancelled."}}}},"/v1/posts/{id}/deletion":{"get":{"tags":["Posts"],"summary":"Preview cancellation or published-post removal","description":"No side effects. Returns the action for the current state, destination-specific support and a full-post snapshot. Removing social posts never deletes uploads or refunds spent publishing credits. Instagram and TikTok posts must be removed in their apps. Pending publication cannot be retracted until its outcome is known. Approval requests must be rejected, not silently erased.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Action, canApply, snapshot, targets with canRemove/alreadyAccepted/url/reason, and effects","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PostDeletionPreview"}}}},"404":{"description":"Post not found in this workspace"}}}},"/v1/posts/{id}":{"get":{"tags":["Posts"],"summary":"Get post","description":"Get a single post by id","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Post"}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"description":"Post not found"}}},"patch":{"tags":["Posts"],"summary":"Edit a scheduled post or a draft","description":"Edit a post that has not gone out. For a scheduled post, `accounts` replaces the exact destination list at least 30 seconds before publishing. Include `expectedAccounts` from your last GET so a stale edit cannot overwrite a newer destination change. Existing matching targets are preserved and new destinations inherit the publish time after preflight validation. Guardrails apply; approval-required agents must ask the owner. `targetAccounts` repairs a disconnected target by exact replacement. A draft may remain incomplete until published.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"text":{"type":"string"},"campaign":{"type":"string","description":"Optional campaign slug. Set null to clear."},"assetMetadata":{"type":"object","additionalProperties":{"type":"string"},"description":"Optional caller-defined asset facts. Set null to clear."},"textOverrides":{"type":"object","additionalProperties":{"type":"string"}},"scheduledAt":{"type":"string","format":"date-time"},"timezone":{"type":"string","description":"IANA timezone (e.g. Europe/London)."},"media":{"type":"array","items":{"type":"string"}},"mediaAlt":{"type":"array","items":{"type":"string"}},"mediaOverrides":{"type":"object","additionalProperties":{"type":"array","items":{"type":"string"}}},"mediaAltOverrides":{"type":"object","additionalProperties":{"type":"array","items":{"type":"string"}}},"platformOptions":{"type":"object","additionalProperties":true,"description":"Merged per platform, so a TikTok title patch does not wipe other TikTok fields."},"accounts":{"type":"array","items":{"type":"string"},"description":"Exact final acc_ destination list. Scheduled posts require at least one connected account; new destinations inherit the same publish time."},"expectedAccounts":{"type":"array","items":{"type":"string"},"description":"Required with accounts on scheduled posts. The current target account ids from GET /v1/posts/{id}, in order. Rejects stale edits."},"targetAccounts":{"type":"object","additionalProperties":{"type":"string"},"description":"Scheduled posts only. Map a disconnected target's old acc_ id to the exact replacement connected acc_ id."},"thread":{"type":"array","items":{"type":"string"},"description":"Drafts only. New follow-on post bodies. An empty array removes the thread."},"firstComment":{"type":"string","description":"Drafts only. New first comment. An empty string removes it."},"firstCommentOverrides":{"type":"object","additionalProperties":{"type":"string"},"description":"Drafts only. New per-platform first-comment overrides."}}}}}},"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Post"}}}},"400":{"description":"The post has already gone out, or the input is invalid"},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"description":"Post not found"}}},"delete":{"tags":["Posts"],"summary":"Cancel a scheduled post, discard a draft, or retract a live one","description":"Cancel a future schedule, discard a personal draft, clear a failed record, or request removal of published targets where supported. Approval requests must be rejected separately. Queued/processing publication returns 409. GET /v1/posts/{id}/deletion previews the consequences; send its snapshot to reject intervening changes. Unsupported destinations stay online and keep the record visible. Accepted removals are retained on remaining targets and not repeated on retry. Uploads are kept; spent publishing credits are not refunded. Instagram and TikTok require removal in their apps.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"required":false,"content":{"application/json":{"schema":{"type":"object","properties":{"snapshot":{"type":"string","description":"Full-post snapshot returned by the deletion preview."}}}}}},"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"id":{"type":"string"},"cancelled":{"type":"boolean"},"deleted":{"type":"boolean"},"targets":{"type":"array","items":{"type":"object","properties":{"account":{"type":"string"},"platform":{"type":"string"},"deleted":{"type":"boolean"},"verified":{"type":"boolean"},"reason":{"type":"string"},"url":{"type":"string"}}}}},"required":["id"]}}}},"207":{"description":"Retracted from some networks but not all. Part of the post is still public."},"400":{"description":"Approval request must be rejected instead"},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"description":"Post not found"},"409":{"description":"Snapshot changed or publication is still pending"}}}},"/v1/posts/{id}/refresh":{"post":{"tags":["Posts"],"summary":"Confirm an async post's current state","description":"Ask an asynchronous publishing platform for the current state now. Useful after a post is accepted in processing state. A provider-confirmed post is promoted to published immediately; failed transitions and credit refunds remain in the background recovery path.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Current post state","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Post"}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"description":"Post not found"}}}},"/v1/posts/{id}/publish":{"post":{"tags":["Posts"],"summary":"Publish a saved draft","description":"Publish or schedule in place with the original ID and immutable sourceDraftId. Content, campaign, assetMetadata and platform options are preserved. Supply expectedSnapshot from GET to fence every stored field. Identical completed approvals return the existing result without reposting; a claimed or uncertain operation returns 409. Approval-required agents cannot approve themselves. Personal drafts remain editable after every destination fails; approved submissions retain a failed outcome for reconciliation.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"required":false,"description":"Optional. Only what changes at publish time.","content":{"application/json":{"schema":{"type":"object","properties":{"scheduledAt":{"oneOf":[{"type":"string","format":"date-time"},{"type":"null"}],"description":"Publish at this time instead. Omit to keep whatever time the draft was carrying (or send now if it had none). Send null to clear the draft's time and publish immediately."},"timezone":{"type":"string","description":"IANA timezone (e.g. Europe/London) for a naive scheduledAt."},"accounts":{"type":"array","items":{"type":"string"},"description":"Publish to these accounts instead of the draft's own destinations."},"expectedSnapshot":{"type":"string","description":"Full-state snapshot from GET. Any intervening edit rejects approval with 409."},"profile":{"type":"string","description":"Publish to every account under this profile instead of the draft's own destinations."},"platforms":{"type":"array","items":{"$ref":"#/components/schemas/Platform"},"description":"Optional filter applied to the resolved destinations."}}}}}},"responses":{"201":{"description":"Published or scheduled","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Post"}}}},"400":{"description":"That post is not a draft, or the content is invalid"},"401":{"$ref":"#/components/responses/Unauthorized"},"402":{"description":"Not enough credits. The draft is left untouched."},"404":{"description":"Draft not found"},"409":{"description":"The reviewed content changed, another approval claimed the post, or the provider outcome needs reconciliation. Read the existing post; do not create a replacement."},"429":{"description":"Rate limited"}}}},"/v1/posts/{id}/analytics":{"get":{"tags":["Analytics"],"summary":"Get post analytics","description":"Live per-post analytics by destination. measuredAt says when the read completed. targets[].reports identifies metrics the network exposes; targets[].metrics is null when insights are unavailable, not zero.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"postId":{"type":"string"},"createdAt":{"type":"string","format":"date-time"},"measuredAt":{"type":"string","format":"date-time","description":"When PostLake completed this live metrics read."},"totals":{"$ref":"#/components/schemas/Metrics"},"targets":{"type":"array","items":{"type":"object","properties":{"account":{"type":"string"},"platform":{"$ref":"#/components/schemas/Platform"},"handle":{"oneOf":[{"type":"string"},{"type":"null"}]},"remoteId":{"oneOf":[{"type":"string"},{"type":"null"}]},"url":{"oneOf":[{"type":"string"},{"type":"null"}]},"reports":{"type":"array","items":{"type":"string"},"description":"Metric names this network reports. An unreported metric must not be displayed as zero."},"metrics":{"oneOf":[{"$ref":"#/components/schemas/Metrics"},{"type":"null"}]}},"required":["account","platform","handle","remoteId","url","reports","metrics"]}}},"required":["postId","createdAt","measuredAt","totals","targets"]}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"description":"Post not found"}}}},"/v1/analytics/refresh":{"post":{"tags":["Analytics"],"summary":"Request background analytics refresh","description":"Queue a workspace analytics refresh and return immediately. Requests are deduplicated while a job is active, with a five-minute start cooldown. Optional ifStale=true reuses a collection updated within fifteen minutes; the dashboard uses it once on entry without restarting collection on filter changes. Recent eligible content is checked first through bounded continuations on a separate queue. During reliability incidents, collection can be paused: new requests return 503, analytics_refresh_unavailable and Retry-After: 60. Stored reports remain available. The same explicit refresh operation is available through get_analytics with refresh=true in MCP.","requestBody":{"required":false,"content":{"application/json":{"schema":{"type":"object","properties":{"ifStale":{"type":"boolean","default":false,"description":"Reuse a recently updated collection instead of starting another job. Automatic-entry freshness interval is fifteen minutes; provider eligibility and cooldowns still apply."}}}}}},"responses":{"202":{"description":"Queued, already active, or within the start cooldown","content":{"application/json":{"schema":{"type":"object","properties":{"jobId":{"type":"string"},"state":{"type":"string","enum":["queued","running","complete","failed"]},"startedAt":{"type":"string","format":"date-time"},"updatedAt":{"type":"string","format":"date-time"},"posts":{"type":"integer"},"errors":{"type":"integer"},"cursor":{"oneOf":[{"type":"string"},{"type":"null"}]}}}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"503":{"description":"Collection temporarily paused or unavailable. Retry-After: 60. Read stored reports without starting another collection job.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}},"get":{"tags":["Analytics"],"summary":"Get analytics refresh progress","description":"Read the most recent workspace refresh state. Null means no refresh has been requested. collectionPaused is true while collection is suspended for service reliability; an existing queued or running job is not necessarily advancing. Complete means eligible collection finished, not that every provider returned every metric. errors counts unavailable readings; prior valid readings are preserved. MCP get_analytics includes this status in its response. Completion invalidates private saved reports immediately; measuredAt remains the metric collection timestamp.","responses":{"200":{"description":"Progress or null","content":{"application/json":{"schema":{"oneOf":[{"type":"object","properties":{"jobId":{"type":"string"},"state":{"type":"string","enum":["queued","running","complete","failed"]},"collectionPaused":{"type":"boolean","description":"True when collection is suspended; queued/running jobs may not advance."},"startedAt":{"type":"string","format":"date-time"},"updatedAt":{"type":"string","format":"date-time"},"posts":{"type":"integer"},"errors":{"type":"integer"},"cursor":{"oneOf":[{"type":"string"},{"type":"null"}]}}},{"type":"null"}]}}}},"401":{"$ref":"#/components/responses/Unauthorized"}}}},"/v1/analytics":{"get":{"tags":["Analytics"],"summary":"Get cross-platform analytics","description":"Read private saved reports for all collected destinations, without a 50-post sample or live provider calls. Cold or invalidated reports use a globally limited read-only builder; busy admission returns 429 with Retry-After: 60 and database failures return 503. Same-filter saved results can be retained with snapshot.stale=true. measuredAt is collection time and snapshot.generatedAt is preparation time. Production reads check the durable generation directly, without an additional edge-cache delay after collection completes. Period mode reports observed counter growth, including older content; Content mode reports lifetime performance of content published in the range. Totals, current, chart and breakdown share mode and filters. Rankings contain up to twenty destinations. POST /v1/analytics/refresh queues collection; GET that route reports progress. GET /v1/analytics/methodology defines reconciliation and freshness.","parameters":[{"name":"period","in":"query","schema":{"type":"string","default":"30d","pattern":"^[0-9]+d$"}},{"name":"profile","in":"query","schema":{"type":"string"},"description":"Profile username. Omit for the whole workspace."},{"name":"mode","in":"query","schema":{"type":"string","enum":["period","content"],"default":"period"},"description":"Observed period activity, or lifetime performance of content published in the range."},{"name":"sort","in":"query","schema":{"type":"string","enum":["views","likes","comments","engagement","engagementRate","impressions","clicks"],"default":"engagement"},"description":"Rank the complete matching destination population before limiting to 20. Unavailable readings sort last. Rates require a positive reported audience count."},{"name":"channel","in":"query","schema":{"type":"string"},"description":"Connected social account ID. Shared posts contribute only matching destinations."},{"name":"network","in":"query","schema":{"type":"string"},"description":"Optional platform name."},{"name":"from","in":"query","schema":{"type":"string","format":"date"},"description":"Inclusive first UTC date, YYYY-MM-DD."},{"name":"to","in":"query","schema":{"type":"string","format":"date"},"description":"Inclusive last UTC date, YYYY-MM-DD. Maximum range 365 days."},{"name":"agent","in":"query","schema":{"type":"string"},"description":"Stable creator client ID, not display name. Unknown historical attribution is not inferred."},{"name":"origin","in":"query","schema":{"type":"string","enum":["dashboard","api","agent"]},"description":"Recorded interface, separate from creator identity. Account-wide supplements are omitted for actor/interface filters."}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"period":{"type":"string"},"since":{"type":"string","format":"date-time"},"mode":{"type":"string","enum":["period","content"]},"until":{"type":"string","format":"date"},"measuredAt":{"type":"string","format":"date-time","nullable":true},"snapshot":{"type":"object","description":"Private saved report metadata. generatedAt is report preparation, not metric collection. stale means rebuilding failed or a newer collection invalidated this report.","properties":{"generatedAt":{"type":"string","format":"date-time"},"stale":{"type":"boolean"}},"required":["generatedAt","stale"]},"rankingScope":{"type":"string","const":"destinations"},"rankingMetric":{"type":"string","enum":["views","likes","comments","engagement","engagementRate","impressions","clicks"]},"averageViews":{"type":"object","nullable":true,"description":"Content mode only: lifetime views divided by destinations explicitly reporting views. Includes measured zeros, excludes unknown readings. A shared post counts once per destination.","properties":{"value":{"type":"number"},"destinations":{"type":"integer"}},"required":["value","destinations"]},"nativeExtrasBasis":{"type":"string","const":"latest_lifetime","description":"Supplemental native extras are lifetime counters, never period activity."},"posts":{"type":"integer","description":"Distinct posts with collected readings published in the range, not every published post."},"totals":{"$ref":"#/components/schemas/Metrics"},"methodologyUrl":{"type":"string","format":"uri","description":"Machine-readable metric and reconciliation definitions."},"current":{"type":"object","nullable":true,"description":"Selected-mode totals for the requested window. Period: observed activity. Content: lifetime results of published content."},"previous":{"type":"object","nullable":true,"description":"Same mode and filters in the preceding equal-length window. Null means no usable observations."},"timeseries":{"type":"array","description":"UTC daily observed growth in Period mode, or lifetime results by publication date in Content mode. Includes likes, comments and engagementRate. A daily rate is null without usable audience counts, not zero.","items":{"type":"object"}},"byChannel":{"type":"array","description":"One row per actual connected social account. Account-wide insights are separate from post totals. fieldCoverage includes readings, unknown legacy readings and per-counter returned counts from latest observations scoped by observation date (Period) or publication date (Content), not historical daily coverage.","items":{"type":"object"}},"topPosts":{"type":"array","description":"Up to 20 ranked destinations across the complete matching population, with optional thumbnailUrl. A shared post can appear more than once.","items":{"type":"object"}},"derived":{"type":"object","description":"Engagement, blended engagement rate and CTR. See methodology for denominators."},"byPlatform":{"type":"array","items":{"type":"object","properties":{"platform":{"$ref":"#/components/schemas/Platform"},"posts":{"type":"integer"},"available":{"type":"boolean"},"metrics":{"$ref":"#/components/schemas/Metrics"},"reports":{"type":"array","items":{"type":"string"},"description":"Metrics that this adapter can report; absence is not a measured zero."},"engagementRate":{"type":"number","nullable":true}},"required":["platform","posts","available","metrics"]}}},"required":["period","since","posts","totals","byPlatform"]}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"429":{"description":"Saved report builder is busy. Retry-After: 60. Retain the previous report and wait before retrying.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"503":{"description":"No saved report is available and preparation is temporarily unavailable. Retry-After: 60.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/v1/analytics/methodology":{"get":{"tags":["Analytics"],"summary":"Read analytics methodology","description":"Machine-readable definitions for normalised metrics, coverage, freshness, availability, comparisons, and per-network reporting. Read this before comparing metrics across networks or explaining a zero.","responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"version":{"type":"integer"},"scope":{"type":"string"},"freshness":{"type":"string"},"totals":{"type":"string"},"engagement":{"type":"string"},"engagementRate":{"type":"string"},"ctr":{"type":"string"},"comparisons":{"type":"string"},"timeseries":{"type":"string"},"availability":{"type":"string"},"profileFilter":{"type":"string"},"accountInsights":{"type":"string"},"reportsByPlatform":{"type":"object","additionalProperties":{"type":"array","items":{"type":"string"}}}},"required":["version","scope","freshness","totals","engagement","engagementRate","ctr","comparisons","timeseries","availability","profileFilter","accountInsights","reportsByPlatform"]}}}},"401":{"$ref":"#/components/responses/Unauthorized"}}}},"/v1/media/policy":{"get":{"tags":["Media"],"summary":"Read uploaded-media retention policy","description":"Applies to PostLake uploads on every plan while the account remains open, not external URLs. Minimum dates are not expiry dates. Source URLs are public; cancelling a post does not delete media. This is publishing storage, not a backup service. See https://postlake.dev/terms#media-retention.","responses":{"200":{"description":"Versioned minimum retention, scheduled protection and exceptions","content":{"application/json":{"schema":{"type":"object","properties":{"version":{"type":"string"},"minimumDays":{"type":"integer"},"scheduledProtection":{"type":"string"},"automaticExpiry":{"type":"boolean"},"exceptions":{"type":"array","items":{"type":"string"}},"scope":{"type":"string"},"accountRequirement":{"type":"string"},"publicUrls":{"type":"boolean"},"postRemovalDeletesMedia":{"type":"boolean"},"backupService":{"type":"boolean"}},"required":["version","minimumDays","scheduledProtection","automaticExpiry","exceptions"]}}}}}}},"/v1/media/{id}":{"delete":{"tags":["Media"],"summary":"Erase an uploaded file and its previews","description":"Owner-scoped, irreversible erasure. First GET /v1/media/{id}/deletion, then send its snapshot. Blocks drafts, approval requests, queued/scheduled/processing targets, concurrent publication and more than 200 references. Replace/remove their media or cancel/reject those posts first. Terminal social posts remain online, but PostLake previews disappear. No publishing-credit refund. Storage failures retain a retryable deletion receipt. This overrides the minimum retention only at the owner's instruction. Does not erase external URLs or copies held independently by social networks.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"snapshot":{"type":"string"}},"required":["snapshot"]}}}},"responses":{"200":{"description":"Original and stored previews removed; repeated completion returns the same receipt","content":{"application/json":{"schema":{"type":"object","properties":{"id":{"type":"string"},"deleted":{"type":"boolean"},"deletesSocialPosts":{"type":"boolean"},"cancelsSchedules":{"type":"boolean"},"refundsPublishingCredits":{"type":"boolean"}}}}}},"400":{"description":"Snapshot is missing or invalid"},"404":{"description":"Upload not found in this workspace"},"409":{"description":"Referenced by pending work, concurrently publishing, stale snapshot or over 200 references"},"500":{"description":"Storage cleanup incomplete; review and retry, not successful erasure"}}},"get":{"tags":["Media"],"summary":"Read an uploaded asset and its retention terms","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Account-scoped media metadata","content":{"application/json":{"schema":{"$ref":"#/components/schemas/MediaAsset"}}}},"404":{"description":"Media not found in this account"}}}},"/v1/media/{id}/deletion":{"get":{"tags":["Media"],"summary":"Preview upload erasure and affected posts","description":"No side effects. Up to 200 affected posts, including draft/options/cover/thumbnail references; truncated=true requires support-assisted erasure. canDelete is a preview, not a lock: apply rechecks references and in-flight publication. state can be active, deleting or deleted. No external social post is deleted.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"snapshot, state, canDelete, references, truncated, warning and effects","content":{"application/json":{"schema":{"$ref":"#/components/schemas/MediaDeletionPreview"}}}},"404":{"description":"Upload not found in this workspace"}}}},"/media/upload":{"put":{"tags":["Media"],"summary":"Complete an MCP local-file upload","description":"Upload raw file bytes to the short-lived target returned by the MCP upload_media tool when called without a URL. Send the returned upload token as `Authorization: Bearer …` and the exact returned Content-Type. This endpoint does not accept a PostLake API key and is not called until upload_media has prepared a media id and target.","requestBody":{"required":true,"content":{"image/jpeg":{"schema":{"type":"string","format":"binary"}},"image/png":{"schema":{"type":"string","format":"binary"}},"image/webp":{"schema":{"type":"string","format":"binary"}},"image/gif":{"schema":{"type":"string","format":"binary"}},"video/mp4":{"schema":{"type":"string","format":"binary"}},"video/quicktime":{"schema":{"type":"string","format":"binary"}},"video/webm":{"schema":{"type":"string","format":"binary"}}}},"responses":{"201":{"description":"Uploaded; returns the MediaAsset whose id is ready for create_post","content":{"application/json":{"schema":{"$ref":"#/components/schemas/MediaAsset"}}}},"400":{"description":"Content type, byte signature, exact size, or media limits did not match the prepared target"},"401":{"description":"Upload token is missing, invalid, or expired"},"409":{"description":"The upload target has already been used"}}}},"/v1/media":{"get":{"tags":["Media"],"summary":"List uploaded files","description":"Owner-scoped cursor pagination in descending media-ID order, not upload-time order. Deleted files are excluded; deleting files remain visible for retry. No post-history scan.","parameters":[{"name":"before","in":"query","schema":{"type":"string"},"description":"next from the previous response"},{"name":"limit","in":"query","schema":{"type":"integer","minimum":1,"maximum":100,"default":50}}],"responses":{"200":{"description":"items and next (null at end)","content":{"application/json":{"schema":{"type":"object","properties":{"items":{"type":"array","items":{"$ref":"#/components/schemas/MediaAsset"}},"next":{"type":"string","nullable":true}}}}}}}},"post":{"tags":["Media"],"summary":"Upload media","description":"Upload media, either as raw binary with Content-Type set, or as multipart/form-data with a file part. For images the response includes the pixel dimensions, which are checked against each network's limits at publish time.","requestBody":{"required":true,"content":{"application/octet-stream":{"schema":{"type":"string","format":"binary"}},"image/jpeg":{"schema":{"type":"string","format":"binary"}},"image/png":{"schema":{"type":"string","format":"binary"}},"image/gif":{"schema":{"type":"string","format":"binary"}},"video/mp4":{"schema":{"type":"string","format":"binary"}},"multipart/form-data":{"schema":{"type":"object","properties":{"file":{"type":"string","format":"binary"}},"required":["file"]}}}},"responses":{"201":{"description":"Created","content":{"application/json":{"schema":{"$ref":"#/components/schemas/MediaAsset"}}}},"400":{"description":"Empty body, missing Content-Type, no file part, or an unsupported media type"},"401":{"$ref":"#/components/responses/Unauthorized"}}}},"/v1/media/batch":{"post":{"tags":["Media"],"summary":"Upload or prepare several media items","description":"Carousel helper. JSON `{ items: [{ contentType, sizeBytes }] }` returns signed five-minute PUT targets (same as MCP local upload). Multipart with multiple file parts uploads the bytes and returns MediaAsset records. Max 12 items.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["items"],"properties":{"items":{"type":"array","minItems":1,"maxItems":12,"items":{"type":"object","required":["contentType"],"properties":{"contentType":{"type":"string"},"sizeBytes":{"type":"integer","minimum":1}}}}}}},"multipart/form-data":{"schema":{"type":"object","additionalProperties":{"type":"string","format":"binary"}}}}},"responses":{"201":{"description":"Created","content":{"application/json":{"schema":{"type":"object","properties":{"items":{"type":"array","items":{"oneOf":[{"$ref":"#/components/schemas/MediaAsset"},{"type":"object"}]}}}}}}},"400":{"description":"Empty items, too many files, or an unsupported media type"},"401":{"$ref":"#/components/responses/Unauthorized"}}}},"/v1/webhooks":{"post":{"tags":["Webhooks"],"summary":"Register webhook","description":"Register a webhook endpoint","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"url":{"type":"string","format":"uri"},"events":{"type":"array","items":{"$ref":"#/components/schemas/WebhookEventType"}}},"required":["url"]}}}},"responses":{"201":{"description":"Created","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WebhookEndpoint"}}}},"400":{"description":"Invalid URL or events"},"401":{"$ref":"#/components/responses/Unauthorized"}}},"get":{"tags":["Webhooks"],"summary":"List webhooks","description":"List webhook endpoints","responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"webhooks":{"type":"array","items":{"$ref":"#/components/schemas/WebhookEndpoint"}}},"required":["webhooks"]}}}},"401":{"$ref":"#/components/responses/Unauthorized"}}}},"/v1/webhooks/{id}":{"delete":{"tags":["Webhooks"],"summary":"Delete webhook","description":"Delete a webhook endpoint","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"id":{"type":"string"},"deleted":{"type":"boolean"}},"required":["id","deleted"]}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"description":"Webhook not found"}}}},"/v1/webhooks/{id}/test":{"post":{"tags":["Webhooks"],"summary":"Send webhook test event","description":"Sends one signed webhook.test event to this endpoint with a ten-second timeout. The receiver must return 2xx directly; redirects are not followed. A failed attempt enters the durable retry queue. HTTP success confirms receipt, not application processing.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Delivery attempted"},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"description":"Webhook not found"}}}},"/v1/webhooks/deliveries":{"get":{"tags":["Webhooks"],"summary":"List pending and dead-lettered deliveries","parameters":[{"name":"status","in":"query","schema":{"type":"string","enum":["pending","dead"]}}],"responses":{"200":{"description":"Retry-queue deliveries"},"401":{"$ref":"#/components/responses/Unauthorized"}}}},"/v1/webhooks/deliveries/{id}/replay":{"post":{"tags":["Webhooks"],"summary":"Replay a webhook delivery","description":"Attempts the durably queued original event again without changing its event id, publishing content or charging credits. Generates a fresh signature. An active delivery lease returns 409 instead of starting an overlapping attempt. Post lifecycle notifications are persisted atomically with saved post state.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Delivery attempted"},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"description":"Webhook delivery not found"},"409":{"description":"Delivery already has an active attempt"}}}},"/v1/changelog":{"get":{"tags":["Platform"],"summary":"Read the public PostLake changelog","description":"Returns the same versioned release records shown at postlake.dev/changelog. No API key is required.","parameters":[{"name":"limit","in":"query","schema":{"type":"integer","minimum":1,"maximum":100,"default":20}}],"responses":{"200":{"description":"Release feed"}}}},"/v1/credentials":{"post":{"tags":["Credentials"],"summary":"Save credentials","description":"Save BYO platform app credentials for supported networks other than X. X always uses PostLake's shared app; customer-owned X app credentials are rejected.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"platform":{"$ref":"#/components/schemas/Platform"},"clientId":{"type":"string"},"clientSecret":{"type":"string"}},"required":["platform","clientId","clientSecret"]}}}},"responses":{"201":{"description":"Created","content":{"application/json":{"schema":{"type":"object","properties":{"credential":{"$ref":"#/components/schemas/Credential"}},"required":["credential"]}}}},"400":{"description":"Invalid platform or missing credentials"},"401":{"$ref":"#/components/responses/Unauthorized"}}},"get":{"tags":["Credentials"],"summary":"List credentials","description":"List BYO credentials","responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"credentials":{"type":"array","items":{"$ref":"#/components/schemas/Credential"}}},"required":["credentials"]}}}},"401":{"$ref":"#/components/responses/Unauthorized"}}}},"/v1/credentials/{platform}":{"delete":{"tags":["Credentials"],"summary":"Delete credentials","description":"Delete BYO credentials for a platform","parameters":[{"name":"platform","in":"path","required":true,"schema":{"$ref":"#/components/schemas/Platform"}}],"responses":{"204":{"description":"Deleted"},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"description":"Credentials not found"}}}},"/v1/connect-link":{"post":{"tags":["Connect"],"summary":"Mint connect link","description":"Mint a short-lived link to the hosted connect/manage page. Pass profile to scope the page to one tenant. Pass returnUrl to send the browser back to your app with connect_status after OAuth (the Upload-Post / Ayrshare JWT pattern). Pass platforms to show only those networks. Do not give end users POST /v1/app-link; that mints a 12-hour dashboard session.","requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"profile":{"type":"string","minLength":1,"description":"Scope the hosted page and the new connection to this profile username. Required for tenant-safe handoffs; workspace API keys still access all profiles."},"returnUrl":{"type":"string","format":"uri","description":"https callback URL without credentials or a fragment. http is allowed only for localhost. Outcome query parameters are unsigned display hints. Bind an expiring single-use state to your signed-in customer on your backend, then read their stored profile's accounts. Never authorize a tenant using the returned profile parameter."},"platforms":{"type":"array","minItems":1,"items":{"type":"string"},"description":"Optional non-empty allow-list enforced on connection and disconnection routes, not just page visibility. Omit for all available networks."}}}}}},"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"url":{"type":"string","format":"uri"},"expiresInSeconds":{"type":"integer"}},"required":["url","expiresInSeconds"]}}}},"400":{"description":"Invalid return URL, platform allow-list, or request body"},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"description":"The requested profile does not exist"}}}},"/v1/app-link":{"post":{"tags":["Connect"],"summary":"Mint app link","description":"Mint a session link to the full app dashboard","responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"url":{"type":"string","format":"uri"},"expiresInSeconds":{"type":"integer"}},"required":["url","expiresInSeconds"]}}}},"401":{"$ref":"#/components/responses/Unauthorized"}}}},"/v1/mcp":{"post":{"tags":["MCP"],"summary":"MCP Endpoint","description":"Model Context Protocol JSON-RPC endpoint. PREFER `POST /mcp`: it is the canonical URL and now accepts either an API key or an OAuth access token, so there is no longer a reason to choose between two paths. This one is kept working for configs already pointing at it. The same tool registry covers publishing, scheduling, validation, media, notifications, comments, direct messages, engagement, profile and channel management, and analytics. See https://docs.postlake.dev/mcp for the full toolset and authentication options.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"jsonrpc":{"type":"string","const":"2.0"},"method":{"type":"string"},"params":{"type":"object"},"id":{"oneOf":[{"type":"string"},{"type":"number"},{"type":"null"}]}},"required":["jsonrpc","method"]}}}},"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"jsonrpc":{"type":"string","const":"2.0"},"result":{"type":"object"},"error":{"type":"object","properties":{"code":{"type":"integer"},"message":{"type":"string"},"data":{"type":"object"}},"required":["code","message"]},"id":{"oneOf":[{"type":"string"},{"type":"number"},{"type":"null"}]}},"required":["jsonrpc"]}}}},"204":{"description":"Notification success"},"401":{"$ref":"#/components/responses/Unauthorized"}}}},"/openapi.json":{"get":{"summary":"OpenAPI Specification","description":"Returns this OpenAPI specification","responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object"}}}}}}}},"components":{"schemas":{"SocialActor":{"type":"object","properties":{"handle":{"type":"string"},"displayName":{"type":"string","nullable":true},"avatarUrl":{"type":"string","nullable":true},"id":{"type":"string","nullable":true,"description":"The network's own id, for follow/block/mute"}}},"ReadProblem":{"type":"object","description":"A network that could not be read for this request. Its presence is what lets you tell 'nothing happened' from 'we could not look'.","properties":{"account":{"type":"string"},"platform":{"type":"string"},"reason":{"type":"string"}}},"Notification":{"type":"object","properties":{"id":{"type":"string"},"platform":{"type":"string"},"account":{"type":"string"},"type":{"type":"string","enum":["like","reply","mention","follow","repost","quote","other"],"description":"Normalised kind. Unknown kinds arrive as 'other' so a new one cannot break a parser."},"platformType":{"type":"string","description":"The network's own word for it, unmapped"},"actor":{"$ref":"#/components/schemas/SocialActor"},"post":{"type":"object","nullable":true,"properties":{"uri":{"type":"string"},"url":{"type":"string","nullable":true},"text":{"type":"string","nullable":true}}},"text":{"type":"string","nullable":true},"createdAt":{"type":"string","format":"date-time"},"read":{"type":"boolean"}}},"Comment":{"type":"object","properties":{"id":{"type":"string"},"platform":{"type":"string"},"account":{"type":"string","description":"The connection (acc_…) this comment was read through. Pass it back when you reply, hide, like or delete."},"author":{"$ref":"#/components/schemas/SocialActor"},"text":{"type":"string"},"createdAt":{"type":"string","format":"date-time"},"url":{"type":"string","nullable":true},"replyCount":{"type":"integer"},"likeCount":{"type":"integer"},"hidden":{"type":"boolean","nullable":true,"description":"Whether this reply is currently hidden. null where the network does not say, which is not the same as false: an agent that reads null as 'not hidden' will keep trying to hide the same reply."},"replies":{"type":"array","items":{"$ref":"#/components/schemas/Comment"}}}},"DiscoveredPost":{"type":"object","description":"A post found by searching or browsing, rather than one you published. Deliberately not the Post shape: far less is known about someone else's post, and a shape full of nulls invites you to assume they are readable.","properties":{"id":{"type":"string"},"platform":{"type":"string"},"author":{"$ref":"#/components/schemas/SocialActor"},"text":{"type":"string"},"createdAt":{"type":"string","format":"date-time"},"url":{"type":"string","nullable":true},"mediaType":{"type":"string","enum":["text","image","video","other"],"description":"'other' keeps a new network media type from breaking a parser that switches on this."},"isReply":{"type":"boolean"},"isQuote":{"type":"boolean"},"authorHidden":{"type":"boolean","description":"True when the network REFUSES to name the author, as opposed to us failing to read it. Instagram hashtag results are the case: Meta rejects the username field on media returned by a tag search, so author.handle is necessarily empty. Treat an empty handle WITHOUT this flag as a bug; with it, as the network's rule."}}},"PublicProfile":{"type":"object","description":"Someone else's public profile.","properties":{"platform":{"type":"string"},"handle":{"type":"string"},"displayName":{"type":"string","nullable":true},"avatarUrl":{"type":"string","nullable":true},"bio":{"type":"string","nullable":true},"verified":{"type":"boolean"},"followerCount":{"type":"integer","nullable":true,"description":"null where the network does not publish it, which is not the same as zero."},"recent":{"type":"object","nullable":true,"description":"The last 7 days, where the network reports it. Each figure is null when unpublished, never 0.","properties":{"likes":{"type":"integer","nullable":true},"quotes":{"type":"integer","nullable":true},"reposts":{"type":"integer","nullable":true},"views":{"type":"integer","nullable":true}}},"id":{"type":"string","nullable":true}}},"Place":{"type":"object","description":"A place that can be tagged on a post. Ids are per-network and only mean anything to the network they came from, so the platform travels with them.","properties":{"id":{"type":"string"},"platform":{"type":"string"},"name":{"type":"string"},"address":{"type":"string","nullable":true},"city":{"type":"string","nullable":true},"country":{"type":"string","nullable":true},"latitude":{"type":"number","nullable":true},"longitude":{"type":"number","nullable":true}}},"Conversation":{"type":"object","description":"A direct-message thread. inboxAccount names the connected profile receiving messages and sending replies, so callers never infer it from account.","properties":{"id":{"type":"string"},"platform":{"type":"string"},"account":{"type":"string"},"inboxAccount":{"allOf":[{"$ref":"#/components/schemas/InboxAccountIdentity"}],"nullable":true},"participants":{"type":"array","items":{"$ref":"#/components/schemas/SocialActor"}},"lastMessage":{"type":"object","nullable":true,"properties":{"text":{"type":"string"},"createdAt":{"type":"string","format":"date-time"},"fromMe":{"type":"boolean"},"content":{"$ref":"#/components/schemas/MessageContent"}}},"unreadCount":{"type":"integer"}}},"InboxAccountIdentity":{"type":"object","description":"The connected profile that receives this conversation and sends its replies.","properties":{"id":{"type":"string"},"handle":{"type":"string"},"displayName":{"type":"string","nullable":true},"avatarUrl":{"type":"string","nullable":true}},"required":["id","handle","displayName","avatarUrl"]},"Message":{"type":"object","properties":{"id":{"type":"string"},"text":{"type":"string"},"createdAt":{"type":"string","format":"date-time"},"from":{"$ref":"#/components/schemas/SocialActor"},"fromMe":{"type":"boolean","description":"True when you sent it, so callers never compare handles"},"content":{"$ref":"#/components/schemas/MessageContent"}}},"MessageContent":{"type":"object","description":"A meaningful non-text direct-message payload. This is present for attachments, shared media, and message types the network does not expose in readable form. Do not treat an empty text field with this object as an empty message.","properties":{"kind":{"type":"string","enum":["attachment","shared_media","unsupported"]},"label":{"type":"string"},"url":{"type":"string","nullable":true,"format":"uri","description":"Provider URL when available. Null means the provider withheld a destination."}},"required":["kind","label","url"]},"Platform":{"type":"string","enum":["bluesky","threads","x","linkedin","instagram","tiktok","facebook","youtube","pinterest"]},"PlatformCapabilities":{"type":"object","description":"What one platform supports: limits, media rules, and every platformOptions field with its valid values.","properties":{"platform":{"$ref":"#/components/schemas/Platform"},"displayName":{"type":"string"},"connectionAvailability":{"type":"object","description":"Public rollout status and connection eligibility for the authenticated account. Capability support and rollout availability are separate. Buying credits or a plan does not grant rollout access.","properties":{"status":{"type":"string","enum":["public","limited"]},"reason":{"type":["string","null"],"enum":["provider_app_review","not_publicly_available",null]},"accountCanConnect":{"type":"boolean"},"purchaseUnlocksAccess":{"type":"boolean","const":false}},"required":["status","reason","accountCanConnect","purchaseUnlocksAccess"]},"maxChars":{"type":"integer"},"charsByPostType":{"type":"object","properties":{"video":{"type":"integer"},"image":{"type":"integer"}}},"title":{"type":"object","properties":{"maxChars":{"type":"integer"},"appliesTo":{"type":"string"}}},"media":{"type":"object","properties":{"required":{"type":"boolean"},"videoRequired":{"type":"boolean"},"maxImages":{"type":"integer"},"maxVideos":{"type":"integer"},"imageTypes":{"type":"array","items":{"type":"string"}},"videoTypes":{"type":"array","items":{"type":"string"}},"maxImageBytes":{"type":"integer"},"maxVideoBytes":{"type":"integer"},"maxVideoSeconds":{"type":"integer"}}},"postsPerDay":{"type":"integer"},"postsPerDayScope":{"type":"string","enum":["account","app"],"description":"account is that connected social user. app is the whole PostLake project (YouTube uploads)."},"asyncPublish":{"type":"boolean"},"options":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"type":{"type":"string","enum":["string","boolean","enum","number"]},"label":{"type":"string"},"values":{"type":"array","items":{"type":"string"}},"default":{},"appliesTo":{"type":"string"},"maxLength":{"type":"integer"},"description":{"type":"string"}},"required":["id","type","label"]}},"notes":{"type":"array","items":{"type":"string"}},"pendingReview":{"type":"object","additionalProperties":{"type":"string"},"description":"Capabilities that are built and tested but waiting on a platform permission. Keyed like the flags (reads.comments, messages). Absent or empty when nothing is waiting."},"firstComment":{"type":"boolean","description":"A comment can be posted under the post in the same call."},"deletePost":{"type":"boolean","description":"A published post can be retracted through the API."},"thread":{"type":"boolean","description":"A chain of posts can be published as one."},"replyToComment":{"type":"boolean"},"hideComments":{"type":"boolean","description":"A reply on your own post can be hidden and unhidden."},"editProfile":{"type":"boolean"},"messages":{"type":"boolean","description":"Direct messages can be read and sent."},"engages":{"type":"array","description":"Which actions POST /v1/engagements accepts on this network.","items":{"type":"string","enum":["like","unlike","repost","unrepost","follow","unfollow","block","unblock","mute","unmute"]}},"reads":{"type":"object","description":"Which of your own surfaces can be read.","properties":{"notifications":{"type":"boolean"},"comments":{"type":"boolean"},"followers":{"type":"boolean"},"following":{"type":"boolean"}}},"discovers":{"type":"object","description":"Which parts of the public network can be searched, so an agent can look before it speaks.","properties":{"posts":{"type":"boolean"},"profiles":{"type":"boolean"},"profilePosts":{"type":"boolean"},"places":{"type":"boolean"},"creators":{"type":"boolean","description":"Find creators through the network's creator marketplace."}}},"variants":{"type":"array","description":"Where a network can be connected in more than one way, the ways it offers. What a connection can do depends on HOW it was made, not only on which network it is: an Instagram account connected through Facebook can search, look people up and read insights, while the same account connected directly cannot. Absent when a network has only one way in.","items":{"type":"object","properties":{"id":{"type":"string","description":"Pass as `variant` when creating a connect link."},"label":{"type":"string","description":"What to call this choice to a person."},"requires":{"type":"array","items":{"type":"string"},"description":"What the person must already have for this way to work."},"then":{"type":"string","description":"What happens next when they choose it."},"summary":{"type":"string","description":"What this way of connecting can do, in one line."},"available":{"type":"array","items":{"type":"string"},"description":"Features available through this connection, suitable for a user-facing comparison."},"unavailable":{"type":"array","items":{"type":"string"},"description":"Features unavailable through this connection."},"conditions":{"type":"array","items":{"type":"string"},"description":"Optional account setup the user can complete to enable specific features."},"only":{"type":"array","items":{"type":"string"},"description":"Capabilities this way unlocks that the others do not."},"note":{"type":"string"}},"required":["id","label"]}}},"required":["platform","displayName","maxChars","media","options"]},"PostState":{"type":"string","enum":["draft","awaiting_approval","rejected","queued","scheduled","processing","partial","published","failed"]},"TargetState":{"type":"string","enum":["queued","scheduled","processing","published","failed"]},"WebhookEventType":{"type":"string","enum":["post.approved","post.rejected","post.published","post.failed","post.partial","post.processing","account.connected","account.disconnected","message.received","comment.received","mention.received","webhook.test"],"description":"Which events an endpoint wants. `message.received` currently fires for Facebook and Instagram, where Meta pushes new messages to PostLake. Its payload carries `replyBy`, the moment Meta stops accepting a normal reply. Poll list conversations for inbox-capable networks that do not provide PostLake with inbound message webhooks."},"Metrics":{"type":"object","description":"Numeric counters retain compatibility. Optional reported lists fields actually returned by this observation, including measured zero. Missing reported means legacy availability is unknown. Do not interpret an omitted field as measured zero.","properties":{"reported":{"type":"array","items":{"type":"string","enum":["views","impressions","reach","likes","comments","shares","saves","clicks"]},"description":"Counters actually returned in this reading. An empty array means none. Omitted for legacy evidence with unknown field coverage."},"impressions":{"type":"integer","description":"Times the content was DISPLAYED."},"views":{"type":"integer","description":"Times it was actually WATCHED or opened. A subset of impressions, not a synonym, and networks that report only one of the two leave the other at 0 rather than filing a watch as a display."},"reach":{"type":"integer"},"likes":{"type":"integer"},"comments":{"type":"integer"},"shares":{"type":"integer"},"saves":{"type":"integer"},"clicks":{"type":"integer"},"followers":{"type":"integer"},"extras":{"type":"object","additionalProperties":{"type":"number"},"description":"Metrics this network reports that have no cross-platform equivalent, keyed by the network's OWN metric name. Pinterest returns a video retention curve (VIDEO_START, VIDEO_10S_VIEW, QUARTILE_95_PERCENT_VIEW) plus PROFILE_VISIT and USER_FOLLOW; Facebook returns its own video and Page numbers. Keys are native and are never normalised, so it is always clear which network a figure came from and what it means there. Rates and averages are deliberately excluded: they cannot be summed across posts, and every one of them is derivable from the counts. Absent when the network reports nothing extra."}},"required":["impressions","reach","likes","comments","shares","saves","clicks","followers"]},"NormalisedError":{"type":"object","description":"`type` is the coarse category you route on and is deliberately small, so it cannot say WHICH input was wrong. `code`, `fix`, `docs` and `param` are what turn a rejection into something a caller can act on rather than guess at. They are optional only in the sense that not every error has all four.","properties":{"type":{"type":"string","description":"Coarse category to route on, e.g. rate_limited, auth_expired, platform_rejected."},"message":{"type":"string"},"platform":{"oneOf":[{"$ref":"#/components/schemas/Platform"},{"type":"null"}]},"retryable":{"type":"boolean"},"code":{"type":"string","description":"Stable, granular handle to branch on, e.g. text_too_long. Also the anchor in the error reference."},"fix":{"type":"string","description":"The next action, in plain words. Safe to show a person as-is."},"docs":{"type":"string","format":"uri","description":"Where the rule is written down, so it is learned once rather than rediscovered."},"param":{"type":"string","description":"The request field at fault, so a form can mark the right input."}},"required":["type","message","retryable"]},"Profile":{"type":"object","properties":{"id":{"type":"string"},"username":{"type":"string","description":"The identifier agents put in `profile:`. Slugified."},"name":{"type":"string","description":"What the person typed, for display. Absent on profiles created before this existed."},"createdAt":{"type":"string","format":"date-time"}},"required":["id","username","createdAt"]},"EmailPreferences":{"type":"object","properties":{"digest":{"type":"boolean","description":"False when digestFrequency is off. Kept so older clients still work."},"digestFrequency":{"type":"string","enum":["off","weekly","daily"]},"onboarding":{"type":"boolean","description":"Whether optional product emails may send."}},"required":["digest","digestFrequency","onboarding"]},"SocialAccount":{"type":"object","properties":{"id":{"type":"string"},"platform":{"$ref":"#/components/schemas/Platform"},"handle":{"type":"string"},"profileId":{"oneOf":[{"type":"string"},{"type":"null"}]},"status":{"type":"string"},"avatarUrl":{"oneOf":[{"type":"string"},{"type":"null"}]},"connectVariant":{"type":"string","description":"Which door this account was connected through, for networks that have more than one. Instagram is the case: `instagram` means Instagram Login, `facebook` means it was connected through a Facebook Page. The two reach genuinely different feature sets, so this decides what the account can do. Absent on networks with a single way in."},"accountType":{"oneOf":[{"type":"string","enum":["BUSINESS","MEDIA_CREATOR","PERSONAL"]},{"type":"null"}],"description":"Instagram account type when known. Stories support both login routes, subject to Meta account eligibility. Facebook Login Stories require a Business account. Null means the type is unknown; Meta enforces eligibility during publishing."},"discovers":{"type":"object","description":"What this ACCOUNT can search, as opposed to GET /v1/platforms/{platform}, which can only speak for the network as a whole. An Instagram connected by Instagram Login cannot search creators or hashtags however the platform-level flags read, so check here before calling a discovery endpoint rather than learning it from an error.","properties":{"posts":{"type":"boolean"},"profiles":{"type":"boolean"},"profilePosts":{"type":"boolean"},"places":{"type":"boolean"},"creators":{"type":"boolean","description":"True only when the connection can reach its creator-discovery product. A true still leaves the network's own onboarding to be done: Meta refuses the search with `creator_marketplace_not_onboarded` until the brand has accepted the relevant terms."}},"required":["posts","profiles","profilePosts","places","creators"]},"requires":{"oneOf":[{"type":"string"},{"type":"null"}],"description":"What this network needs before it will publish, in one sentence (media requirements, title and caption limits). Read it before composing: TikTok, Instagram, YouTube and Pinterest all refuse text-only posts. Null when the network accepts anything."}},"required":["id","platform","handle","profileId","status","discovers"]},"PostTarget":{"type":"object","properties":{"removal":{"type":"object","description":"Present after accepted network removal on a partial result. Retry skips accepted destinations; verified separately confirms absence.","properties":{"accepted":{"type":"boolean","enum":[true]},"verified":{"type":"boolean"},"at":{"type":"string","format":"date-time"}},"required":["accepted","verified","at"]},"account":{"type":"string"},"platform":{"$ref":"#/components/schemas/Platform"},"state":{"$ref":"#/components/schemas/TargetState"},"profile":{"type":"string","description":"Username of the profile this channel belongs to. Resolved when the post is read, so it follows the channel if it moves. Absent once the channel is disconnected."},"channelName":{"type":"string","description":"The channel's handle at the time of reading, not the one frozen at publish. Absent once the channel is disconnected."},"connection":{"type":"object","description":"The native account or destination identity captured when this target was scheduled. It is used only to repair a disconnected channel safely.","properties":{"platformAccountId":{"type":"string"},"destinationId":{"type":"string"},"destinationName":{"type":"string"},"handle":{"type":"string"}}},"remoteId":{"oneOf":[{"type":"string"},{"type":"null"}]},"url":{"oneOf":[{"type":"string"},{"type":"null"}]},"publishedAt":{"oneOf":[{"type":"string","format":"date-time"},{"type":"null"}]},"permalinkPending":{"type":"boolean","description":"True when publication succeeded but an exact public permalink is still being resolved."},"error":{"oneOf":[{"$ref":"#/components/schemas/NormalisedError"},{"type":"null"}]}},"required":["account","platform","state","remoteId","url","publishedAt","error"]},"Post":{"type":"object","properties":{"id":{"type":"string"},"sourceDraftId":{"type":"string","description":"Immutable original draft ID. New approvals preserve this as id; legacy audited approvals may resolve an older ID to a different canonical id."},"snapshot":{"type":"string","description":"Computed full-state approval fence returned by GET. Includes media, options and metadata."},"state":{"$ref":"#/components/schemas/PostState"},"createdAt":{"type":"string","format":"date-time"},"clientId":{"type":"string","description":"Stable machine principal that created the post. Absent for human-created dashboard posts."},"surface":{"type":"string","enum":["dashboard","api","agent"],"description":"Where the post was created, independent of the principal identity."},"approval":{"type":"object","description":"Present for approval-gated agent work, including the owner's final decision when one has been made.","properties":{"requestedAt":{"type":"string","format":"date-time"},"decision":{"type":"string","enum":["approved","rejected"]},"decidedAt":{"type":"string","format":"date-time"},"reason":{"type":"string"}},"required":["requestedAt"]},"scheduledAt":{"oneOf":[{"type":"string","format":"date-time"},{"type":"null"}]},"timezone":{"type":"string","description":"IANA timezone the caller scheduled in, when they named one."},"scheduledAtLocal":{"type":"string","description":"Wall-clock time in timezone, computed on read. Not stored."},"text":{"type":"string"},"campaign":{"type":"string"},"assetMetadata":{"type":"object","additionalProperties":{"type":"string"}},"media":{"type":"array","items":{"type":"string"}},"warnings":{"type":"array","items":{"type":"string"},"description":"Advisory notes from create or edit, for example PNG converted to JPEG for TikTok."},"platformOptions":{"oneOf":[{"type":"object","properties":{"pinterest":{"type":"object","properties":{"boardId":{"type":"string"},"link":{"type":"string"},"altText":{"type":"string"}}}}},{"type":"null"}]},"targets":{"type":"array","items":{"$ref":"#/components/schemas/PostTarget"}}},"required":["id","state","createdAt","scheduledAt","text","media","platformOptions","targets"]},"PostDeletionPreview":{"type":"object","required":["id","kind","canApply","snapshot","targets","effects","warning"],"properties":{"id":{"type":"string"},"kind":{"type":"string","enum":["cancel_schedule","discard_draft","reject_approval","remove_from_networks","remove_record"]},"canApply":{"type":"boolean"},"snapshot":{"type":"string"},"warning":{"type":"string"},"targets":{"type":"array","items":{"type":"object","required":["account","platform","canRemove","alreadyAccepted","url","reason"],"properties":{"account":{"type":"string"},"platform":{"$ref":"#/components/schemas/Platform"},"canRemove":{"type":"boolean"},"alreadyAccepted":{"type":"boolean"},"url":{"type":"string","nullable":true},"reason":{"type":"string"}}}},"effects":{"type":"object","properties":{"deletesUploads":{"type":"boolean","enum":[false]},"refundsPublishingCredits":{"type":"boolean","enum":[false]}},"required":["deletesUploads","refundsPublishingCredits"]}}},"MediaDeletionPreview":{"type":"object","required":["id","state","canDelete","truncated","references","snapshot","effects","warning"],"properties":{"id":{"type":"string"},"state":{"type":"string","enum":["active","deleting","deleted"]},"canDelete":{"type":"boolean"},"truncated":{"type":"boolean"},"snapshot":{"type":"string"},"warning":{"type":"string"},"references":{"type":"array","maxItems":200,"items":{"type":"object","properties":{"id":{"type":"string"},"state":{"type":"string"},"text":{"type":"string","maxLength":120},"blocking":{"type":"boolean"}},"required":["id","state","text","blocking"]}},"effects":{"type":"object","properties":{"deletesSource":{"type":"boolean","enum":[true]},"deletesPreviews":{"type":"boolean","enum":[true]},"deletesSocialPosts":{"type":"boolean","enum":[false]},"cancelsSchedules":{"type":"boolean","enum":[false]},"refundsPublishingCredits":{"type":"boolean","enum":[false]}},"required":["deletesSource","deletesPreviews","deletesSocialPosts","cancelsSchedules","refundsPublishingCredits"]}}},"MediaAsset":{"type":"object","properties":{"deletionState":{"type":"string","enum":["deleting"],"description":"Cleanup is incomplete and can be retried. This upload cannot be used in new posts."},"retention":{"type":"object","description":"Minimum 90 days from upload on every plan while the account is open. Scheduled references remain protected until terminal state plus 30 days if later. No automatic expiry currently. Account deletion, verified erasure requests and legal/security removal are exceptions. Source URLs are public, not private storage.","properties":{"version":{"type":"string"},"minimumDays":{"type":"integer"},"minimumUntil":{"type":"string","format":"date-time"},"scheduledProtection":{"type":"string"},"automaticExpiry":{"type":"boolean"},"exceptions":{"type":"array","items":{"type":"string"}},"scope":{"type":"string"},"accountRequirement":{"type":"string"},"publicUrls":{"type":"boolean"},"postRemovalDeletesMedia":{"type":"boolean"},"backupService":{"type":"boolean"}}},"id":{"type":"string"},"url":{"type":"string","format":"uri"},"contentType":{"type":"string"},"size":{"type":"integer"},"createdAt":{"type":"string","format":"date-time"},"width":{"type":"integer","description":"Pixel width. Images only, and absent when the header could not be read."},"height":{"type":"integer","description":"Pixel height. Images only, and absent when the header could not be read."}},"required":["id","url","contentType","size","createdAt"]},"WebhookEndpoint":{"type":"object","properties":{"id":{"type":"string"},"url":{"type":"string","format":"uri"},"secret":{"oneOf":[{"type":"string"},{"type":"null"}]},"events":{"type":"array","items":{"$ref":"#/components/schemas/WebhookEventType"}},"createdAt":{"type":"string","format":"date-time"}},"required":["id","url","secret","events","createdAt"]},"AuditEvent":{"type":"object","properties":{"id":{"type":"string"},"action":{"type":"string"},"ip":{"type":"string"},"userAgent":{"type":"string"},"createdAt":{"type":"string","format":"date-time"}},"required":["id","action","ip","userAgent","createdAt"]},"Credential":{"type":"object","properties":{"platform":{"$ref":"#/components/schemas/Platform"},"clientId":{"type":"string"},"createdAt":{"type":"string","format":"date-time"}},"required":["platform","clientId","createdAt"]},"ErrorResponse":{"type":"object","properties":{"error":{"$ref":"#/components/schemas/NormalisedError"}},"required":["error"]}},"responses":{"Unauthorized":{"description":"Unauthorized","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":{"type":"unauthorized","message":"missing or invalid api key","retryable":false}}}}},"RateLimited":{"description":"Rate Limited","headers":{"Retry-After":{"schema":{"type":"integer"},"description":"Seconds to wait before retrying"}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":{"type":"rate_limited","message":"Rate limit exceeded","retryable":true}}}}}}}}