{"openapi":"3.1.0","info":{"title":"Planify API","version":"1.1.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, reading, editing, scheduling and publishing."},{"name":"Analytics","description":"How posts and channels performed."}],"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"]}}}},"endpoints":{"type":"object","description":"All 20 endpoints, split by whether this credential can call them. The scopes a key holds only answer the question if you already know which scope each endpoint wants — so the mapping is served, and `denied` names the missing scope per entry. A key without posts:publish can prepare and schedule everything and publish nothing; saying so up front beats failing the call. A cookie session holds every scope, so denied is empty.","properties":{"allowed":{"type":"array","items":{"$ref":"#/components/schemas/EndpointScope"}},"denied":{"type":"array","items":{"$ref":"#/components/schemas/EndpointScope"}}}},"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"}},"supports_alt_text":{"type":"boolean","description":"True on twitter, bluesky, mastodon, instagram, pinterest — the platforms whose publishing API has an alt-text field. False elsewhere, where media[].alt is still accepted and stored on the post but not sent onward. Reported rather than left to be guessed: alt text that is stored and never delivered is worse than none, because nothing says so."},"max_alt_text_characters":{"type":"integer","default":1000}}},"media":{"type":"object","description":"How attachments are treated, beyond the counts in limits.","properties":{"ordering_preserved":{"type":"boolean","description":"True everywhere. media[] and media_urls publish in the order given: index 0 is the first attachment, and for an Instagram carousel that is the first slide."},"ordering":{"type":"string","description":"The same guarantee in prose, for a model to read."}}},"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":{"get":{"operationId":"listMedia","summary":"List uploaded media","description":"The account's media, newest first.\n\nLists the storage folder rather than a table, which means it returns files uploaded through the web app as well as through the API — the case that matters is an agent finishing a post a person started, where the images are already in Planify and the agent just needs to name them.\n\n`media:write` rather than a read scope because there is no `media:read`, and these are exactly the files that scope creates. Adding a scope would break every key already issued.\n\n**Scopes:** `media:write`\n\n**Rate class:** read (120/min)","tags":["Media"],"parameters":[{"name":"limit","in":"query","required":false,"schema":{"type":"integer","default":50,"maximum":100},"description":"Items per page."},{"name":"offset","in":"query","required":false,"schema":{"type":"integer","default":0},"description":"From next_offset on the previous page."},{"name":"mime_type","in":"query","required":false,"schema":{"type":"string"},"description":"Prefix match on content type, e.g. \"image/\". Narrows this page only — Storage has no server-side filter, so a page can come back short while later pages still hold matches."}],"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":{"id":{"type":["string","null"]},"url":{"type":"string","format":"uri"},"filename":{"type":"string"},"content_type":{"type":["string","null"]},"bytes":{"type":["integer","null"]},"created_at":{"type":["string","null"],"format":"date-time"}}}},"next_offset":{"type":["integer","null"],"description":"Pass as ?offset= for the next page. Null means this was the last."},"note":{"type":"string","description":"Present when mime_type was used."}}}}}},"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":[]}]},"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/validate":{"post":{"operationId":"validatePost","summary":"Check a post without creating it","description":"The same body as POST /v1/posts, checked and discarded. Nothing is created, nothing is published.\n\nGET /v1/channels/{id}/settings already returns the rules, and a caller can mostly apply them. Mostly is the problem: it has to count characters the way the server counts them, know that a threaded post is exempt from the character limit, and work out whether a rule has been satisfied. The first time it gets one wrong it finds out by creating something — a draft in someone's queue, or a published post that should not have been.\n\nRuns the create path's own resolution and checking rather than a copy, so a green result here and a rejection there cannot disagree.\n\nA malformed body comes back as 200 with `valid: false` rather than 400 — the caller asked whether the post is acceptable, and \"no, and here is why\" is the answer in both cases. An unknown channel id is still a 404: that is a broken request, not an invalid post.\n\n**Content and settings only.** Plan limits, quotas and the posts:publish scope are checked when the post is created, so `valid: true` is not a guarantee the create call will succeed. GET /v1/me answers the plan question.\n\n**Scopes:** `posts:read`\n\n**Rate class:** read (120/min)","tags":["Posts"],"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"}},"media":{"description":"Attachments in order, each with optional alt text. Use instead of media_urls, not alongside it.","maxItems":10,"type":"array","items":{"type":"object","properties":{"url":{"type":"string","format":"uri"},"alt":{"description":"Alt text for this attachment. Sent to the platforms that accept it — see limits.supports_alt_text on GET /v1/channels/:id/settings — and stored for the rest.","type":"string","maxLength":1000}},"required":["url"]}},"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"}},"media":{"description":"Attachments in order, each with optional alt text. Use instead of media_urls, not alongside it.","maxItems":10,"type":"array","items":{"type":"object","properties":{"url":{"type":"string","format":"uri"},"alt":{"description":"Alt text for this attachment. Sent to the platforms that accept it — see limits.supports_alt_text on GET /v1/channels/:id/settings — and stored for the rest.","type":"string","maxLength":1000}},"required":["url"]}},"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}}},"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":{"valid":{"type":"boolean","description":"True when nothing fatal was found."},"errors":{"type":"array","description":"Fatal. Each names the field, the rule, and the numbers where there are any.","items":{"type":"object","properties":{"channel_id":{"type":"string","format":"uuid"},"platform":{"type":"string"},"field":{"type":"string"},"rule":{"type":"string","description":"Stable. Branch on this, not the message."},"message":{"type":"string"},"limit":{"type":"integer"},"actual":{"type":"integer"}}}},"warnings":{"type":"array","items":{"type":"string"},"description":"Non-fatal: the post would be created, but something in it would be ignored."},"channels":{"type":"array","description":"Per channel, because with per-channel copy the answer genuinely differs.","items":{"type":"object","properties":{"channel_id":{"type":"string","format":"uuid"},"platform":{"type":"string"},"characters":{"type":"integer"},"media_count":{"type":"integer"},"alt_text_supplied":{"type":"integer"},"valid":{"type":"boolean"}}}},"checked":{"type":"integer","description":"How many channels were checked."},"note":{"type":"string","description":"What a green result does and does not promise."}}}}}},"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":[]}]}},"/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\n**Attachments.** `media_urls` and `media` both work; `media` is the form that carries alt text, as `[{ url, alt }]`. Prefer it when the images carry information — for a data slide the alt text is the only machine-readable copy of the numbers on it, and there is no other way to attach it to a scheduled post. Where both are sent, `media` wins. `limits.supports_alt_text` on GET /v1/channels/{id}/settings says whether a given channel delivers it.\n\n**Order is the post's order.** Index 0 is the first attachment, and for an Instagram carousel that is the first slide — the services iterate the array as given, so reordering it reorders the post.\n\nPOST /v1/posts/validate takes this same body and reports what would happen, without creating anything.\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"}},"media":{"description":"Attachments in order, each with optional alt text. Use instead of media_urls, not alongside it.","maxItems":10,"type":"array","items":{"type":"object","properties":{"url":{"type":"string","format":"uri"},"alt":{"description":"Alt text for this attachment. Sent to the platforms that accept it — see limits.supports_alt_text on GET /v1/channels/:id/settings — and stored for the rest.","type":"string","maxLength":1000}},"required":["url"]}},"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"}},"media":{"description":"Attachments in order, each with optional alt text. Use instead of media_urls, not alongside it.","maxItems":10,"type":"array","items":{"type":"object","properties":{"url":{"type":"string","format":"uri"},"alt":{"description":"Alt text for this attachment. Sent to the platforms that accept it — see limits.supports_alt_text on GET /v1/channels/:id/settings — and stored for the rest.","type":"string","maxLength":1000}},"required":["url"]}},"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"],"description":"One channel per post — a compose fans out into one post per channel. Null on legacy rows that target a platform without a specific account."},"platform":{"type":["string","null"]},"status":{"type":"string","enum":["draft","scheduled","published","failed","partial"]},"publishing_status":{"type":["string","null"],"description":"Set only once publishing has been attempted: publishing, published, partial or failed."},"scheduled_at":{"type":["string","null"],"format":"date-time"},"published_at":{"type":["string","null"],"format":"date-time"},"content":{"type":"string"},"media_urls":{"type":"array","items":{"type":"string","format":"uri"}},"media_alt":{"type":"array","items":{"type":"string"},"description":"Alt text, index-aligned with media_urls: media_alt[i] describes media_urls[i]. Shorter than media_urls means the rest have none."},"media":{"type":"array","description":"The same attachments, paired with their alt text. Read this rather than zipping the two arrays.","items":{"type":"object","properties":{"url":{"type":"string","format":"uri"},"alt":{"type":["string","null"]}}}},"permalink":{"type":["string","null"],"format":"uri","description":"URL of the live post. Null until it publishes, and on platforms that return none. This is how a caller finds out where a *scheduled* post ended up — the publish response only exists for a post published in that same call."},"platform_post_id":{"type":["string","null"],"description":"The platform's own id, for callers that query the platform directly."},"delivery_status":{"type":["string","null"],"enum":["pending","publishing","published","failed","cancelled",null],"description":"The platform attempt, as recorded. Null before any attempt."},"delivery_error":{"type":["string","null"],"description":"The platform's rejection message, when it rejected."},"platform_settings":{"$ref":"#/components/schemas/PlatformSettings"},"last_publish_error":{"type":["string","null"],"description":"Why the last publish attempt failed, when one did. The first thing to read on a failed post."},"publish_retry_count":{"type":"integer"},"created_at":{"type":"string","format":"date-time"},"updated_at":{"type":["string","null"],"format":"date-time"}}}},"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":[]}]},"get":{"operationId":"listPosts","summary":"List posts in a date range","description":"\"What is already scheduled this week\" is the question that comes before \"add another\", so the date window is the primary filter rather than an afterthought.\n\nThe window filters on `scheduled_at`, which means undated drafts are excluded — a date-range question is about the calendar, and an undated draft is not on it. Omit `from` and `to` to see everything, newest first.\n\n**Scopes:** `posts:read`\n\n**Rate class:** read (120/min)","tags":["Posts"],"parameters":[{"name":"from","in":"query","required":false,"schema":{"type":"string","format":"date-time"},"description":"Start of the window (inclusive), ISO-8601."},{"name":"to","in":"query","required":false,"schema":{"type":"string","format":"date-time"},"description":"End of the window (inclusive), ISO-8601."},{"name":"status","in":"query","required":false,"schema":{"type":"string","enum":["draft","scheduled","published","failed","partial"]},"description":"Filter by status."},{"name":"channel_id","in":"query","required":false,"schema":{"type":"string"},"description":"Only posts targeting this channel."},{"name":"platform","in":"query","required":false,"schema":{"type":"string"},"description":"Only posts targeting this platform."},{"name":"limit","in":"query","required":false,"schema":{"type":"integer","minimum":1,"maximum":100,"default":50},"description":"Rows to return."}],"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":{"posts":{"type":"array","items":{"$ref":"#/components/schemas/Post"}},"total":{"type":"integer","description":"Total matching the filters, ignoring limit."},"note":{"type":"string","description":"Present when the result was truncated by limit."}}}}}},"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":[]}]}},"/posts/{id}":{"get":{"operationId":"getPost","summary":"Read one post","description":"The full stored post, including its platform settings and publish status.\n\n**Scopes:** `posts:read`\n\n**Rate class:** read (120/min)","tags":["Posts"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"},"description":"Post id."}],"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":{"post":{"$ref":"#/components/schemas/Post"}}}}}},"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":[]}]},"patch":{"operationId":"updatePost","summary":"Edit a draft or scheduled post","description":"Change the content, media, settings, schedule, or flip between `draft` and `scheduled`. Everything is optional; send only what changes.\n\n**A published post cannot be edited** (409). A published row records something that exists on a real platform, and changing it would make Planify disagree with what the audience actually saw. Editing the live post is a different operation with different per-platform limits, and pretending a PATCH does that would be worse than refusing.\n\nThe result is validated against the target channel exactly as creation is, so an edit cannot push a post past a character limit.\n\nSending `media` replaces the attachments and their alt text together, and `media_urls` clears the alt text along with them — leaving old descriptions attached to new images would describe the wrong pictures.\n\n**Scopes:** `posts:write`\n\n**Rate class:** write (60/min)\n\n**Requires an active subscription** (402 otherwise).","tags":["Posts"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"},"description":"Post id."}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"content":{"type":"string"},"media_urls":{"type":"array","items":{"type":"string","format":"uri"},"description":"Replaces the attachments, and clears any alt text they had."},"media":{"type":"array","description":"Replaces the attachments and their alt text together. Use this when any has alt text.","items":{"type":"object","required":["url"],"properties":{"url":{"type":"string","format":"uri"},"alt":{"type":"string","maxLength":1000}}}},"status":{"type":"string","enum":["draft","scheduled"],"description":"Flip between the two. Cannot be set to published — publish it instead."},"scheduled_at":{"type":["string","null"],"format":"date-time","description":"Required when status is scheduled."},"platform_settings":{"$ref":"#/components/schemas/PlatformSettings"}}}}}},"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":{"post":{"$ref":"#/components/schemas/Post"}}}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"402":{"$ref":"#/components/responses/PaymentRequired"},"403":{"$ref":"#/components/responses/Forbidden"},"404":{"$ref":"#/components/responses/NotFound"},"409":{"$ref":"#/components/responses/AlreadyPublished"},"422":{"$ref":"#/components/responses/ValidationFailed"},"429":{"$ref":"#/components/responses/RateLimited"},"500":{"$ref":"#/components/responses/InternalError"},"503":{"$ref":"#/components/responses/NotConfigured"}},"security":[{"ApiKeyAuth":[]}]},"delete":{"operationId":"deletePost","summary":"Delete a post","description":"Removes the post from Planify.\n\nDeleting an already-published post is allowed but removes only Planify's record — the post stays live on the platform and must be removed there. The response says so explicitly, because an agent asking to \"delete the post\" quite likely meant to unpublish it.\n\n**Scopes:** `posts:write`\n\n**Rate class:** write (60/min)\n\n**Requires an active subscription** (402 otherwise).","tags":["Posts"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"},"description":"Post id."}],"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":{"deleted":{"type":"boolean"},"id":{"type":"string"},"note":{"type":"string","description":"Present when the deleted post had already been published."}}}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"402":{"$ref":"#/components/responses/PaymentRequired"},"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":[]}]}},"/posts/{id}/publish":{"post":{"operationId":"publishPost","summary":"Publish a post that already exists","description":"Publishes a draft or scheduled post now. `POST /v1/posts` with `type: \"now\"` covers create-and-publish; this covers a post prepared earlier and since approved.\n\nThat approval flow is the point. Issue an agent a key with **posts:write but not posts:publish** and it can prepare everything and publish nothing — a human then calls this. Without it, \"let the agent draft and I will approve\" would mean approving in the dashboard only.\n\nReturns a per-channel result. Republishing an already-published post is refused with 409 rather than posting a duplicate to the audience.\n\n**Scopes:** `posts:write`, `posts:publish`\n\n**Rate class:** publish (20/min)\n\n**Requires an active subscription** (402 otherwise).","tags":["Posts"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"},"description":"Post id."}],"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":{"post_id":{"type":"string"},"overall_status":{"type":"string","enum":["published","partial","failed"]},"results":{"type":"array","items":{"type":"object","properties":{"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"},"retries":{"type":"integer"}}}},"warning":{"type":"string","description":"Set when the post published but Planify could not record it. Do not retry."}}}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"402":{"$ref":"#/components/responses/PaymentRequired"},"403":{"$ref":"#/components/responses/Forbidden"},"404":{"$ref":"#/components/responses/NotFound"},"409":{"$ref":"#/components/responses/AlreadyPublished"},"422":{"$ref":"#/components/responses/ValidationFailed"},"429":{"$ref":"#/components/responses/RateLimited"},"500":{"$ref":"#/components/responses/InternalError"},"503":{"$ref":"#/components/responses/NotConfigured"}},"security":[{"ApiKeyAuth":[]}]}},"/channels/{id}/schedule":{"get":{"operationId":"getChannelSchedule","summary":"Read a channel's weekly posting schedule","description":"The slots and the timezone they are written in.\n\nReturns `schedule: null` for a channel that has none, which is also why GET /channels/{id}/slot would return `slot: null`. Null rather than a 404, because a 404 reads as \"no such channel\".\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","properties":{"channel_id":{"type":"string","format":"uuid"},"platform":{"type":"string"},"schedule":{"description":"Null when this channel has no schedule — the common case, and not an error.","oneOf":[{"type":"object","properties":{"timezone":{"type":"string","description":"IANA zone name. Slots are local to it."},"slots":{"$ref":"#/components/schemas/ScheduleSlots"},"updated_at":{"type":["string","null"],"format":"date-time"}}},{"type":"null"}]},"reason":{"type":"string","description":"Present when schedule is null."},"next_slot_endpoint":{"type":"string","description":"Where the schedule turns into timestamps."}}}}}},"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":[]}]},"put":{"operationId":"setChannelSchedule","summary":"Set a channel's weekly posting schedule","description":"Replaces the whole schedule — send every slot you want, not just the new ones.\n\n**Times are local wall-clock time in `timezone`, never UTC.** That is what makes \"Tuesdays at 12:30\" survive a daylight-saving change: the slot is stored as 12:30 in a named zone and converted to UTC each time a post is scheduled, so it stays 12:30 to the audience rather than drifting by an hour twice a year. Storing UTC times, or offsets, is the bug this shape avoids.\n\nThe zone is validated against the runtime tz database, not a regex: \"Asia/Calcutta\" and \"Asia/Kolkata\" are both real, \"Asia/Bangalore\" is not, and a typo that stores cleanly surfaces weeks later as posts going out at the wrong hour.\n\nWrites the same table and the same `{ day, time }` shape the dashboard editor uses, so a schedule set here is editable in the UI and the other way round. Duplicate slots are dropped. An empty `slots` array clears the schedule.\n\n**Scopes:** `channels:read`, `posts:write`\n\n**Rate class:** write (60/min)\n\n**Requires an active subscription** (402 otherwise).","tags":["Channels"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"},"description":"Channel id from GET /v1/channels."}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$schema":"https://json-schema.org/draft/2020-12/schema","type":"object","properties":{"timezone":{"description":"IANA zone the slots are written in, e.g. \"Asia/Kolkata\". Slots are local to it and converted to UTC when a post is scheduled, so a slot survives a daylight-saving shift.","type":"string","minLength":1},"slots":{"description":"The weekly grid. An empty array clears the schedule without deleting the row.","maxItems":70,"type":"array","items":{"type":"object","properties":{"day":{"description":"Three-letter lowercase weekday.","type":"string","enum":["mon","tue","wed","thu","fri","sat","sun"]},"time":{"description":"HH:MM, 24-hour, local to `timezone` — not UTC.","type":"string","pattern":"^([01]\\d|2[0-3]):[0-5]\\d$"}},"required":["day","time"]}}},"required":["timezone","slots"]}}}},"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":"Null when this channel has no schedule — the common case, and not an error.","oneOf":[{"type":"object","properties":{"timezone":{"type":"string","description":"IANA zone name. Slots are local to it."},"slots":{"$ref":"#/components/schemas/ScheduleSlots"},"updated_at":{"type":["string","null"],"format":"date-time"}}},{"type":"null"}]},"reason":{"type":"string","description":"Present when schedule is null."},"next_slot_endpoint":{"type":"string","description":"Where the schedule turns into timestamps."}}}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"402":{"$ref":"#/components/responses/PaymentRequired"},"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":[]}]},"delete":{"operationId":"deleteChannelSchedule","summary":"Remove a channel's posting schedule","description":"Removes the weekly template. Posts already scheduled at those times keep their times — this stops future slot suggestions, it does not unschedule anything.\n\n**Scopes:** `channels:read`, `posts:write`\n\n**Rate class:** write (60/min)\n\n**Requires an active subscription** (402 otherwise).","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","properties":{"deleted":{"type":"boolean"},"channel_id":{"type":"string","format":"uuid"},"note":{"type":"string"}}}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"402":{"$ref":"#/components/responses/PaymentRequired"},"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":[]}]}},"/analytics":{"get":{"operationId":"getAnalyticsOverview","summary":"Account performance over a period","description":"Totals and trends across channels. This is what turns a write-only integration into one that can decide: \"post three times this week\" needs no analytics, \"post more of what worked\" does.\n\nRequires a plan including analytics — the same gate the dashboard applies, because an API that quietly bypasses a plan feature is a pricing hole rather than a convenience.\n\n**Scopes:** `analytics:read`\n\n**Rate class:** read (120/min)","tags":["Analytics"],"parameters":[{"name":"range","in":"query","required":false,"schema":{"type":"string","enum":["7d","30d","90d","month","year","lifetime"],"default":"30d"},"description":"Period to report on."},{"name":"from","in":"query","required":false,"schema":{"type":"string","format":"date-time"},"description":"Custom window start; overrides range."},{"name":"to","in":"query","required":false,"schema":{"type":"string","format":"date-time"},"description":"Custom window end."},{"name":"platform","in":"query","required":false,"schema":{"type":"string"},"description":"Restrict to one platform."},{"name":"channel_id","in":"query","required":false,"schema":{"type":"string"},"description":"Restrict to one channel."}],"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":{"range":{"type":"string"},"overview":{"type":"object","description":"Totals, per-platform breakdown and trend series.","additionalProperties":true},"note":{"type":"string","description":"States that figures are synced periodically and may lag the platform."}}}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"402":{"$ref":"#/components/responses/PaymentRequired"},"403":{"$ref":"#/components/responses/Forbidden"},"429":{"$ref":"#/components/responses/RateLimited"},"500":{"$ref":"#/components/responses/InternalError"},"503":{"$ref":"#/components/responses/NotConfigured"}},"security":[{"ApiKeyAuth":[]}]}},"/analytics/posts":{"get":{"operationId":"getPostAnalytics","summary":"Per-post performance, sortable","description":"Which posts worked. Sorting is exposed on purpose: \"top 10 by engagement over the last 30 days\" should be one call, not a full fetch followed by client-side sorting.\n\n**Scopes:** `analytics:read`\n\n**Rate class:** read (120/min)","tags":["Analytics"],"parameters":[{"name":"range","in":"query","required":false,"schema":{"type":"string","enum":["7d","30d","90d","month","year","lifetime"],"default":"30d"},"description":"Period to report on."},{"name":"sort_by","in":"query","required":false,"schema":{"type":"string","enum":["engagement","likes","shares","comments","reach","impressions","date"],"default":"engagement"},"description":"Metric to sort on."},{"name":"sort_order","in":"query","required":false,"schema":{"type":"string","enum":["asc","desc"],"default":"desc"},"description":"Sort direction."},{"name":"platform","in":"query","required":false,"schema":{"type":"string"},"description":"Restrict to one platform."},{"name":"channel_id","in":"query","required":false,"schema":{"type":"string"},"description":"Restrict to one channel."},{"name":"limit","in":"query","required":false,"schema":{"type":"integer","minimum":1,"maximum":100,"default":25},"description":"Rows to return."},{"name":"offset","in":"query","required":false,"schema":{"type":"integer","minimum":0,"default":0},"description":"Rows to skip, for paging."}],"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":{"posts":{"type":"array","items":{"type":"object","additionalProperties":true}},"total":{"type":"integer"},"next_offset":{"type":"integer","description":"Present when more rows remain."},"note":{"type":"string"}}}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"402":{"$ref":"#/components/responses/PaymentRequired"},"403":{"$ref":"#/components/responses/Forbidden"},"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"}},"supports_alt_text":{"type":"boolean","description":"True on twitter, bluesky, mastodon, instagram, pinterest — the platforms whose publishing API has an alt-text field. False elsewhere, where media[].alt is still accepted and stored on the post but not sent onward. Reported rather than left to be guessed: alt text that is stored and never delivered is worse than none, because nothing says so."},"max_alt_text_characters":{"type":"integer","default":1000}}},"media":{"type":"object","description":"How attachments are treated, beyond the counts in limits.","properties":{"ordering_preserved":{"type":"boolean","description":"True everywhere. media[] and media_urls publish in the order given: index 0 is the first attachment, and for an Instagram carousel that is the first slide."},"ordering":{"type":"string","description":"The same guarantee in prose, for a model to read."}}},"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"],"description":"One channel per post — a compose fans out into one post per channel. Null on legacy rows that target a platform without a specific account."},"platform":{"type":["string","null"]},"status":{"type":"string","enum":["draft","scheduled","published","failed","partial"]},"publishing_status":{"type":["string","null"],"description":"Set only once publishing has been attempted: publishing, published, partial or failed."},"scheduled_at":{"type":["string","null"],"format":"date-time"},"published_at":{"type":["string","null"],"format":"date-time"},"content":{"type":"string"},"media_urls":{"type":"array","items":{"type":"string","format":"uri"}},"media_alt":{"type":"array","items":{"type":"string"},"description":"Alt text, index-aligned with media_urls: media_alt[i] describes media_urls[i]. Shorter than media_urls means the rest have none."},"media":{"type":"array","description":"The same attachments, paired with their alt text. Read this rather than zipping the two arrays.","items":{"type":"object","properties":{"url":{"type":"string","format":"uri"},"alt":{"type":["string","null"]}}}},"permalink":{"type":["string","null"],"format":"uri","description":"URL of the live post. Null until it publishes, and on platforms that return none. This is how a caller finds out where a *scheduled* post ended up — the publish response only exists for a post published in that same call."},"platform_post_id":{"type":["string","null"],"description":"The platform's own id, for callers that query the platform directly."},"delivery_status":{"type":["string","null"],"enum":["pending","publishing","published","failed","cancelled",null],"description":"The platform attempt, as recorded. Null before any attempt."},"delivery_error":{"type":["string","null"],"description":"The platform's rejection message, when it rejected."},"platform_settings":{"$ref":"#/components/schemas/PlatformSettings"},"last_publish_error":{"type":["string","null"],"description":"Why the last publish attempt failed, when one did. The first thing to read on a failed post."},"publish_retry_count":{"type":"integer"},"created_at":{"type":"string","format":"date-time"},"updated_at":{"type":["string","null"],"format":"date-time"}}},"ScheduleSlots":{"type":"array","description":"Monday-first. `time` is local to the schedule's timezone, not UTC — that is what keeps a slot at the same wall-clock hour across a daylight-saving change.","items":{"type":"object","required":["day","time"],"properties":{"day":{"type":"string","enum":["mon","tue","wed","thu","fri","sat","sun"]},"time":{"type":"string","pattern":"^([01][0-9]|2[0-3]):[0-5][0-9]$","example":"12:30"}}}},"EndpointScope":{"type":"object","properties":{"method":{"type":"string","enum":["GET","POST","PATCH","PUT","DELETE"]},"path":{"type":"string"},"scopes":{"type":"array","items":{"type":"string","enum":["channels:read","posts:read","posts:write","posts:publish","media:write","analytics:read"]}},"paid":{"type":"boolean","description":"Also needs a plan granting app access (402 otherwise)."},"summary":{"type":"string"},"missing_scopes":{"type":"array","items":{"type":"string","enum":["channels:read","posts:read","posts:write","posts:publish","media:write","analytics:read"]},"description":"On denied entries only: which scopes this credential is missing."}}},"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"}}}},"AlreadyPublished":{"description":"The post has already been published. Editing or republishing it would either misrepresent what went out or post a duplicate.","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"}}}}}}}