Planify
Planify
Get Started
v1.0.0REST · JSON · bearer key

Planify API

Schedule and publish to 9 social platforms from anything that can make an HTTP request — an AI assistant, a workflow tool, your own script. No browser required.

Introduction

Planify schedules and publishes social posts. This API exists so software can do that — an AI assistant acting for a person, a workflow in n8n or Zapier, or a script you wrote yourself.

Every response is JSON. Every error uses one envelope. Field names are snake_case throughout, including the platform settings, so nothing changes case halfway through a request.

Base URL

https://planifyapps.com/api/v1

Auth

Authorization: Bearer <key>

Spec

/openapi.json

MCP server

npx -y @planifyapps/mcp

Publish a post in five calls

Step 2 is the one worth not skipping. It reports the character limit, media rules and required settings for that specific channel — which is the difference between a post that publishes and one that discovers each rule by being rejected.

bash
export PLANIFY_API_KEY="plfy_live_..."
API="https://planifyapps.com/api/v1"

# 1 · find a channel
curl -s "$API/channels" -H "Authorization: Bearer $PLANIFY_API_KEY"

# 2 · learn what it accepts — limits, rules, settings schema, lookups
curl -s "$API/channels/$CHANNEL_ID/settings" -H "Authorization: Bearer $PLANIFY_API_KEY"

# 3 · fetch anything unguessable, e.g. a Pinterest board
curl -s -X POST "$API/channels/$CHANNEL_ID/trigger" \
  -H "Authorization: Bearer $PLANIFY_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"method_name":"boards"}'

# 4 · get an image in (the server downloads it for you)
curl -s -X POST "$API/media/from-url" \
  -H "Authorization: Bearer $PLANIFY_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"url":"https://example.com/photo.jpg"}'

# 5 · publish
curl -s -X POST "$API/posts" \
  -H "Authorization: Bearer $PLANIFY_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{"type":"now","content":"Shipped something today.","channel_ids":["'"$CHANNEL_ID"'"]}'

Authentication

Send an API key as a bearer token. Create one in the dashboard under Settings → API.

Keys are shown exactly once and stored only as a SHA-256 hash, so a lost key is replaced rather than recovered — and a database leak cannot be replayed against the API. Keys cannot create other keys, so a leaked key cannot mint more.

http
Authorization: Bearer plfy_live_b5Ihj_NPiuEWh5BQrAz5wAHLmW8mi…

Scopes

A key carries only the scopes it was created with. Notably posts:publish is not granted by default — so a leaked read-write key can prepare drafts but cannot post to your audience.

If you are wiring up an AI assistant and want it to prepare work for your approval rather than publish on its own, issue a key without that scope and everything else still works.

Available scopes

channels:readdefaultList channels, read their posting rules, and run their lookups.
posts:readdefaultRead posts that already exist.
posts:writedefaultCreate drafts and scheduled posts.
posts:publishopt-inPublish immediately. Never granted by default — ask for it explicitly.
media:writedefaultUpload images and video.
analytics:readdefaultRead performance data.

Rate limits

Counted per key, per minute. Every response carries X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset; a 429 also carries Retry-After.

Publishing is tightest because it is the one action that cannot be undone.

120
read / min
60
write / min
20
publish / min

Idempotency

Send an Idempotency-Key header on POST /v1/posts wherever a retry is possible. Repeating a request with the same key returns the posts created the first time rather than creating more, and sets idempotent_replay: true.

This matters more for software than for people, because software retries automatically. Generate one key per intended post — a UUID is ideal — and reuse it across retries of that same post.

Errors

Every failure returns the same envelope. Branch on code — it is stable and machine-readable. hint usually names the endpoint that answers the question.

json
{
  "error": {
    "code": "validation_failed",
    "message": "twitter (channel ch_1a2b3c): Content exceeds Twitter limit of 280 characters",
    "hint": "GET /api/v1/channels/:id/settings returns the limits and rules for each channel."
  }
}

Status codes you should handle

401invalid_api_keyMissing, unrecognised or revoked key. Do not retry.
402payment_requiredThe plan does not include this. GET /v1/me reports plan and status.
403insufficient_scopeThe key lacks a needed scope. Do not retry with this key.
409request_in_progressAn identical Idempotency-Key request is still running. Wait and retry.
422validation_failedThe post breaks a channel rule. Read the channel settings.
429rate_limitedHonour Retry-After.
502platform_errorThe social platform refused it. Retrying may help.

Account

get/v1/me

Who this credential is, and what it may do

The first call to make. Reports the plan, the scopes the key carries, and quota remaining.

Deliberately 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.

Scopes: none beyond a valid credential.

Rate class: read (120/min)

curl
curl -s "https://planifyapps.com/api/v1/me" \
  -H "Authorization: Bearer $PLANIFY_API_KEY"

Response — 200

user_idstring (uuid)
authobject
planobject
platformsstring[]Platforms this plan may publish to. A channel outside this list is read-only.
featuresobject
usageobjectA null limit means unlimited, and so does a null remaining.
rate_limitsobjectPer-minute ceilings by operation weight, so a client can pace itself.

Channels

get/v1/channels

List connected channels

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.

Channels the plan excludes are returned with available: false rather than hidden — omitting them would be indistinguishable from the account never having been connected.

Scopes: channels:read

Rate class: read (120/min)

curl
curl -s "https://planifyapps.com/api/v1/channels" \
  -H "Authorization: Bearer $PLANIFY_API_KEY"

Response — 200

channelsobject[]
summaryobject
get/v1/channels/{id}/settings

What this channel will accept — the endpoint to read before writing a post

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.

Reading 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.

Scopes: channels:read

Rate class: read (120/min)

Parameters

idrequiredstringChannel id from GET /v1/channels.
curl
curl -s "https://planifyapps.com/api/v1/channels/$CHANNEL_ID/settings" \
  -H "Authorization: Bearer $PLANIFY_API_KEY"

Response — 200

idstring (uuid)
platformstring
usernamestring | null
display_namestring | null
availableboolean
unavailable_reasonstring
limitsobject
threadingobject
rulesstring[]Hard requirements first, then style guidance. Prose, written to be read by a model.
settings_schemaobject | nullJSON Schema for this platform's slice of platform_settings.
toolsobject[]
post/v1/channels/{id}/trigger

Run a per-channel lookup

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.

Which 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.

Scopes: channels:read

Rate class: read (120/min)

Parameters

idrequiredstringChannel id.

Body — application/json

method_namestringWhich lookup to run. Listed as tools[].method_name by GET /v1/channels/:id/settings.
methodNamestring
dataobjectArguments for the lookup, matching that tool's input schema.
curl
curl -s -X POST "https://planifyapps.com/api/v1/channels/$CHANNEL_ID/trigger" \
  -H "Authorization: Bearer $PLANIFY_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "method_name": "boards" }'

Response — 200

method_namestring
get/v1/channels/{id}/slot

Next free posting slot for a channel

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.

Slots 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.

Scopes: channels:read

Rate class: read (120/min)

Parameters

idrequiredstringChannel id.
?countintegerHow many slots to return.
?afterstring (date-time)Search from a later point than now.
curl
curl -s "https://planifyapps.com/api/v1/channels/$CHANNEL_ID/slot" \
  -H "Authorization: Bearer $PLANIFY_API_KEY"

Response — 200

channel_idstring (uuid)
platformstring
scheduleobject | nullThe weekly schedule these came from, or null when none is set.
slotsstring (date-time)[]Ascending, UTC.
slotstring | nullFirst of slots — what most callers want.
reasonstringPresent when there are no slots to give.
notestringPresent when fewer slots were free than requested.

Media

post/v1/media

Upload one or more files

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.

Returns public URLs to pass as media_urls when creating a post.

Scopes: media:write

Rate class: write (60/min)

Requires an active subscription (402 otherwise).

Body — multipart/form-data

filefile[]Up to 10 files, each at most 100MB.
curl
curl -s -X POST "https://planifyapps.com/api/v1/media" \
  -H "Authorization: Bearer $PLANIFY_API_KEY" \
  -F "file=@photo.jpg"

Response — 200

mediaobject[]
media_urlsstring (uri)[]Exactly what media_urls on POST /v1/posts expects, so one call feeds the next.
post/v1/media/from-url

Have the server download media from a URL

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.

Every 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.

Scopes: media:write

Rate class: write (60/min)

Requires an active subscription (402 otherwise).

Body — application/json

urlstringA single publicly reachable URL to ingest.
urlsstring[]Several URLs to ingest in one call.
curl
curl -s -X POST "https://planifyapps.com/api/v1/media/from-url" \
  -H "Authorization: Bearer $PLANIFY_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "url": "https://example.com/photo.jpg" }'

Response — 200

mediaobject[]
media_urlsstring (uri)[]Exactly what media_urls on POST /v1/posts expects, so one call feeds the next.

Posts

post/v1/posts

Create a post — draft, scheduled, or published now

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.

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.

Send 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.

Scopes: posts:write

Rate class: write (60/min)

Requires an active subscription (402 otherwise).

Parameters

Idempotency-KeystringMakes the call replay-safe. Send one wherever a retry is possible.

Body — application/json

type"draft" | "schedule" | "now"draft saves it, schedule queues it for scheduled_at, now publishes immediately (requires the posts:publish scope).
contentrequiredstring | object[]A string fans the same copy to every channel in channel_ids. An array gives per-channel copy.
channel_idsstring[]Required when content is a string; ignored when it is an array.
media_urlsstring (uri)[]Public URLs returned by POST /v1/media or /v1/media/from-url.
scheduled_atstring (date-time) | nullUTC ISO-8601. Required when type is "schedule".
platform_settingsobjectKeyed by platform slug: { pinterest: { board_id } }. See GET /v1/channels/:id/settings.
threadstring[]Post as a thread on platforms that support it. Part one becomes the body.
idempotency_keystringIn-body equivalent of the Idempotency-Key header; the header wins.
curl
curl -s -X POST "https://planifyapps.com/api/v1/posts" \
  -H "Authorization: Bearer $PLANIFY_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{
     "type": "schedule",
     "content": "Shipped the thing. Notes in the thread.",
     "channel_ids": ["ch_1a2b3c"],
     "media_urls": ["https://…/photo.jpg"],
     "scheduled_at": "2026-08-04T09:30:00Z"
  }'

Response — 200

type"draft" | "schedule" | "now"
postsobject[]
warningsstring[]Non-fatal. E.g. a thread sent to a platform that does not support threading.
idempotent_replaybooleanTrue when this Idempotency-Key had already been used and nothing new was created.
publishobjectPresent only for type "now".
get/v1/posts

List posts in a date range

"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.

The 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.

Scopes: posts:read

Rate class: read (120/min)

Parameters

?fromstring (date-time)Start of the window (inclusive), ISO-8601.
?tostring (date-time)End of the window (inclusive), ISO-8601.
?status"draft" | "scheduled" | "published" | "failed" | "partial"Filter by status.
?channel_idstringOnly posts targeting this channel.
?platformstringOnly posts targeting this platform.
?limitintegerRows to return.
curl
curl -s "https://planifyapps.com/api/v1/posts" \
  -H "Authorization: Bearer $PLANIFY_API_KEY"

Response — 200

postsobject[]
totalintegerTotal matching the filters, ignoring limit.
notestringPresent when the result was truncated by limit.
get/v1/posts/{id}

Read one post

The full stored post, including its platform settings and publish status.

Scopes: posts:read

Rate class: read (120/min)

Parameters

idrequiredstringPost id.
curl
curl -s "https://planifyapps.com/api/v1/posts/$POST_ID" \
  -H "Authorization: Bearer $PLANIFY_API_KEY"

Response — 200

postobject
patch/v1/posts/{id}

Edit a draft or scheduled post

Change the content, media, settings, schedule, or flip between draft and scheduled. Everything is optional; send only what changes.

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.

The result is validated against the target channel exactly as creation is, so an edit cannot push a post past a character limit.

Scopes: posts:write

Rate class: write (60/min)

Requires an active subscription (402 otherwise).

Parameters

idrequiredstringPost id.

Body — application/json

contentstring
media_urlsstring (uri)[]
status"draft" | "scheduled"Flip between the two. Cannot be set to published — publish it instead.
scheduled_atstring | nullRequired when status is scheduled.
platform_settingsobject
curl
curl -s -X PATCH "https://planifyapps.com/api/v1/posts/$POST_ID" \
  -H "Authorization: Bearer $PLANIFY_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "content": "Reworded before it goes out.", "status": "scheduled", "scheduled_at": "2026-08-05T09:00:00Z" }'

Response — 200

postobject
delete/v1/posts/{id}

Delete a post

Removes the post from Planify.

Deleting 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.

Scopes: posts:write

Rate class: write (60/min)

Requires an active subscription (402 otherwise).

Parameters

idrequiredstringPost id.
curl
curl -s -X DELETE "https://planifyapps.com/api/v1/posts/$POST_ID" \
  -H "Authorization: Bearer $PLANIFY_API_KEY"

Response — 200

deletedboolean
idstring
notestringPresent when the deleted post had already been published.
post/v1/posts/{id}/publish

Publish a post that already exists

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.

That 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.

Returns a per-channel result. Republishing an already-published post is refused with 409 rather than posting a duplicate to the audience.

Scopes: posts:write, posts:publish

Rate class: publish (20/min)

Requires an active subscription (402 otherwise).

Parameters

idrequiredstringPost id.
curl
curl -s -X POST "https://planifyapps.com/api/v1/posts/$POST_ID/publish" \
  -H "Authorization: Bearer $PLANIFY_API_KEY"

Response — 200

post_idstring
overall_status"published" | "partial" | "failed"
resultsobject[]
warningstringSet when the post published but Planify could not record it. Do not retry.

Analytics

get/v1/analytics

Account performance over a period

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.

Requires 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.

Scopes: analytics:read

Rate class: read (120/min)

Parameters

?range"7d" | "30d" | "90d" | "month" | "year" | "lifetime"Period to report on.
?fromstring (date-time)Custom window start; overrides range.
?tostring (date-time)Custom window end.
?platformstringRestrict to one platform.
?channel_idstringRestrict to one channel.
curl
curl -s "https://planifyapps.com/api/v1/analytics" \
  -H "Authorization: Bearer $PLANIFY_API_KEY"

Response — 200

rangestring
overviewobjectTotals, per-platform breakdown and trend series.
notestringStates that figures are synced periodically and may lag the platform.
get/v1/analytics/posts

Per-post performance, sortable

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.

Scopes: analytics:read

Rate class: read (120/min)

Parameters

?range"7d" | "30d" | "90d" | "month" | "year" | "lifetime"Period to report on.
?sort_by"engagement" | "likes" | "shares" | "comments" | "reach" | "impressions" | "date"Metric to sort on.
?sort_order"asc" | "desc"Sort direction.
?platformstringRestrict to one platform.
?channel_idstringRestrict to one channel.
?limitintegerRows to return.
?offsetintegerRows to skip, for paging.
curl
curl -s "https://planifyapps.com/api/v1/analytics/posts" \
  -H "Authorization: Bearer $PLANIFY_API_KEY"

Response — 200

postsobject[]
totalinteger
next_offsetintegerPresent when more rows remain.
notestring

Clients

Using Planify from an AI assistant

Planify publishes an MCP server, so an assistant that speaks the Model Context Protocol — Claude, or any other MCP client — can use this API directly without you writing glue code.

claude_desktop_config.json
{
  "mcpServers": {
    "planify": {
      "command": "npx",
      "args": ["-y", "@planifyapps/mcp"],
      "env": { "PLANIFY_API_KEY": "plfy_live_..." }
    }
  }
}

It exposes one tool per endpoint, and the per-channel rules from GET /v1/channels/{id}/settings become the tool inputs — so the assistant is told a channel’s character limit and required fields up front rather than discovering them by being rejected.

The tool that creates posts defaults to a draft. Publishing needs type: "now", a key holding posts:publish, and your confirmation, which the server’s own instructions tell the assistant to ask for.

A note on publishing

Publishing is irreversible and public. If you are building an agent, confirm with the person before publishing on their behalf — draft or schedule first and let them approve. The API supports that path precisely so it can be the default one.

Frequently asked questions

Can an AI assistant post to social media with Planify?

Yes. Planify publishes an MCP server, so any assistant that speaks the Model Context Protocol — Claude among them — can list your connected accounts, read each one's posting rules, upload media and publish. Install it with npx -y @planifyapps/mcp and a Planify API key. Publishing immediately requires a key holding the posts:publish scope, which is withheld by default, so an assistant can prepare drafts without being able to post to your audience.

Which social platforms can the Planify API publish to?

The API can publish to 9 platforms: twitter, facebook, instagram, threads, bluesky, pinterest, mastodon, linkedin, youtube. Which of those a given account may use depends on its plan — GET /api/v1/channels reports availability per channel rather than hiding the ones your plan excludes.

How do I know each platform's character limit and media rules?

Call GET /api/v1/channels/{id}/settings before writing the post. It returns that specific channel's character limit, how much media it accepts, whether media is required, the rules that decide whether publishing succeeds, and a JSON Schema for its platform settings. It is per channel rather than per platform, because a Facebook Page and a personal profile differ.

Does the Planify API prevent duplicate posts on retry?

Yes. Send an Idempotency-Key header on POST /api/v1/posts. Repeating a request with the same key returns the posts created the first time instead of creating more, and sets idempotent_replay: true in the response. This matters more for software than for people, because software retries automatically.

Is there an OpenAPI specification for the Planify API?

Yes, at https://planifyapps.com/openapi.json — OpenAPI 3.1, served without authentication. It is generated from the same schemas the API validates requests against, so it cannot describe a version of the API that no longer exists.

How do I get a Planify API key?

Create one in the Planify dashboard under Settings → API. The key is shown once and stored only as a SHA-256 hash, so a lost key is replaced rather than recovered. Keys cannot create other keys, which means a leaked key cannot mint more.

Ready to build?

Create a key in Settings → API, then start with GET /v1/channels.