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 asAuthorization: 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 viaX-Workspaceor?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.
| Status | Means |
|---|---|
| 401 | Missing or invalid credential. |
| 402 | Plan limit reached. The body names the limit, the plan, what was used and what is allowed. |
| 403 | Authenticated, but not a member of the workspace you asked for. |
| 404 | No such resource in this workspace. |
| 409 | The request conflicts with current state — cancelling a finished post, deleting media a post still uses. |
| 422 | The request is malformed, and the body says which field. |
| 429 | Rate limited. Retry-After says how long. |
| 503 | A 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.
- 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.
- 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.