API reference

Every endpoint the API serves. Base URL https://api.postwharf.com. This page is generated from the route table and a documented endpoint that stops existing fails the build.

Authentication

Two credentials reach these routes and they are not interchangeable.

  • Workspace API key — wf_live_…, sent as Authorization: Bearer. Scoped to one workspace, for machines: the CLI, the MCP server, your own code.
  • Session — sess_…, normally an httpOnly cookie, for a human in the dashboard. Must also name the workspace via X-Workspace or ?workspace=, and be a member of it.

Never ship a workspace key to a browser. It can publish to every channel in the workspace and does not expire. Keep it server-side and rotate it from Settings → API keys if it leaks.

Errors

Every error is JSON with an error field naming the cause.

StatusMeans
401Missing or invalid credential.
402Plan limit reached. The body names the limit, the plan, what was used and what is allowed.
403Authenticated, but not a member of the workspace you asked for.
404No such resource in this workspace.
409The request conflicts with current state — cancelling a finished post, deleting media a post still uses.
422The request is malformed, and the body says which field.
429Rate limited. Retry-After says how long.
503A dependency this route needs is not configured on the deployment.

A 402 is not “slow down” — it is “the plan you are on does not include this”, which is why it is distinct from 429.

Paging

List endpoints take ?limit=. Defaults are 50 (60 for media), the cap is 200, and anything unparseable falls back to the default rather than erroring.

Authentication

POST /v1/auth/signup none

Create a user, their first workspace and a session in one call.

email
Required. Must be unique.
password
Required, 8 characters minimum.
name
Optional display name.
workspace
Optional workspace name; defaults from the user name.

POST /v1/auth/login none

Exchange email and password for a session. Sets an httpOnly cookie and returns the token for non-browser callers.

POST /v1/auth/logout session

Delete the current session and clear the cookie.

GET /v1/auth/me session

The signed-in user and every workspace they can act on. Never includes an API key.

DELETE /v1/auth/me session

Erase the account. Workspaces the user solely owns are deleted with it; shared ones survive and the user is removed. Cascades to channels, media, posts, deliveries and webhooks.

POST /v1/auth/forgot none

Email a single-use reset link. Answers 202 whether or not the address has an account, so it cannot be used to discover which emails are registered. Returns 503 when no mail transport is configured — the link is never returned to the caller.

email
Required.

POST /v1/auth/reset none

Set a new password from a reset token. The link works once and expires after an hour. Every existing session for that user is destroyed and a fresh one is returned.

token
Required. From the emailed link.
password
Required. At least 8 characters.

GET /v1/auth/methods none

Which sign-in methods this deployment has. The sign-in page uses it to show the Google button only when the server holds credentials, rather than advertising a method that fails.

GET /v1/oauth/google/start none

Begin Sign in with Google. Redirects to Google's consent screen with a signed, ten-minute state. Returns 503 when no Google credentials are configured. This is authentication into PostWharf — not permission to publish to a network.

GET /v1/oauth/google/callback none

Google returns here. Verifies state, exchanges the code server-side, and requires a Google-verified email before matching it to an account. On success it sets the same session cookie a password login would and redirects to the dashboard; on any refusal it redirects to /auth?error=… and sets no cookie.

Workspaces and keys

GET /v1/workspaces/:id/key session

Reveal the workspace API key. Deliberately on request rather than in every response — the dashboard authenticates as a session and never needs it.

POST /v1/workspaces/:id/key/rotate session

Issue a new key and invalidate the old one immediately.

POST /v1/workspaces session

Create another workspace. Capped by the plan you already own, and the new workspace inherits that plan. Returns 402 naming the limit when the tier does not include another. A workspace API key cannot call this — a key belongs to one workspace.

name
Required.
timezone
Optional IANA zone name. Decides what a zoneless <code>publish_at</code> like <code>2026-09-01T09:00</code> means for this workspace. Defaults to UTC.

GET /v1/workspaces/allowance session

How many workspaces you hold, how many the plan allows (null is unlimited), and which plan that is.

GET /v1/workspace key or session

The current workspace: name, plan, plan status, created date and the timezone it schedules in.

PATCH /v1/workspace key or session

Set the clock this workspace schedules on. Only times sent AFTER this move: everything already queued is stored as an absolute instant, so a post set for 09:00 London stays at that instant whatever the zone is later changed to.

timezone
Required. An IANA zone name such as <code>Europe/Berlin</code>. Refused with 422 when it is not a zone this runtime knows.

Channels

GET /v1/channels key or session

Every channel in the workspace. The stored credential is dropped in the query, so it cannot leak through this route; `connected` reports whether one exists.

POST /v1/channels key or session

Connect an account. The networks you can self-serve (Bluesky and Telegram) take a `credentials` object, sealed with AES-256-GCM before storage. Reddit, Discord, Snapchat and WordPress are request-only and return `connector_request_only` instead of creating a placeholder channel.

platform
Required. A connectable platform; request-only platforms are refused.
handle
Required. The account this publishes to.
credentials
Optional object; shape depends on the platform.
token_expires_at
Optional ISO-8601 instant, used to warn before expiry.

DELETE /v1/channels/:id key or session

Disconnect. Queued deliveries to this channel will fail.

GET /v1/connector-requests key or session

The request-only connectors this workspace has asked for.

POST /v1/connector-requests key or session

Request Reddit, Discord, Snapchat or WordPress. Idempotent per workspace and platform; it records demand but creates no channel and enables no publishing.

platform
Required. One of <code>reddit</code>, <code>discord</code>, <code>snapchat</code> or <code>wordpress</code>.

Posts

POST /v1/posts key or session

Create a post and one delivery per channel. Omit `publish_at` to publish now. Counts once against the plan regardless of how many channels it fans out to. Send draft: true to keep it without sending it.

channels
Required, non-empty array of channel ids in this workspace — unless this is a draft, which may have none yet.
text
Required unless `media` is given.
media
Optional array of media ids owned by this workspace. The order you send is the order they publish in.
publish_at
Optional ISO-8601 instant. Store an instant, not a local time.
overrides
Optional map of channel id to per-channel overrides.
draft
Optional boolean. <code>true</code> saves without sending and skips the channel requirement and the plan count; anything other than a boolean is refused rather than guessed at.
post_type
Optional shape of the post — <code>feed</code>, <code>reel</code>, <code>short</code>, <code>story</code>, <code>carousel</code> or <code>article</code>. Refused with 422 when a network on this post does not offer that shape. A post keeps its type for life.

GET /v1/posts key or session

Recent posts with their deliveries. `?limit=` defaults to 50, capped at 200. `?status=draft` returns the drawer rather than the queue; an unknown status is refused with 422 rather than answered with an empty list.

GET /v1/posts/:id key or session

One post: status, text, media, and a delivery per channel carrying status, attempts, the live URL and the error text.

PATCH /v1/posts/:id key or session

Change a draft, or a post that has not gone out. Every field is optional and leaving one out means "leave it alone", so moving a time cannot blank the text. The MERGED post is validated, not the patch. draft: false is how a draft is released into the queue. Returns 409 once a post has started publishing, and 422 for post_type, which a post keeps for life.

text
Optional. Replaces the text.
media
Optional array of media ids. Replaces the whole list, which is also how you reorder one.
channels
Optional array of channel ids. Replaces the whole list.
publish_at
Optional ISO-8601 instant, or <code>null</code> to publish on release.
overrides
Optional map of channel id to per-channel overrides. Replaces the whole map.
draft
Optional boolean. <code>false</code> sends it to the queue, <code>true</code> pulls it back into the drawer.
post_type
Optional, and only while it has never been set: a post keeps its type for life, so changing an existing one is refused with 422.

DELETE /v1/posts/:id key or session

Cancel a post that has not finished, or throw a draft away. Returns 409 if it already has.

Media

POST /v1/media/upload-url key or session

A presigned PUT so the browser uploads straight to the staging bucket. Staging is not storage: the file is there to reach the network, and it is deleted seven days after the post publishes or is cancelled. Returns 503 when no bucket is configured and 402 when the staging ceiling would be exceeded.

content_type
Required. JPEG, PNG, WebP, GIF, MP4, MOV or WebM.
bytes
Required. 25MB image cap, 512MB video cap.
alt
Optional description of the image, up to 2,000 characters. Can also be written later with PATCH.

GET /v1/media key or session

The workspace media library, newest first. Pages with ?cursor= from the previous response's next; next is null on the last page. ?ids=a,b,c answers a different question — the files behind a post's attachment ids, whether or not they are still inside the newest window.

limit
Optional. Defaults to 60, capped at 200.
cursor
Optional. The <code>next</code> value from the previous page. A cursor that cannot be read is refused with 422 rather than silently answered with page one.
ids
Optional comma-separated media ids, up to 50. Returns the rows that exist; ids from another workspace are simply absent.

GET /v1/media/:id key or session

One file, by id — the same row shape the library returns, and a 404 rather than an empty list when it has gone.

POST /v1/media key or session

Register media you already host, by URL. Nothing is uploaded or stored, so this consumes no staging budget — it is the route to prefer from the API, CLI and MCP server.

url
Required. Must be public https; loopback, private ranges and cloud metadata addresses are refused.
kind
image or video.
alt
Optional description of the image, up to 2,000 characters. PostWharf publishes it on Bluesky today and not yet on the other networks; it is stored against the file either way, so every post that uses the picture has it.

PATCH /v1/media/:id key or session

Write or replace the description on a file that is already registered, including one attached to a draft or to a post that has not gone out. alt is the only field, and it is required: leaving it out is answered with 422 alt_required rather than a silent success. The URL and the kind are facts about the file and cannot be patched.

alt
Required. The description, or <code>null</code> to take one off.

DELETE /v1/media/:id key or session

Delete a file and its stored object. Returns 409 if a live post still references it.

Usage and events

GET /v1/usage key or session

This calendar month against the plan: posts used and allowed, media bytes used and allowed, and whether uploads are possible at all on this deployment.

GET /v1/analytics key or session

What each network reported about the posts you sent there, one row per network. Figures are never added across networks: the same post on two networks is two audiences and two counts. Every figure carries the number of deliveries that reported it, and the oldest and newest measurement behind it. Some networks report no figures at all, and say so rather than showing zeroes.

days
Optional, 7 or 30. Default 30. Networks stop reporting new figures about a month after a post goes out.

GET /v1/events key or session

The receipt feed — `delivery.succeeded`, `delivery.retrying`, `delivery.failed`, `channel.token_expiring`. A failure carries an `action` field naming what to do.

Webhooks

GET /v1/webhooks key or session

Registered endpoints.

POST /v1/webhooks key or session

Register an endpoint. Deliveries are signed; private addresses are refused unless explicitly allowed.

url
Required, https.
events
Optional array; defaults to all.

DELETE /v1/webhooks/:id key or session

Remove an endpoint.

GET /v1/webhooks/:id/deliveries key or session

Attempts for one endpoint, with response codes.

Health

GET /healthz none

Liveness, plus which posting provider is configured.