{"openapi":"3.1.0","info":{"title":"Planify API","version":"1.0.0","summary":"Schedule and publish social posts from anything that can make an HTTP request.","description":"Planify schedules and publishes to Twitter/X, Facebook, Instagram, Bluesky, Pinterest, Threads,\nLinkedIn, Mastodon and YouTube. This API exists so software — an AI assistant, a workflow tool,\nyour own script — can do that without a browser.\n\n## Authentication\n\nSend an API key as a bearer token:\n\n```\nAuthorization: Bearer plfy_live_...\n```\n\nCreate one in the Planify dashboard under Settings → API. Keys are shown once and stored only as a\nhash, so a lost key must be replaced rather than recovered. Keys cannot create other keys.\n\n## The shortest useful path\n\n1. `GET /v1/channels` — find a channel\n2. `GET /v1/channels/{id}/settings` — read its limits, rules and settings schema\n3. `POST /v1/channels/{id}/trigger` — fetch anything unguessable, e.g. a Pinterest board\n4. `POST /v1/media/from-url` — get an image in\n5. `POST /v1/posts` — draft, schedule, or publish\n\nStep 2 is the one worth not skipping. It reports the character limit, media count, and the rules\nthat decide whether a publish succeeds — per channel, since a Facebook Page and a profile differ.\n\n## Errors\n\nEvery failure returns `{ \"error\": { \"code\", \"message\", \"hint\" } }`. Branch on `code`; it is stable.\n`hint` usually names the endpoint that answers the question.\n\n## Rate limits\n\n- **read**: 120 requests/minute\n- **write**: 60 requests/minute\n- **publish**: 20 requests/minute\n\nCounted per key. Every response carries `X-RateLimit-Limit`, `-Remaining` and `-Reset`; a 429 also\ncarries `Retry-After`. Publishing is tightest because it is the one action that cannot be undone.\n\n## Idempotency\n\nSend `Idempotency-Key` on `POST /v1/posts`. Repeating a request with the same key returns the\noriginal posts rather than creating more — which matters more for machines than people, because\nmachines retry automatically.","contact":{"name":"Planify","url":"https://planifyapps.com/contact"},"termsOfService":"https://planifyapps.com/terms"},"servers":[{"url":"https://planifyapps.com/api/v1","description":"Production"}],"tags":[{"name":"Account","description":"Who you are and what your plan allows."},{"name":"Channels","description":"Connected social accounts, their rules, and their lookups."},{"name":"Media","description":"Getting images and video in."},{"name":"Posts","description":"Creating, scheduling and publishing."}],"paths":{"/me":{"get":{"operationId":"getMe","summary":"Who this credential is, and what it may do","description":"The first call to make. Reports the plan, the scopes the key carries, and quota remaining.\n\nDeliberately answers even when the plan grants no app access — it is how a caller discovers it is paywalled, rather than inferring that from a 402 somewhere less obvious.\n\n**Scopes:** none beyond a valid credential.\n\n**Rate class:** read (120/min)","tags":["Account"],"responses":{"200":{"description":"Success","headers":{"X-RateLimit-Limit":{"schema":{"type":"integer"},"description":"Ceiling for this rate class."},"X-RateLimit-Remaining":{"schema":{"type":"integer"},"description":"Requests left this minute."},"X-RateLimit-Reset":{"schema":{"type":"integer"},"description":"Unix seconds when the counter resets."}},"content":{"application/json":{"schema":{"type":"object","properties":{"user_id":{"type":"string","format":"uuid"},"auth":{"type":"object","properties":{"via":{"type":"string","enum":["api_key","cookie"]},"key_id":{"type":["string","null"]},"scopes":{"type":"array","items":{"type":"string","enum":["channels:read","posts:read","posts:write","posts:publish","media:write","analytics:read"]}}}},"plan":{"type":"object","properties":{"slug":{"type":"string"},"tier":{"type":"string","enum":["free","starter","pro","studio"]},"status":{"type":"string"},"app_access":{"type":"boolean"},"trial":{"type":"object","properties":{"is_trialing":{"type":"boolean"},"ends_at":{"type":["string","null"],"format":"date-time"}}}}},"platforms":{"type":"array","items":{"type":"string"},"description":"Platforms this plan may publish to. A channel outside this list is read-only."},"features":{"type":"object","properties":{"advanced_analytics":{"type":"boolean"},"analytics_export":{"type":"boolean"},"ai_assistant":{"type":"boolean"}}},"usage":{"type":"object","description":"A null limit means unlimited, and so does a null remaining.","properties":{"channels":{"$ref":"#/components/schemas/Quota"},"scheduled_posts":{"$ref":"#/components/schemas/Quota"},"ai_requests":{"$ref":"#/components/schemas/Quota"},"resets_at":{"type":"string","format":"date-time"}}},"rate_limits":{"type":"object","description":"Per-minute ceilings by operation weight, so a client can pace itself.","properties":{"read":{"type":"integer","default":120},"write":{"type":"integer","default":60},"publish":{"type":"integer","default":20}}}}}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"429":{"$ref":"#/components/responses/RateLimited"},"500":{"$ref":"#/components/responses/InternalError"},"503":{"$ref":"#/components/responses/NotConfigured"}},"security":[{"ApiKeyAuth":[]}]}},"/channels":{"get":{"operationId":"listChannels","summary":"List connected channels","description":"Channels the credential can reach. \"Channel\" rather than \"account\" because that word already means the Planify user, the social login, and the individual Facebook Page.\n\nChannels the plan excludes are returned with `available: false` rather than hidden — omitting them would be indistinguishable from the account never having been connected.\n\n**Scopes:** `channels:read`\n\n**Rate class:** read (120/min)","tags":["Channels"],"responses":{"200":{"description":"Success","headers":{"X-RateLimit-Limit":{"schema":{"type":"integer"},"description":"Ceiling for this rate class."},"X-RateLimit-Remaining":{"schema":{"type":"integer"},"description":"Requests left this minute."},"X-RateLimit-Reset":{"schema":{"type":"integer"},"description":"Unix seconds when the counter resets."}},"content":{"application/json":{"schema":{"type":"object","properties":{"channels":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"platform":{"type":"string","enum":["twitter","facebook","instagram","threads","bluesky","pinterest","mastodon","linkedin","youtube"]},"username":{"type":["string","null"]},"display_name":{"type":["string","null"]},"avatar_url":{"type":["string","null"]},"connected_at":{"type":"string","format":"date-time"},"available":{"type":"boolean","description":"False when the plan excludes this platform. The channel is still listed — absent and not-permitted are different facts."},"unavailable_reason":{"type":"string"},"tools":{"type":"array","items":{"type":"string"},"description":"Lookups this channel supports. Details at GET /v1/channels/{id}/settings."}}}},"summary":{"type":"object","properties":{"total":{"type":"integer"},"available":{"type":"integer"}}}}}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"429":{"$ref":"#/components/responses/RateLimited"},"500":{"$ref":"#/components/responses/InternalError"},"503":{"$ref":"#/components/responses/NotConfigured"}},"security":[{"ApiKeyAuth":[]}]}},"/channels/{id}/settings":{"get":{"operationId":"getChannelSettings","summary":"What this channel will accept — the endpoint to read before writing a post","description":"Character and media limits, the hard rules that decide whether a publish succeeds, a JSON Schema for this platform's slice of `platform_settings`, and the lookups this channel supports.\n\nReading this first is the difference between a post that publishes and one that discovers each rule by being rejected. The settings schema is generated from the types the publishing code actually reads, so it cannot go stale.\n\n**Scopes:** `channels:read`\n\n**Rate class:** read (120/min)","tags":["Channels"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"},"description":"Channel id from GET /v1/channels."}],"responses":{"200":{"description":"Success","headers":{"X-RateLimit-Limit":{"schema":{"type":"integer"},"description":"Ceiling for this rate class."},"X-RateLimit-Remaining":{"schema":{"type":"integer"},"description":"Requests left this minute."},"X-RateLimit-Reset":{"schema":{"type":"integer"},"description":"Unix seconds when the counter resets."}},"content":{"application/json":{"schema":{"type":"object","description":"Everything needed to write a post this channel will accept.","properties":{"id":{"type":"string","format":"uuid"},"platform":{"type":"string"},"username":{"type":["string","null"]},"display_name":{"type":["string","null"]},"available":{"type":"boolean"},"unavailable_reason":{"type":"string"},"limits":{"type":"object","properties":{"max_characters":{"type":"integer"},"max_media":{"type":"integer"},"requires_media":{"type":"boolean","description":"True where a post with no attachment is rejected."},"max_files_per_post":{"type":"integer","default":10},"max_file_size_bytes":{"type":"integer","default":104857600},"allowed_mime_types":{"type":"array","items":{"type":"string"}}}},"threading":{"type":"object","properties":{"supported":{"type":"boolean"},"enabled_by":{"type":"string","description":"Which setting turns threading on."}}},"rules":{"type":"array","items":{"type":"string"},"description":"Hard requirements first, then style guidance. Prose, written to be read by a model."},"settings_schema":{"description":"JSON Schema for this platform's slice of platform_settings.","oneOf":[{"type":"object"},{"type":"null"}]},"tools":{"type":"array","items":{"type":"object","properties":{"method_name":{"type":"string"},"description":{"type":"string"},"fills":{"type":"string","description":"Which post setting the result fills in."},"input":{"type":"object","description":"JSON Schema for the data argument."}}}}}}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"404":{"$ref":"#/components/responses/NotFound"},"429":{"$ref":"#/components/responses/RateLimited"},"500":{"$ref":"#/components/responses/InternalError"},"503":{"$ref":"#/components/responses/NotConfigured"}},"security":[{"ApiKeyAuth":[]}]}},"/channels/{id}/trigger":{"post":{"operationId":"runChannelTrigger","summary":"Run a per-channel lookup","description":"Some platforms need a value that cannot be guessed. Pinterest rejects a pin without `board_id`, and that id is opaque — so without this an agent cannot post to Pinterest at all.\n\nWhich lookups a channel supports is advertised as `tools` by GET /v1/channels/{id}/settings, so they are discovered rather than hardcoded. POST rather than GET because the arguments are structured and some lookups hit a rate-limited upstream API.\n\n**Scopes:** `channels:read`\n\n**Rate class:** read (120/min)","tags":["Channels"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"},"description":"Channel id."}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$schema":"https://json-schema.org/draft/2020-12/schema","type":"object","properties":{"method_name":{"description":"Which lookup to run. Listed as tools[].method_name by GET /v1/channels/:id/settings.","type":"string","minLength":1},"methodName":{"type":"string","minLength":1},"data":{"description":"Arguments for the lookup, matching that tool's input schema.","default":{},"type":"object","propertyNames":{"type":"string"},"additionalProperties":{}}}}}}},"responses":{"200":{"description":"Success","headers":{"X-RateLimit-Limit":{"schema":{"type":"integer"},"description":"Ceiling for this rate class."},"X-RateLimit-Remaining":{"schema":{"type":"integer"},"description":"Requests left this minute."},"X-RateLimit-Reset":{"schema":{"type":"integer"},"description":"Unix seconds when the counter resets."}},"content":{"application/json":{"schema":{"type":"object","description":"Shape depends on the lookup. Always echoes method_name.","properties":{"method_name":{"type":"string"}},"additionalProperties":true}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"404":{"$ref":"#/components/responses/NotFound"},"429":{"$ref":"#/components/responses/RateLimited"},"500":{"$ref":"#/components/responses/InternalError"},"502":{"$ref":"#/components/responses/PlatformError"},"503":{"$ref":"#/components/responses/NotConfigured"}},"security":[{"ApiKeyAuth":[]}]}},"/channels/{id}/slot":{"get":{"operationId":"getNextSlot","summary":"Next free posting slot for a channel","description":"An agent told to \"post this sometime good\" has no way to pick a time. The user has usually already answered that in the dashboard as a weekly schedule; this is how that answer reaches the agent.\n\nSlots already taken by a scheduled post are skipped, so filling a week does not stack everything on one time. Returns `slot: null` with a `reason` when the channel has no schedule — that is not an error.\n\n**Scopes:** `channels:read`\n\n**Rate class:** read (120/min)","tags":["Channels"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"},"description":"Channel id."},{"name":"count","in":"query","required":false,"schema":{"type":"integer","minimum":1,"maximum":20,"default":1},"description":"How many slots to return."},{"name":"after","in":"query","required":false,"schema":{"type":"string","format":"date-time"},"description":"Search from a later point than now."}],"responses":{"200":{"description":"Success","headers":{"X-RateLimit-Limit":{"schema":{"type":"integer"},"description":"Ceiling for this rate class."},"X-RateLimit-Remaining":{"schema":{"type":"integer"},"description":"Requests left this minute."},"X-RateLimit-Reset":{"schema":{"type":"integer"},"description":"Unix seconds when the counter resets."}},"content":{"application/json":{"schema":{"type":"object","properties":{"channel_id":{"type":"string","format":"uuid"},"platform":{"type":"string"},"schedule":{"description":"The weekly schedule these came from, or null when none is set.","oneOf":[{"type":"object","properties":{"timezone":{"type":"string"},"slots":{"type":"array","items":{"type":"object","properties":{"day":{"type":"string","enum":["mon","tue","wed","thu","fri","sat","sun"]},"time":{"type":"string","pattern":"^([01]\\d|2[0-3]):[0-5]\\d$"}}}}}},{"type":"null"}]},"slots":{"type":"array","items":{"type":"string","format":"date-time"},"description":"Ascending, UTC."},"slot":{"type":["string","null"],"format":"date-time","description":"First of slots — what most callers want."},"reason":{"type":"string","description":"Present when there are no slots to give."},"note":{"type":"string","description":"Present when fewer slots were free than requested."}}}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"404":{"$ref":"#/components/responses/NotFound"},"429":{"$ref":"#/components/responses/RateLimited"},"500":{"$ref":"#/components/responses/InternalError"},"503":{"$ref":"#/components/responses/NotConfigured"}},"security":[{"ApiKeyAuth":[]}]}},"/media":{"post":{"operationId":"uploadMedia","summary":"Upload one or more files","description":"multipart/form-data with one or more `file` parts. Multipart rather than base64 JSON because a 100MB video base64-encodes to ~133MB of request body, and every HTTP client already knows how to do this.\n\nReturns public URLs to pass as `media_urls` when creating a post.\n\n**Scopes:** `media:write`\n\n**Rate class:** write (60/min)\n\n**Requires an active subscription** (402 otherwise).","tags":["Media"],"requestBody":{"required":true,"content":{"multipart/form-data":{"schema":{"type":"object","properties":{"file":{"type":"array","items":{"type":"string","format":"binary"},"description":"Up to 10 files, each at most 100MB."}}}}}},"responses":{"200":{"description":"Success","headers":{"X-RateLimit-Limit":{"schema":{"type":"integer"},"description":"Ceiling for this rate class."},"X-RateLimit-Remaining":{"schema":{"type":"integer"},"description":"Requests left this minute."},"X-RateLimit-Reset":{"schema":{"type":"integer"},"description":"Unix seconds when the counter resets."}},"content":{"application/json":{"schema":{"type":"object","properties":{"media":{"type":"array","items":{"type":"object","properties":{"url":{"type":"string","format":"uri"},"content_type":{"type":"string"},"bytes":{"type":"integer"}}}},"media_urls":{"type":"array","items":{"type":"string","format":"uri"},"description":"Exactly what media_urls on POST /v1/posts expects, so one call feeds the next."}}}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"402":{"$ref":"#/components/responses/PaymentRequired"},"403":{"$ref":"#/components/responses/Forbidden"},"413":{"$ref":"#/components/responses/FileTooLarge"},"415":{"$ref":"#/components/responses/UnsupportedMediaType"},"429":{"$ref":"#/components/responses/RateLimited"},"500":{"$ref":"#/components/responses/InternalError"},"503":{"$ref":"#/components/responses/NotConfigured"}},"security":[{"ApiKeyAuth":[]}]}},"/media/from-url":{"post":{"operationId":"ingestMediaFromUrl","summary":"Have the server download media from a URL","description":"The common agent shape: an image was just generated somewhere and exists only as a URL, so making the agent download and re-upload it is pure round trip.\n\nEvery URL is resolved and checked before any request leaves the server, and again on each redirect hop — private, loopback, link-local and cloud-metadata addresses are refused.\n\n**Scopes:** `media:write`\n\n**Rate class:** write (60/min)\n\n**Requires an active subscription** (402 otherwise).","tags":["Media"],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$schema":"https://json-schema.org/draft/2020-12/schema","type":"object","properties":{"url":{"description":"A single publicly reachable URL to ingest.","type":"string"},"urls":{"description":"Several URLs to ingest in one call.","type":"array","items":{"type":"string"}}}}}}},"responses":{"200":{"description":"Success","headers":{"X-RateLimit-Limit":{"schema":{"type":"integer"},"description":"Ceiling for this rate class."},"X-RateLimit-Remaining":{"schema":{"type":"integer"},"description":"Requests left this minute."},"X-RateLimit-Reset":{"schema":{"type":"integer"},"description":"Unix seconds when the counter resets."}},"content":{"application/json":{"schema":{"type":"object","properties":{"media":{"type":"array","items":{"type":"object","properties":{"url":{"type":"string","format":"uri"},"content_type":{"type":"string"},"bytes":{"type":"integer"}}}},"media_urls":{"type":"array","items":{"type":"string","format":"uri"},"description":"Exactly what media_urls on POST /v1/posts expects, so one call feeds the next."}}}}}},"400":{"$ref":"#/components/responses/UrlNotAllowed"},"401":{"$ref":"#/components/responses/Unauthorized"},"402":{"$ref":"#/components/responses/PaymentRequired"},"403":{"$ref":"#/components/responses/Forbidden"},"413":{"$ref":"#/components/responses/FileTooLarge"},"415":{"$ref":"#/components/responses/UnsupportedMediaType"},"429":{"$ref":"#/components/responses/RateLimited"},"500":{"$ref":"#/components/responses/InternalError"},"502":{"$ref":"#/components/responses/FetchFailed"},"503":{"$ref":"#/components/responses/NotConfigured"}},"security":[{"ApiKeyAuth":[]}]}},"/posts":{"post":{"operationId":"createPost","summary":"Create a post — draft, scheduled, or published now","description":"`content` as a string fans the same copy to every channel in `channel_ids`. As an array of `{ channel_id, content, … }` it gives per-channel copy, which is what you want when the same idea needs 280 characters on Twitter and 2,200 on Instagram.\n\n`type: \"now\"` publishes immediately and requires the **posts:publish** scope, which keys do not carry unless asked for — so a leaked read-write key cannot post to someone's audience.\n\nSend an `Idempotency-Key` header wherever a retry is possible. Repeating a request with the same key returns the original posts instead of creating more, and sets `idempotent_replay: true`.\n\n**Scopes:** `posts:write`\n\n**Rate class:** write (60/min)\n\n**Requires an active subscription** (402 otherwise).","tags":["Posts"],"parameters":[{"name":"Idempotency-Key","in":"header","required":false,"schema":{"type":"string","maxLength":200},"description":"Makes the call replay-safe. Send one wherever a retry is possible."}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$schema":"https://json-schema.org/draft/2020-12/schema","type":"object","properties":{"type":{"description":"draft saves it, schedule queues it for scheduled_at, now publishes immediately (requires the posts:publish scope).","default":"draft","type":"string","enum":["draft","schedule","now"]},"content":{"description":"A string fans the same copy to every channel in channel_ids. An array gives per-channel copy.","anyOf":[{"type":"string"},{"minItems":1,"type":"array","items":{"type":"object","properties":{"channel_id":{"description":"Channel this copy is for.","type":"string","minLength":1},"content":{"description":"Post body for this channel only.","type":"string"},"media_urls":{"description":"Public URLs returned by POST /v1/media or /v1/media/from-url.","maxItems":10,"type":"array","items":{"type":"string","format":"uri"}},"settings":{"description":"This channel's platform settings, unwrapped — e.g. { board_id }.","type":"object","propertyNames":{"type":"string"},"additionalProperties":{}},"thread":{"description":"Post as a thread. Part one becomes the body.","type":"array","items":{"type":"string","minLength":1}}},"required":["channel_id","content"]}}]},"channel_ids":{"description":"Required when content is a string; ignored when it is an array.","type":"array","items":{"type":"string","minLength":1}},"media_urls":{"description":"Public URLs returned by POST /v1/media or /v1/media/from-url.","maxItems":10,"type":"array","items":{"type":"string","format":"uri"}},"scheduled_at":{"description":"UTC ISO-8601. Required when type is \"schedule\".","anyOf":[{"type":"string","format":"date-time","pattern":"^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$"},{"type":"null"}]},"platform_settings":{"description":"Keyed by platform slug: { pinterest: { board_id } }. See GET /v1/channels/:id/settings.","type":"object","propertyNames":{"type":"string"},"additionalProperties":{}},"thread":{"description":"Post as a thread on platforms that support it. Part one becomes the body.","type":"array","items":{"type":"string","minLength":1}},"idempotency_key":{"description":"In-body equivalent of the Idempotency-Key header; the header wins.","type":"string","minLength":1,"maxLength":200}},"required":["content"]}}}},"responses":{"200":{"description":"Success","headers":{"X-RateLimit-Limit":{"schema":{"type":"integer"},"description":"Ceiling for this rate class."},"X-RateLimit-Remaining":{"schema":{"type":"integer"},"description":"Requests left this minute."},"X-RateLimit-Reset":{"schema":{"type":"integer"},"description":"Unix seconds when the counter resets."}},"content":{"application/json":{"schema":{"type":"object","properties":{"type":{"type":"string","enum":["draft","schedule","now"]},"posts":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"channel_id":{"type":["string","null"]},"platform":{"type":["string","null"]},"status":{"type":"string","enum":["draft","scheduled"]},"scheduled_at":{"type":["string","null"],"format":"date-time"},"content":{"type":"string"}}}},"warnings":{"type":"array","items":{"type":"string"},"description":"Non-fatal. E.g. a thread sent to a platform that does not support threading."},"idempotent_replay":{"type":"boolean","description":"True when this Idempotency-Key had already been used and nothing new was created."},"publish":{"type":"object","description":"Present only for type \"now\".","properties":{"overall_status":{"type":"string","enum":["published","partial","failed"]},"results":{"type":"array","items":{"type":"object","properties":{"post_id":{"type":["string","null"]},"channel_id":{"type":"string"},"platform":{"type":"string"},"username":{"type":"string"},"success":{"type":"boolean"},"error":{"type":"string"},"url":{"type":"string","description":"Link to the live post, when the platform returns one."},"platform_post_id":{"type":"string"}}}}}}}}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"402":{"$ref":"#/components/responses/PaymentRequired"},"403":{"$ref":"#/components/responses/Forbidden"},"404":{"$ref":"#/components/responses/NotFound"},"409":{"$ref":"#/components/responses/RequestInProgress"},"422":{"$ref":"#/components/responses/ValidationFailed"},"429":{"$ref":"#/components/responses/RateLimited"},"500":{"$ref":"#/components/responses/InternalError"},"503":{"$ref":"#/components/responses/NotConfigured"}},"security":[{"ApiKeyAuth":[]}]}}},"components":{"securitySchemes":{"ApiKeyAuth":{"type":"http","scheme":"bearer","description":"A Planify API key, prefixed `plfy_live_` or `plfy_test_`. Create one in Settings → API."}},"schemas":{"Error":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Stable, machine-readable. Branch on this, not the message."},"message":{"type":"string"},"hint":{"type":"string","description":"What to do about it. Often names the endpoint that answers the question."}}}}},"Quota":{"type":"object","properties":{"used":{"type":"integer"},"limit":{"type":["integer","null"],"description":"null means unlimited."},"remaining":{"type":["integer","null"],"description":"null means unlimited."}}},"Channel":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"platform":{"type":"string","enum":["twitter","facebook","instagram","threads","bluesky","pinterest","mastodon","linkedin","youtube"]},"username":{"type":["string","null"]},"display_name":{"type":["string","null"]},"avatar_url":{"type":["string","null"]},"connected_at":{"type":"string","format":"date-time"},"available":{"type":"boolean","description":"False when the plan excludes this platform. The channel is still listed — absent and not-permitted are different facts."},"unavailable_reason":{"type":"string"},"tools":{"type":"array","items":{"type":"string"},"description":"Lookups this channel supports. Details at GET /v1/channels/{id}/settings."}}},"ChannelCapabilities":{"type":"object","description":"Everything needed to write a post this channel will accept.","properties":{"id":{"type":"string","format":"uuid"},"platform":{"type":"string"},"username":{"type":["string","null"]},"display_name":{"type":["string","null"]},"available":{"type":"boolean"},"unavailable_reason":{"type":"string"},"limits":{"type":"object","properties":{"max_characters":{"type":"integer"},"max_media":{"type":"integer"},"requires_media":{"type":"boolean","description":"True where a post with no attachment is rejected."},"max_files_per_post":{"type":"integer","default":10},"max_file_size_bytes":{"type":"integer","default":104857600},"allowed_mime_types":{"type":"array","items":{"type":"string"}}}},"threading":{"type":"object","properties":{"supported":{"type":"boolean"},"enabled_by":{"type":"string","description":"Which setting turns threading on."}}},"rules":{"type":"array","items":{"type":"string"},"description":"Hard requirements first, then style guidance. Prose, written to be read by a model."},"settings_schema":{"description":"JSON Schema for this platform's slice of platform_settings.","oneOf":[{"type":"object"},{"type":"null"}]},"tools":{"type":"array","items":{"type":"object","properties":{"method_name":{"type":"string"},"description":{"type":"string"},"fills":{"type":"string","description":"Which post setting the result fills in."},"input":{"type":"object","description":"JSON Schema for the data argument."}}}}}},"Post":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"channel_id":{"type":["string","null"]},"platform":{"type":["string","null"]},"status":{"type":"string","enum":["draft","scheduled"]},"scheduled_at":{"type":["string","null"],"format":"date-time"},"content":{"type":"string"}}},"PlatformSettings":{"type":"object","description":"Keyed by platform slug. Generated from the same declarations the services read.","properties":{"twitter":{"$schema":"https://json-schema.org/draft/2020-12/schema","type":"object","properties":{"community_id":{"description":"Post into a Twitter community. Find one with POST /v1/channels/:id/trigger { method_name: \"communities\" }.","type":"string"},"community_also_on_profile":{"description":"Also show a community post on your profile timeline.","type":"boolean"},"enable_threading":{"description":"Split content longer than 280 characters into a thread instead of rejecting it.","type":"boolean"},"thread_tweets":{"description":"Explicit thread parts. Overrides automatic splitting when set.","type":"array","items":{"type":"string"}}}},"instagram":{"$schema":"https://json-schema.org/draft/2020-12/schema","type":"object","properties":{"first_comment":{"description":"Posted as a comment immediately after publishing. Where hashtags usually go.","type":"string"},"content_type":{"description":"post = feed, reel = video reel, story = 24-hour story. Defaults to post.","type":"string","enum":["post","reel","story"]},"location_id":{"description":"Facebook Place ID. Required for a location tag — location_name alone is ignored.","type":"string"},"location_name":{"description":"Display name of the location. Cosmetic; tagging needs location_id.","type":"string"},"share_to_feed":{"description":"For reels: also show in the main feed.","type":"boolean"},"audio_name":{"type":"string"},"thumbnail_url":{"description":"Cover image for a reel.","type":"string"},"enable_reels":{"description":"Deprecated — use content_type: \"reel\".","type":"boolean"},"location":{"description":"Deprecated — use location_name.","type":"string"},"tagged_users":{"type":"array","items":{"type":"string"}}}},"linkedin":{"$schema":"https://json-schema.org/draft/2020-12/schema","type":"object","properties":{"article_mode":{"type":"boolean"},"visibility":{"type":"string","enum":["public","connections","private"]}}},"facebook":{"$schema":"https://json-schema.org/draft/2020-12/schema","type":"object","properties":{"location":{"type":"string"},"feeling":{"type":"string"},"tagged_friends":{"type":"array","items":{"type":"string"}},"privacy":{"type":"string","enum":["public","friends","custom"]}}},"threads":{"$schema":"https://json-schema.org/draft/2020-12/schema","type":"object","properties":{"enable_threading":{"description":"Split content over 500 characters into a chain of replies.","type":"boolean"},"reply_control":{"type":"string","enum":["everyone","mentioned","followers"]}}},"tiktok":{"$schema":"https://json-schema.org/draft/2020-12/schema","type":"object","properties":{"allow_comments":{"type":"boolean"},"allow_duet":{"type":"boolean"},"allow_stitch":{"type":"boolean"},"privacy":{"type":"string","enum":["public","friends","private"]}}},"youtube":{"$schema":"https://json-schema.org/draft/2020-12/schema","type":"object","properties":{"category":{"type":"string"},"privacy":{"type":"string","enum":["public","unlisted","private"]},"made_for_kids":{"type":"boolean"}}},"bluesky":{"$schema":"https://json-schema.org/draft/2020-12/schema","type":"object","properties":{"enable_threading":{"description":"Split content over 300 characters into a thread.","type":"boolean"},"thread_posts":{"description":"Explicit thread parts. Overrides automatic splitting when set.","type":"array","items":{"type":"string"}},"auto_link_card":{"description":"Attach a preview card for the first URL in the post.","type":"boolean"}}},"pinterest":{"$schema":"https://json-schema.org/draft/2020-12/schema","type":"object","properties":{"board_id":{"description":"Required to publish. Unguessable — list boards with POST /v1/channels/:id/trigger { method_name: \"boards\" }.","type":"string"},"board_name":{"description":"Display only; publishing keys off board_id.","type":"string"},"alt_text":{"type":"string"},"link":{"description":"Destination URL the pin links to.","type":"string"}}},"mastodon":{"$schema":"https://json-schema.org/draft/2020-12/schema","type":"object","properties":{"visibility":{"type":"string","enum":["public","unlisted","private","direct"]},"sensitive":{"description":"Hide media behind a click-through warning.","type":"boolean"},"spoiler_text":{"description":"Content warning shown before the post body.","type":"string"}}}}}},"responses":{"Unauthorized":{"description":"No credential, or an unrecognised or revoked API key.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"Forbidden":{"description":"The key lacks a scope this operation needs. Do not retry with this key.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"NotFound":{"description":"No such channel, or it is not yours.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"PaymentRequired":{"description":"The plan does not grant this. GET /v1/me reports plan and status.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"RequestInProgress":{"description":"An identical Idempotency-Key request is still running. Wait and retry.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"ValidationFailed":{"description":"The post breaks a channel rule. GET /v1/channels/{id}/settings lists them.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"FileTooLarge":{"description":"File exceeds 100MB.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"UnsupportedMediaType":{"description":"Not an accepted media type. 42 types are allowed; a channel's settings list them.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"UrlNotAllowed":{"description":"The URL is not a public http(s) address, or resolves somewhere private.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"FetchFailed":{"description":"The remote URL could not be downloaded.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"PlatformError":{"description":"The social platform refused the request. Retrying may help.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"RateLimited":{"description":"Rate limit exceeded. Honour Retry-After.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"InternalError":{"description":"Something went wrong on our side.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"NotConfigured":{"description":"API key auth is not configured on this deployment.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}}