✦

One key. Four ways in.
One queue behind them.

Terminal, script, agent or dashboard — every post lands in the same queue with the same retry policy and the same delivery receipt.

$ npm i -g postwharf && wharf auth
✓ workspace linked · 12 channels

Authentication

Every request carries a workspace key as a bearer token. Keys are scoped to a single workspace, so a key for one brand can never publish to another. Create and revoke them in Settings → API keys.

# every request
curl https://api.postwharf.com/v1/channels \
  -H "Authorization: Bearer wf_live_..."
Never ship a key to a browser. Keys publish on your behalf. Keep them server-side, in an environment variable, and rotate immediately if one leaks.

REST API

Base URL https://api.postwharf.com/v1. JSON in, JSON out.

GET/platforms
GET/channels
GET/connector-requests
POST/connector-requests
POST/media/upload-url
POST/media
GET/media
PATCH/media/:id
POST/posts
GET/posts
GET/posts/:id
PATCH/posts/:id
DELETE/posts/:id
GET/analytics
GET/events

Every route, with its parameters and refusals, is on the API reference.

Four connectors are request-only. Reddit, Discord, Snapchat and WordPress stay visible, but cannot create channels or publish yet. POST /connector-requests records workspace demand once per platform.

Create a post

Target any set of channels. Give publish_at to schedule, or omit it to publish immediately. Per-channel overrides let one payload carry a different caption for each network.

POST /v1/posts
{
  "text": "Most founders think they have a marketing problem.",
  "media": ["med_8f2a"],
  "channels": ["ch_x_kevin", "ch_li_rm", "ch_ig_hs"],
  "publish_at": "2026-08-18T09:00:00+04:00",
  "overrides": {
    "ch_x_kevin": { "text": "Short version for X →" }
  }
}

Response

{
  "id": "post_31ba",
  "status": "scheduled",
  "deliveries": [
    { "channel": "ch_x_kevin", "status": "pending" },
    { "channel": "ch_li_rm",  "status": "pending" }
  ]
}

Poll GET /posts/:id or subscribe to a webhook. Once live, each delivery gains a url pointing at the post on the network, and a url_state saying why when it has none: none (nothing was published on that leg), pending (it went out and the address is not known yet, so it is worth asking again), or not_reported (it went out and that network gives no address back).

The media array is ordered. Files publish in the order you list them, not the order they were uploaded, so a carousel arrives the way you assembled it. Sending media on PATCH /posts/:id replaces the whole list, which is also how you reorder one.

Ask what each network takes

GET /platforms returns the rules as data: the text limit for each network, the shorter one that applies once a file is attached, how many images and videos one post may carry, the shapes it publishes, and whether this deployment publishes there at all. Every number is the one the refusals are written against, so there is nothing to discover by having a post rejected.

GET /v1/platforms
{
  "platforms": [
    { "platform": "x", "text_limit": 280, "caption_limit": 280,
      "media": { "images": 4, "videos": 1, "mixed": false },
      "post_types": ["feed", "long_form"], "publishes": "live", "measures": true }
  ],
  "upload": { "available": true, "max_image_bytes": 26214400, "max_video_bytes": 536870912 }
}

Check a post before you make it

Send dry_run: true and the call runs every check the real one runs and then writes nothing. A post that would be refused is refused identically, with the same body — so a batch can be validated before the first one is queued rather than discovered halfway through. A dry run does not spend an idempotency key and does not count against your monthly posts.

Files you have not registered yet can be described with media_preview, so a rehearsal costs no upload. It is accepted on a dry run only.

POST /v1/posts
{
  "text": "Most founders think they have a marketing problem.",
  "channels": ["ch_x_kevin", "ch_ig_hs"],
  "media_preview": [{ "kind": "video" }],
  "dry_run": true
}

Running the same call twice

Send an Idempotency-Key header and a repeat of the same call returns the post the first one made, with a 200 instead of a 201. A batch that fails partway can be re-run whole: the calls that already landed cost nothing and create nothing.

POST /v1/posts
Idempotency-Key: sept-campaign-day-03-x

Save a draft

Send draft: true and the post is kept without being sent. A draft is the one post that may have no channels on it yet, because people write first and pick the networks after. It does not count against your monthly posts until it goes out.

POST /v1/posts
{
  "text": "Most founders think they have a marketing problem.",
  "draft": true
}

Read the drawer back with GET /posts?status=draft. An unrecognised status is a 422, not an empty list, so a typo cannot read as "no drafts".

Edit a post

PATCH /posts/:id changes a draft, or a post that has not gone out yet. Every field is optional and leaving one out means leave it alone, so moving a time cannot blank the text by omission. What is checked is the merged post, not the patch: adding a channel re-checks the text against that network's limit, and moving the time re-checks it against the clock.

PATCH /v1/posts/post_31ba
{
  "publish_at": "2026-08-19T09:00:00+04:00",
  "channels": ["ch_x_kevin", "ch_bs_kevin"]
}

Setting draft: false is how a draft is released into the queue; draft: true pulls a scheduled post back into the drawer. Once a post has started publishing it is finished, and the call is refused with 409 post_not_editable rather than half-applied. post_type is the one field that cannot be patched: a post keeps the shape it was created with, and quietly turning a Story into a feed post is the substitution this API exists to prevent.

Uploading a file

You do not have to host anything. POST /media/upload-url takes a content type and a byte count and returns a presigned PUT address plus the public address the file will have. Send the bytes straight to it — they never pass through the API — and the media row comes back on the same response, ready to name in a post.

POST /v1/media/upload-url
{ "content_type": "video/mp4", "bytes": 18234112 }

{
  "upload_url": "https://…",
  "public_url": "https://media.postwharf.com/…",
  "media": { "id": "med_8f2a" }
}

Both headers the presigned address was signed with — Content-Type and Content-Length — have to be sent on the PUT, and the length has to match the byte count you asked with.

POST /media is the other half: a file you already host, registered by address. It does not fetch the address by default. Pass verify: true to have it checked once, there and then, so a link that has expired is refused while you are still holding it rather than four weeks later as a scheduled post with nothing behind it.

Describing an image

POST /media and POST /media/upload-url both take an optional alt: a description of the file, up to 2,000 characters. A whitespace-only description is stored as none. GET /media returns it. Networks that carry a per-image description publish it — on Bluesky it becomes the image's alt text.

POST /v1/media
{
  "url": "https://cdn.example.com/desk.jpg",
  "kind": "image",
  "alt": "Two people at a standing desk, mid-conversation."
}

To add or change a description later, PATCH /media/:id with alt. Send null to take the description off. The field is required on this call — a body without alt is a 422 alt_required rather than a silent no-op.

PATCH /v1/media/med_8f2a
{ "alt": "Two people at a standing desk, mid-conversation." }

MCP server

The connector exposes your channels to any MCP client as tools. A workspace key scopes every call to one workspace, so an agent can only ever act on the brand you handed it — and if you run several brands, that means one connection per brand, each with its own key.

Point a hosted client at https://mcp.postwharf.com/mcp with your workspace key.

The quickest way in is from the dashboard. Agent & MCP gives you one block with your key already in it: copy it, paste it into your agent as a message, and the agent sets itself up. The server then tells the agent how to upload a file, post a video and fit a set of pictures onto each network, so you ask in plain words and it does that work itself.

get_workspaceWhich brand this key reaches, its plan and the time zone it schedules in
list_channelsEvery connected channel and its token health
describe_networksWhat each network takes: shapes, file counts, text limits, its own settings
connect_channelAttach a channel to the workspace
stage_uploadAn upload URL and a public address for a file you just made
upload_mediaRegister an image or video you already host, get a media id
describe_mediaWrite or replace the alt text on a file already registered
create_postPublish to one or many channels
schedule_postQueue a post for a given time
schedule_manyQueue a batch — a month of a calendar in one call
save_draftWrite a post and leave it for a person to approve
list_draftsEvery draft in the workspace, saved but not queued
update_postEdit a draft or a queued post before it goes out
publish_draftSend a draft, or queue it for a time
get_statusDelivery state, permalink, error text
list_queueWhat is scheduled and when
cancel_postPull something out of the queue
list_eventsThe delivery receipt feed
healthWhether the API is reachable and which networks it really publishes to

CLI

Good for cron, CI, a post-export hook, an agent, or muscle memory.

# authenticate once — or set POSTWHARF_API_KEY and skip this entirely
wharf auth --key wf_live_…

# what this key can do, before you queue a month of posts
wharf whoami
plan      pro
posts     18 used of 1000, 982 left this month

# what each network takes, without sending a post to find out
wharf platforms

# a local file. It is uploaded for you; nothing needs hosting
wharf post "New reel" --to instagram,tiktok --media ./out/reel.mp4 --at "tue 9am"

# check it against every network first. Writes nothing, uploads nothing
wharf post "Same idea, two lengths" --to instagram,pinterest \
           --text-for pinterest:"the short one" --dry-run

# re-run a batch that died halfway. The ones that landed cost nothing
wharf post "Day 3" --to x --idempotency-key sept-day03-x

# what happened this week
wharf status --since 7d --metrics
✓ 41 live   ⚠ 1 retrying   ✗ 0 failed

# and which of them worked
wharf analytics --days 7 --by post

# pipe a caption in
cat caption.txt | wharf post --to linkedin,x

Webhooks

This is the part every other scheduler leaves out. You are notified when a post lands and when it fails, rather than discovering it a week later.

{
  "event": "delivery.failed",
  "post": "post_31ba",
  "channel": "ch_ig_hs",
  "attempt": 3,
  "error": "token_expired",
  "action": "reconnect_required"
}

Events: delivery.succeeded, delivery.failed, delivery.retrying, channel.token_expiring, channel.disconnected.

token_expiring fires early. Networks expire OAuth tokens every 60–90 days. You get the warning before the channel goes dark, not after a week of posting into nothing.

AI clients

C

Claude Code

Add the connector and your channels become tools. Drafting, queueing and status all work from the terminal.

M

MCP clients

Any client speaking Streamable HTTP MCP can connect to the hosted server with a workspace key.

⏱

Cron & n8n

The CLI exits non-zero on failure, so a scheduled job fails loudly instead of silently.

{ }

Your own code

Plain REST with a bearer token. No SDK required, though one is coming.

Limits & errors

We publish no per-key rate limit. Publishing throughput is bounded by each network's own limits, not ours, and we queue against them rather than failing. Sign-in, signup and password reset are rate limited per address and per account.

CodeMeaningWhat to do
401Bad or revoked keyReissue in Settings → API keys
403Key is scoped to another workspaceUse that workspace's key
409Channel needs reconnectingToken expired — re-run OAuth
422Invalid content, platform, or request-only connectorUse the error code and hint; connector_request_only means request it instead
429Rate limitedBack off; Retry-After is set