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_..."
REST API
Base URL https://api.postwharf.com/v1. JSON in, JSON out.
Every route, with its parameters and refusals, is on the API reference.
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.
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.
AI clients
Claude Code
Add the connector and your channels become tools. Drafting, queueing and status all work from the terminal.
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.
| Code | Meaning | What to do |
|---|---|---|
| 401 | Bad or revoked key | Reissue in Settings → API keys |
| 403 | Key is scoped to another workspace | Use that workspace's key |
| 409 | Channel needs reconnecting | Token expired — re-run OAuth |
| 422 | Invalid content, platform, or request-only connector | Use the error code and hint; connector_request_only means request it instead |
| 429 | Rate limited | Back off; Retry-After is set |