REST API
Programmatically create posts, manage accounts, and schedule content via the posterly API.
The posterly API lets you schedule posts, manage accounts, and upload media programmatically.
Public signup for agents
Agents and AI tools can start a signup with API and MCP access without an existing posterly session. It starts a 7-day free trial of posterly with API and MCP access. A card is required. You are charged on day 8, unless you start the paid plan sooner. API and MCP are limited during the trial (20 writes and 100 reads per hour, 25 posts in total, and no batch posting, AI video, webhooks or OAuth clients). Agent signup is monthly only with the add-on:
curl -X POST https://www.poster.ly/api/v1/signup \
-H "Content-Type: application/json" \
-d '{
"email": "owner@example.com",
"name": "Example Owner",
"tier": "starter",
"api_addon": true,
"source": "chatgpt"
}'
The response returns a posterly checkout_redirect_url, raw Stripe checkout_url, signup poll_url, dashboard/login URLs, and an optional OAuth authorize URL when the request includes a registered client_id, redirect_uri, and PKCE challenge. It always includes payment and a billing block that says a card is required, that nothing is charged today (charged_today: false, trial_period_days: 7), and how an agent with its own approved payment credential can complete checkout_redirect_url. Nothing is due today, so payment.amount is null. Do not use a one-time virtual card for the trial: it becomes the card on file and the day 8 charge fails, so use a reusable card the person approved or let them enter their own card. Agent card pay (agent_pay) is not available during a trial. An account that already had a trial is charged at checkout instead (trial_period_days: 0). See Pay for posterly from an agent for how an agent hands the trial Checkout to the person (and why one-time cards must not be used). Poll the signup session while the card is saved and password setup progress.
If the email already belongs to a posterly account, the endpoint changes nothing, creates no Checkout session, and sends no email. It returns HTTP 200 with status: "sign_in_required", a message, login_url, dashboard_url, and oauth_authorize_url when an OAuth handoff was supplied. The response is identical for every existing account and says nothing about plan or billing, so tell the user to sign in at login_url and manage their plan from the dashboard. An unconfirmed signup that was started earlier but never paid is not treated as an account, so once the 24 hour per-email window has passed it gets a fresh Checkout as usual (inside that window the endpoint returns 429, so reuse the checkout_redirect_url from the earlier signup).
The public, crawlable signup handoff page is /agents/signup. Use it when an agent needs a human-readable explanation of the same trial signup and account-connection flow.
If an AI client supports MCP but not generic HTTP requests, install posterly-mcp-server@latest without POSTERLY_API_KEY and use the public MCP tools start_signup and get_signup_session for the same pre-auth signup handshake.
API signup is not a free-token path: no API key is returned from signup. After the card is saved and the trial starts, the verified user lands on the API page and posterly auto-creates the first API key if none exists, showing it once. OAuth clients receive their access token after the user completes consent, then should ask which social account to connect and create a connect session.
Authentication
All API requests require an API key. Generate one in Dashboard → API & MCP.
Pass your key in the Authorization header:
Authorization: Bearer YOUR_API_KEY
A key acts as the person who created it, with that person's workspace role. It reaches every post, account, and brand in the workspaces that person actively belongs to, teammates' posts included, with the same role rules as the dashboard (or slightly stricter, noted below): there is no separate "your own posts" scope. Creating or rescheduling a post, disconnecting an account, and starting a credential connection need the Publisher role or higher in the account's workspace; editing a draft's content needs Editor, but editing a post that is already scheduled, paused, or failed needs Publisher, and a PATCH that sets scheduled_at always needs Publisher, even on a draft, which is stricter than the dashboard; creating a workspace-scoped key needs Admin. A request the role does not allow returns 403 with code: "insufficient_workspace_permissions" and the required_role. Brand-locked seats are held to their brands as well: rows outside them answer 404, and a connect started by a brand-locked seat must pass workspace_client_id for one of its brands (403 brand_required otherwise), which the newly connected account is placed in. Reconnecting an account that already lives in another brand, or workspace-wide with no brand, answers 404 account_not_found before any credential is stored. A workspace whose billing is overdue answers post update, delete, status-change, and release-id requests with 402 billing_write_blocked; reads keep working. See Teams & Roles.
Base URL
https://www.poster.ly/api/v1
Quickstart
The fastest end-to-end flow is: confirm who you are, list your connected accounts, upload media, validate the complete payload with dry_run: true, show the preview, obtain explicit confirmation, then schedule the live post. The examples below run against production with a key from Dashboard → API & MCP.
Node.js
No SDK needed; the built-in fetch in Node 18+ covers everything:
const BASE_URL = "https://www.poster.ly/api/v1";
const headers = {
Authorization: "Bearer pst_live_YOUR_API_KEY",
"Content-Type": "application/json",
};
async function main() {
// 1. Confirm the authenticated user and scopes
const whoami = await fetch(`${BASE_URL}/whoami`, { headers }).then((r) => r.json());
console.log("Authenticated as", whoami.user.email, "scopes:", whoami.api_key.scopes);
// 2. List connected social accounts and pick one
const { accounts } = await fetch(`${BASE_URL}/accounts`, { headers }).then((r) => r.json());
const account = accounts[0];
console.log("Posting to", account.platform, account.username);
// 3. Copy a public image into posterly storage
const media = await fetch(`${BASE_URL}/media/upload-from-url`, {
method: "POST",
headers,
body: JSON.stringify({
url: "https://example.com/launch-photo.jpg",
filename: "launch-photo.jpg",
}),
}).then((r) => r.json());
// 4. Schedule the post
const res = await fetch(`${BASE_URL}/posts`, {
method: "POST",
headers,
body: JSON.stringify({
account_id: account.id,
caption: "Launch notes are live.",
media_urls: [media.url],
scheduled_at: "2026-08-01T14:00:00Z",
}),
});
const { post } = await res.json();
console.log("Post", post.id, "is", post.status, "for", post.scheduled_at);
}
main().catch(console.error);
Python
The same flow with requests:
import requests
BASE_URL = "https://www.poster.ly/api/v1"
headers = {"Authorization": "Bearer pst_live_YOUR_API_KEY"}
# 1. Confirm the authenticated user and scopes
whoami = requests.get(f"{BASE_URL}/whoami", headers=headers).json()
print("Authenticated as", whoami["user"]["email"], "scopes:", whoami["api_key"]["scopes"])
# 2. List connected social accounts and pick one
accounts = requests.get(f"{BASE_URL}/accounts", headers=headers).json()["accounts"]
account = accounts[0]
print("Posting to", account["platform"], account["username"])
# 3. Copy a public image into posterly storage
media = requests.post(
f"{BASE_URL}/media/upload-from-url",
headers=headers,
json={"url": "https://example.com/launch-photo.jpg", "filename": "launch-photo.jpg"},
).json()
# 4. Schedule the post
post = requests.post(
f"{BASE_URL}/posts",
headers=headers,
json={
"account_id": account["id"],
"caption": "Launch notes are live.",
"media_urls": [media["url"]],
"scheduled_at": "2026-08-01T14:00:00Z",
},
).json()["post"]
print("Post", post["id"], "is", post["status"], "for", post["scheduled_at"])
Zapier
posterly does not have a native Zapier app yet, but both directions work today with Zapier's built-in tools:
Actions (Zap creates a posterly post): use the Webhooks by Zapier action with these settings:
- Event:
Custom Request - Method:
POST - URL:
https://www.poster.ly/api/v1/posts - Headers:
Authorization: Bearer pst_live_YOUR_API_KEYandContent-Type: application/json - Data:
{
"account_id": "123",
"caption": "New blog post is live: {{title}}",
"media_urls": ["{{image_url}}"],
"scheduled_at": "2026-08-01T14:00:00Z"
}
Get the account_id values once by calling GET /api/v1/accounts (curl or the Node/Python snippets above).
Triggers (posterly events start a Zap): use the Webhooks by Zapier trigger (Catch Hook), copy the hook URL Zapier gives you, then register it as a posterly outbound webhook:
curl -X POST https://www.poster.ly/api/v1/webhooks \
-H "Authorization: Bearer pst_live_YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"url": "https://hooks.zapier.com/hooks/catch/xxx/yyy/",
"events": ["post.published", "post.failed"]
}'
posterly will now call your Zap whenever a post publishes or fails. Supported events also include post.created, post.updated, post.deleted, post.publishing, account.disconnected, account.needs_reconnect, account.reconnected, and analytics.synced. Use POST /api/v1/webhooks/{id}/test to send a test delivery while building the Zap.
n8n
Create posts with the HTTP Request node:
- Method:
POST - URL:
https://www.poster.ly/api/v1/posts - Authentication:
Generic Credential Type→Header Auth, nameAuthorization, valueBearer pst_live_YOUR_API_KEY - Body Content Type:
JSON - Body:
{
"account_id": "123",
"caption": "{{ $json.caption }}",
"media_urls": ["{{ $json.image_url }}"],
"scheduled_at": "{{ $json.publish_time }}"
}
React to posterly events with the Webhook node: create a workflow starting with a Webhook node (method POST), copy its production URL, and register it via POST /api/v1/webhooks exactly as in the Zapier example. Each delivery is signed with an X-posterly-Signature header (t=<unix>,v1=<hmac_sha256>) you can verify in a Code node using the webhook secret returned on create.
Make
Create posts with the HTTP → Make a request module:
- URL:
https://www.poster.ly/api/v1/posts - Method:
POST - Headers:
Authorization: Bearer pst_live_YOUR_API_KEY - Body type:
Raw, Content type:JSON (application/json) - Request content:
{
"account_id": "123",
"caption": "Launch notes are live.",
"media_urls": ["https://example.com/launch-photo.jpg"],
"scheduled_at": "2026-08-01T14:00:00Z"
}
React to posterly events with the Webhooks → Custom webhook module: add the module, copy the webhook address Make generates, and register it via POST /api/v1/webhooks with the events you care about (for example post.published and post.failed), the same call shown in the Zapier section.
Scopes
Every API key is granted a set of scopes. The default set on new keys covers everything the public surface offers today:
accounts:read, list connected social accounts and read posterly's publishing statusaccounts:write, disconnect connected social accountsposts:read/posts:write, read and schedule postsmedia:write, upload images and videoanalytics:read, read per-account and per-post analyticsbilling:read, read your AI credit balance and posterly subscription status. Over OAuth, only posterly's listed connectors (Claude, ChatGPT, Cursor and the like) can hold it, after a reconnect. A self-registered OAuth app or developer app cannot; use an API key with Read billing.billing:write, cancel, pause, resume, or downgrade your posterly subscription, and (Agency owners) buy or remove extra connected accounts
These scopes are never on by default:
review_links:write, create one-time sign-in links that open a client's review page as that client. See Client portal sign-in links.members:readandmembers:write, list and manage workspace colleagues. On a key, both come from one checkbox, Manage team. ChatGPT, Claude, and other OAuth connections hold them automatically, limited to workspaces where you are an owner or admin. See Team management.
Keys issued before analytics:read existed have been backfilled automatically, no regeneration needed. The two billing scopes are separate opt-ins when you create a key. Read billing adds billing:read and can change nothing, so it is safe to grant to a tool you want to ask about credits. Manage billing adds billing:write for cancel, pause, resume and downgrade; only share those keys with tools you trust.
review_links:write is a third opt-in, and a stricter one. It is granted only when a person ticks Create client portal sign-in links while creating a key in /dashboard/api. POST /api/v1/api-keys can neither inherit it nor request it, so a key an agent mints for itself can never carry it. Manage team (members:read + members:write) works the same way: only a person can grant it, in /dashboard/api, and ChatGPT or Claude connections that sign in with OAuth cannot hold it.
Workspace scoping
API keys can be scoped to a single workspace. Connection handoff and webhook endpoints use that workspace automatically when workspace_id is omitted. If those requests include a different workspace_id, posterly returns 403 API key is scoped to a different workspace. Removing a member from a workspace revokes that member's workspace-scoped API keys for that workspace before the seat is removed. If those keys cannot be revoked, the member stays.
Unscoped keys can still pass workspace_id on workspace-aware endpoints. posterly verifies the API-key user belongs to that workspace, and holds the role the endpoint needs, before returning data or creating resources. A connect session or credential connection started by an unscoped key without workspace_id targets the key owner's personal workspace; pass workspace_id to connect into a team workspace.
A key only ever reaches the workspaces its owner may use through the API: their own workspace when they have the API add-on, and any teammate's workspace where the owner turned on API access for them. Every other workspace (and, for a teammate without their own add-on, their personal workspace and its older rows with no workspace) answers exactly as if they were not a member. See Teammates and API access.
Teammates and API access
A workspace owner with the API add-on can let teammates use it. Nobody has it by default. The owner or an admin turns on API and AI access for a teammate in Workspace → Team (or over the API, below). Admins can change it for publishers, editors and viewers; only the owner changes an admin's. Nobody can change their own.
With it on, the teammate uses their own API keys and AI app connections (ChatGPT, Claude, and others) in that workspace, with the same role and brand limits as in the dashboard. A viewer gets read-only API access: AI generation (images, video, video helpers, captions, review reply suggestions) and media uploads (upload, upload from URL, signed upload, media drops) answer 403 insufficient_workspace_permissions for them; reads stay open. They do not need a plan or add-on of their own for it. Their usage, AI credits, quotas and feature limits count against the workspace owner's plan and wallet.
- The owner's switch always wins. Even a teammate who pays for their own add-on reaches the owner's workspace only while the switch is on. Their own add-on covers their own workspace.
- Keys. In
/dashboard/apithe teammate sees that access is included with the workspace's plan and creates keys bound to that workspace.POST /api/v1/api-keysfrom such a teammate also creates a bound key (passworkspace_idwhen several workspaces qualify). Each teammate can hold as many keys per workspace as the owner's plan allows one person (Starter 1, Pro 2, Power 3, Agency 4). AI app connections do not count. The owner is emailed when a teammate creates a key or connects an AI app to the workspace. - AI app connections. When a teammate's only API access comes from one workspace, a new ChatGPT, Claude or other AI app connection is bound to it, and the consent screen says so. It stays bound when its token refreshes; reconnect to change it. When several workspaces qualify, the connection is not bound and calls that need one take
workspace_id. - Naming a workspace. A
workspace_id(query string, or JSON body where accepted) must be a workspace your key may use through the API, otherwise the call answers403 insufficient_workspace_permissionsbefore anything else runs. Post creation is counted against the workspace each post lands in (itsworkspace_id, or its account's workspace); a batch that spans several workspaces counts once against each. - Which workspace a call uses. An unscoped key keeps its personal workspace as the default whenever that workspace is usable through the API, exactly as before. Otherwise, if exactly one workspace qualifies it is the default; if several do, calls that need a workspace answer
400 workspace_id_requireduntil you passworkspace_id. Pass it as a query parameter, or in the JSON body ofPOST /api/v1/ai/generate-image,/ai/generate-video,/media/upload,/media/upload-from-url,/media/signed-uploadand/media/drop-sessions; that workspace's owner plan and credits pay for the call, and a media drop link remembers its workspace so the upload counts against the same plan.GET /api/v1/whoamilists each workspace withapi_access,api_access_source(ownorinherited),brand_accessandbrand_ids, plusworkspaces_without_api_accessandworkspace_id_required. - Turning it off. Switch it off, or remove the teammate, and their keys and AI app connections stop working in that workspace on the next request (a key bound to it answers
403 api_access_not_granted), and their webhooks bound to the workspace are switched off. Unbound webhooks stop receiving that workspace's events. - Owner loses the add-on. Teammates lose inherited access at the same moment.
Owners and admins change it with PATCH /api/v1/workspaces/{workspace_id}/members/{member_id} and { "api_access": true } (or false), on its own, preview then confirm like every team change. GET .../members shows api_access for every member, and for owners and admins an api_usage summary (key count, connected AI apps, last used). Changing your own answers 403 cannot_change_own_access; the owner row answers 403 owner_api_access_fixed; an admin changing another admin's answers 403 insufficient_workspace_permissions.
Endpoints
Identity and platform discovery
GET /api/v1/whoami
GET /api/v1/platforms
GET /api/v1/accounts/{id}/schema
POST /api/v1/platforms/trigger
Use whoami to confirm the authenticated user, scopes, default workspace, and available workspaces. Use platform discovery before creating posts with settings; it returns supported post types, media limits, content limits, first-class settings fields, analytics support, and helper tools.
Available helper tools currently include pinterest.boards, youtube.playlists, tiktok.creator_info, linkedin.recent_mentions, linkedin.company_lookup, and x.quota. youtube.playlists is refused until YOUTUBE_PLAYLIST_INSERT_ENABLED is true. linkedin.company_lookup is refused until LINKEDIN_COMPANY_LOOKUP_API_ENABLED is true, and then it only calls the organization vanity lookup the composer already uses. A match can be any public company page, so the result carries posting_access: "not_checked": finding a company does not mean the account can post as it. Pass include_planned=true to GET /api/v1/platforms to see the integration backlog and possibilities from the provider registry. Pass include=feature_support to add a feature_support list to every platform: for each feature, whether it is supported, the stated reason, whether it is a documented platform API limitation, the connection type it needs, any numeric limits, and the source. A reason of null means no reason is documented. The list leaves feature_support out by default to stay small. A single platform (platform=) and GET /api/v1/accounts/{id}/schema always include it.
Helpers read or refresh the connected account's token, so POST /api/v1/platforms/trigger needs the Editor role or higher in the workspace that owns the account, including for an account a teammate connected. A viewer gets 403 with code: "insufficient_workspace_permissions" and required_role: "editor". An account you cannot reach answers 400 with account_id not found or not owned by you. Other helper failures return a short generic message; the details stay in posterly's logs. GET /api/v1/accounts/{id}/schema is a read, so any member of the workspace can use it.
The human-readable registry page is at /api-platforms.
List accounts
GET /api/v1/accounts
Returns all connected social accounts in your workspace.
Each account includes connection_status (ok or needs_reconnect), connection_reason, and connection_since. connection_reason is reconnect_required when the account is flagged for a reconnect, or access_token_expired when the access token has expired and cannot refresh itself. When connection_status is ok, connection_reason and connection_since are both null. connection_since is an ISO 8601 UTC time: when the reconnect was flagged, or when the token expired. The response never includes the token or the provider's error text. The same change is pushed once, as account.needs_reconnect or account.reconnected (see Activity and webhooks).
Filter with connection=ok or connection=needs_reconnect. Any other value answers 400 with code: "validation_error" and the message connection must be ok or needs_reconnect.
Disconnect an account
DELETE /api/v1/accounts/{id}
Disconnects a connected social account owned by the caller. This requires accounts:write and should be treated as irreversible: posterly deletes the account connection, preserves reconnect metadata on posts, archives Instagram analytics where available, and emits account.disconnected webhooks.
Agents should call GET /api/v1/accounts first, show the exact id, platform, username, and workspace_id, and only call delete after explicit user confirmation.
Account connection handoff
GET /api/v1/connect
GET /api/v1/connect/{platform}
POST /api/v1/connect/{platform}/credentials
POST /api/v1/connect/{platform}/sessions
GET /api/v1/connect/sessions/{session_id}
Use these endpoints when an agent or external workflow needs to help a user connect another channel. They return the dashboard connection_url, connection method (dashboard_oauth, manual_credentials, or planned), provider scopes, missing environment configuration, and matching connected accounts.
Direct public OAuth URLs are intentionally not returned yet. The current provider callbacks depend on short-lived browser state, provider-mode cookies, and PKCE verifiers, so agents should open connection_url in a logged-in browser session.
For a smoother agent experience, create a connect session instead of only fetching the static handoff link:
- Call
POST /api/v1/connect/{platform}/sessions. - Show or open the returned
connect_session.connect_urlfor the user. - Poll
connect_session.poll_url. - Narrate the returned
status_messageuntil the status becomesconnected,failed,cancelled, orexpired.
The dashboard updates the session as the user moves through the flow: created -> opened -> awaiting_provider or awaiting_credentials -> connected. This lets agents say things like "waiting for Instagram approval" or "connection completed" instead of leaving the user guessing.
Examples:
curl -H "Authorization: Bearer pst_live_xxx" \
https://www.poster.ly/api/v1/connect/instagram
{
"connect": {
"platform": "instagram",
"label": "Instagram",
"method": "dashboard_oauth",
"connection_url": "https://www.poster.ly/dashboard/connect?platform=instagram",
"oauth_url": null,
"requires_browser_session": true,
"configured": true,
"connected_count": 1
}
}
Create a live connection session:
curl -X POST \
-H "Authorization: Bearer pst_live_xxx" \
-H "Content-Type: application/json" \
-d '{"workspace_id":"2d0a6d4c-9eef-4ee2-9026-2ca0dc29c8d6","auto_start":true}' \
https://www.poster.ly/api/v1/connect/instagram/sessions
{
"connect_session": {
"id": "8ec5ae8c-fd6b-4e0b-83d7-0f88c3f5e9e1",
"platform": "instagram",
"method": "dashboard_oauth",
"status": "created",
"status_message": "Connection link created. Waiting for the user to open posterly.",
"connect_url": "https://www.poster.ly/dashboard/connect?platform=instagram&connect_session=8ec5ae8c-fd6b-4e0b-83d7-0f88c3f5e9e1&auto_start=1",
"poll_url": "https://www.poster.ly/api/v1/connect/sessions/8ec5ae8c-fd6b-4e0b-83d7-0f88c3f5e9e1",
"connected_count": 0
}
}
Direct credential connection (no browser)
POST /api/v1/connect/{platform}/credentials
Credential-based platforms (telegram, bluesky, discord, wordpress, devto, hashnode, lemmy) can be connected headlessly with accounts:write. Fetch GET /api/v1/connect/{platform} first: its credential_fields array lists exactly which keys the platform needs, and supports_direct_credentials: true plus credentials_endpoint confirm the platform accepts this flow. These seven platforms now also report requires_browser_session: false in the connect options payload. Mastodon and all OAuth platforms are excluded; use connect sessions for those.
Send the fields inside a credentials object. Unknown keys are rejected, required keys must be non-empty strings. Optional workspace_id targets a workspace you are a member of (your personal workspace when omitted on an unscoped key), optional workspace_client_id places a newly connected account in that brand, and optional connect_session_id marks an existing connect session connected or failed based on the outcome. The call has upsert semantics: reconnecting the same account updates it in place and returns 200 either way. A brand-locked seat can only reconnect accounts already in one of its brands; any other existing account answers 404 account_not_found and is left untouched. Prefer scoped secrets (app passwords, bot tokens, webhook URLs, API tokens) over primary passwords.
curl -X POST \
-H "Authorization: Bearer pst_live_xxx" \
-H "Content-Type: application/json" \
-d '{"credentials":{"handle":"posterly.bsky.social","app_password":"xxxx-xxxx-xxxx-xxxx"}}' \
https://www.poster.ly/api/v1/connect/bluesky/credentials
{
"connected": true,
"account": {
"id": 4321,
"platform": "bluesky",
"username": "posterly.bsky.social",
"did": "did:plc:example"
},
"workspace_id": "2d0a6d4c-9eef-4ee2-9026-2ca0dc29c8d6"
}
Rate limit: 20 credential connect attempts per hour per user. Errors return { "error", "code" } with codes like invalid_credentials, plan_limit, provider_unreachable, and credentials_not_supported; credential values are never echoed back.
OAuth developer apps
GET /api/v1/oauth/clients
POST /api/v1/oauth/clients
PATCH /api/v1/oauth/clients/{clientId}
DELETE /api/v1/oauth/clients/{clientId}
Use these endpoints to create public OAuth + PKCE clients for third-party tools that need to act on behalf of a posterly user. Developer clients are owned by the API-key user, use exact redirect URI matching, and never expose client secrets or wildcard redirects.
Direct provider connection URLs are still handled by the dashboard connection handoff endpoints above. OAuth developer apps are for apps authenticating into posterly's API surface.
Example:
curl -X POST https://www.poster.ly/api/v1/oauth/clients \
-H "Authorization: Bearer pst_live_xxx" \
-H "Content-Type: application/json" \
-d '{
"client_name": "Agency approval app",
"allowed_redirect_uris": ["https://agency.example.com/oauth/callback"],
"default_scopes": ["accounts:read", "posts:read", "posts:write"]
}'
Client ID Metadata Documents
The ChatGPT path for people is the plugin listing. This section is the advanced custom connector path.
Hosted MCP clients that present an HTTPS client_id (Claude.ai / Claude Connectors, ChatGPT custom connectors, Gemini Spark, Smithery, and similar) do not have to register first. If the URL is on the trusted connector-host allowlist, posterly fetches the published metadata document and uses its redirect_uris.
Public documents may authenticate at the token endpoint with none (PKCE only) or private_key_jwt. Claude Connectors uses none. ChatGPT custom connectors publish a preference of private_key_jwt plus token_endpoint_auth_methods_supported: ["none", "private_key_jwt"] and an HTTPS jwks_uri on the same trusted host family. When a signed client_assertion is present, posterly verifies it (iss and sub equal the document URL, audience may be the token endpoint, the issuer, or the MCP resource URL, RS256 only). If the assertion is omitted and none is in the client's supported methods, PKCE-only is allowed. Secret-based methods (client_secret_basic, client_secret_post, client_secret_jwt) are rejected.
Claude Connectors uses https://claude.ai/oauth/mcp-oauth-client-metadata with callback https://claude.ai/api/mcp/auth_callback. ChatGPT custom connectors use a per-connector https://chatgpt.com/oauth/.../client.json document and callback https://chatgpt.com/connector/oauth/.... Gemini web sign-in is still being validated. Compatibility support allows Client ID Metadata Documents on accountlinking.google.com (https://accountlinking.google.com/clientidmetadata/...). Redirect URIs in that document may be on googleusercontent.com. Dynamic Client Registration below remains available for Gemini and already accepts those redirect URIs. A jwks_uri, when Gemini uses private_key_jwt, must be on accountlinking.google.com or a subdomain of it. Broader google.com hosts are not trusted. Discovery advertises client_id_metadata_document_supported: true and token_endpoint_auth_methods_supported: ["none", "private_key_jwt"]. DCR below is a separate path and is not what Claude Connectors or ChatGPT CIMD use for Connect.
CIMD clients that authenticate with private_key_jwt receive the same rotating refresh token and 1-hour access token as DCR. Claude CIMD, and ChatGPT when it uses none, stay on the long-lived access-only grant.
Dynamic Client Registration (RFC 7591)
POST /api/oauth/register
For hosted MCP clients that register themselves instead of publishing a Client ID Metadata Document. Send a JSON metadata document and receive a client_id immediately. No auth required. Rate-limited per IP. All registered clients are public PKCE clients; no client_secret is issued. Only supported scopes are granted.
curl -X POST https://www.poster.ly/api/oauth/register \
-H "Content-Type: application/json" \
-d '{
"client_name": "My MCP host",
"redirect_uris": ["https://my-host.example.com/oauth/callback"],
"grant_types": ["authorization_code", "refresh_token"],
"scope": "accounts:read posts:read posts:write"
}'
OAuth token endpoint
POST /api/oauth/token
Authorization-code exchange and refresh-token rotation. DCR clients and CIMD clients that authenticate with private_key_jwt receive a 1 hour access token plus a rotating refresh token (30 days, reuse revokes the chain). Built-in clients and none CIMD clients (Claude, Smithery, or ChatGPT when it omits the assertion) still receive a long-lived access token and no refresh grant. PKCE is required on authorization_code. A client_assertion is verified when present. Clients that also support none may omit it and use PKCE only. Clients that only support private_key_jwt must send a valid assertion.
# Exchange authorization code for tokens
curl -X POST https://www.poster.ly/api/oauth/token \
-H "Content-Type: application/x-www-form-urlencoded" \
-d 'grant_type=authorization_code&code=...&client_id=dyn_...&code_verifier=...&redirect_uri=...'
# Refresh
curl -X POST https://www.poster.ly/api/oauth/token \
-H "Content-Type: application/x-www-form-urlencoded" \
-d 'grant_type=refresh_token&refresh_token=pst_oauth_rt_...&client_id=dyn_...'
Discovery: GET /.well-known/oauth-authorization-server advertises Client ID Metadata Document support, the registration endpoint, refresh-token grant, token_endpoint_auth_methods_supported (none and private_key_jwt), supported scopes, and token endpoint.
Brand and client context
GET /api/v1/brands
GET /api/v1/brands/{id}
GET /api/v1/brands/{id}/accounts
GET /api/v1/brands/{id}/profile
POST /api/v1/brands
PATCH /api/v1/brands/{id}
POST /api/v1/brands/{id}/archive
Use brand endpoints when an external workflow needs the same client context posterly uses in the dashboard. They return workspace-scoped brands/clients, assigned social accounts, and saved brand profile guidance such as voice, tone, audience, and positioning. Reads require accounts:read.
Creating a brand (POST, name plus confirm: true) and updating its name, timezone, or website (PATCH, confirm: true) need an admin, the same as the brand settings page. Archiving (POST /archive) also needs an admin. Call it without confirm to get a preview that changes nothing, then call it with confirm: true to archive. Connected accounts stay connected, and scheduled posts are not cancelled: they still publish. The preview counts both. Writes require accounts:write.
List posts
GET /api/v1/posts
GET /api/v1/posts/counts
Filter with status (including missed), group_id, release_id, platform, account_id, workspace_id, updated_since, created_since, sort_by, order (asc or desc, default desc), limit (default 20, max 200), offset, a scheduled date range, and three client-approval filters: approval_status (one of none, pending_client, approved, changes_requested, rejected), approval_waiting_on (client or team), and brand_id (a brand/client id from GET /api/v1/brands, matching a post's brand_id). approval_status=pending_client and approval_waiting_on=client are the same queue. approval_waiting_on=team is changes_requested or rejected. Sending both filters with no overlap answers 400 with code: "approval_filter_conflict".
status=missed includes a failed post, a scheduled or queued post whose scheduled_at is already in the past, and a paused post that a workspace or brand publishing pause held past its scheduled time. A scheduled, queued, or paused post with no scheduled_at is not included. A future post is not included. Drafts and published posts are not included. group_id matches the post group column and the group ids stored on the post. release_id matches the release id stored on the post. Each value may contain only letters, numbers, a dot, an underscore, a colon, or a hyphen. Anything else answers 400 with invalid_group_id or invalid_release_id.
A month or any window. scheduled_from (inclusive) and scheduled_to take a plain date (2026-10-01) or an ISO 8601 date-time. Plain dates, and date-times without an offset, are read in timezone (an IANA name such as Europe/London, default UTC). A plain-date scheduled_to includes that whole day; a date-time scheduled_to is exclusive. So scheduled_from=2026-10-01&scheduled_to=2026-10-31&timezone=Europe/London&order=asc is all of October in London, oldest first. The response echoes the resolved window as range: { from, to, timezone }. A date that is not real, a bad time zone, or a scheduled_from after scheduled_to answers 400 (invalid_scheduled_from, invalid_scheduled_to, invalid_timezone, invalid_scheduled_range), and a bad order answers 400 invalid_order.
Paging. total is the exact number of posts matching every filter. has_more is true while offset + posts.length is below total, and next_offset is the offset of the next page (null on the last one). Keep calling with offset=next_offset until has_more is false before drawing any conclusion about how many posts there are. The order always ends on the post id, so pages never skip or repeat a post, even when many share the same scheduled time.
Counts. GET /api/v1/posts/counts takes the same filters and scoping, requires scheduled_from and scheduled_to (at most 366 days), and returns total plus by_day (every day in the range in timezone, zero days included), by_status, by_account (with platform and username), and by_brand (with name). Its total always equals the list total for the same query. Grouping reads at most 10,000 posts; past that truncated is true and total stays exact. Use it for "how many posts does each client have this month" instead of paging the whole list.
An unscoped key or session searches every workspace you actively belong to, teammates' posts included, per your role in each one (a brand-locked seat only sees its assigned brands, never posts with no brand assigned); pass workspace_id to scope to one workspace instead. A key created for a personal account with no workspace still sees its own legacy posts. The same applies to the MCP list_posts tool and the dashboard's own agent session. If a PATCH you make as a teammate imports a third-party media URL, it counts against your own storage quota, not the post owner's.
A draft any editor skipped in the Content Engine review queue is always excluded from this list, even if you explicitly pass status=draft - and GET /api/v1/posts/{id} returns a 404 for one, the same as an id that does not exist. If a skipped draft is ever scheduled by some other path, it is reverted to a draft at publish time rather than published; this listing exclusion only affects whether the API can see it.
Every returned post also carries brand_id (the assigned brand/client, or null), content_plan_id (or null), and a nested approval object:
{
"brand_id": "9f6d99a4-b0e0-4ee0-8949-6a87786a52cc",
"content_plan_id": null,
"approval": {
"status": "pending_client",
"asset_status": "approved",
"caption_status": "pending_client",
"waiting_on": "client",
"pending_client_approval": true,
"requested_at": "2026-09-18T14:02:00.000Z",
"completed_at": null
}
}
approval.waiting_on is client when approval.status is pending_client, team when it is changes_requested or rejected, and null otherwise (none or approved). approval.pending_client_approval is true only in that first case. These fields cannot be set through POST/PATCH /api/v1/posts. Change them with POST /api/v1/posts/{id}/approval. An attempt to write client_approval or other server-owned review metadata on create or update answers 400 protected_metadata as documented below.
Posts returned by the read and update endpoints also say when and how they were created. created_at is the creation time (ISO 8601 with time zone). created_by is read-only:
"created_by": {
"user_id": "9a8b7c6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d",
"name": "Olive",
"source": "dashboard",
"source_label": "Dashboard"
}
user_id is the posterly account the post was created under. name is that account's full name, falling back to its username and then its email address when no name is set. It is filled in by GET /api/v1/posts, GET /api/v1/posts/{id}, and PATCH /api/v1/posts/{id} (other responses leave it null). source is how the post was created, for example dashboard, api_v1, mcp_http, mcp_stdio, bulk_upload, or autopublisher. Posts created before the channel was recorded have source: null; nothing is filled in after the fact.
Create a post
POST /api/v1/posts
Create and schedule a new post. See the API Reference for the full request body schema. media_url and media_urls can be posterly media URLs or third-party HTTP(S) URLs. Third-party assets are copied into posterly storage before the post is saved, so short-lived signed URLs must still be live when the request runs. The new post inherits workspace_client_id from the target social account, so it lands under that account's brand on the calendar.
You can post to any account in a workspace you belong to, including one a teammate connected, as long as you have the Publisher role or higher there. A member below Publisher gets 403 with code: "insufficient_workspace_permissions" and required_role: "publisher". An account that is outside your brand limits, in a workspace your key cannot use, or that does not exist answers 404. When you pass username and platform instead of account_id, posterly only matches accounts you can post to, treats % and _ as ordinary characters, and if more than one account still matches it uses the one you connected yourself when exactly one is yours, otherwise it answers 404. Pass account_id to avoid any ambiguity.
The response can include an optional warnings array alongside post and workspace. The post is still created; a warning flags something worth checking before it publishes. Today the only warning is tiktok_pending_inbox_cap: TikTok posts sent to the inbox (no settings.privacy_level/direct-post settings) only holds about 5 pending shares per account at a time, so a post that would push past that limit around its own scheduled time comes back with this warning instead of being blocked.
Platform-specific settings, pass settings to use the same first-class scheduling controls available in the posterly composer. The API maps these into the right platform metadata and publisher fields for you, so you do not need to rely on raw metadata for common platform options:
{
"account_id": "169",
"caption": "Launch notes are live.",
"media_urls": ["https://assets.poster.ly/users/123/reel.mp4"],
"scheduled_at": "2026-05-01T14:00:00Z",
"settings": {
"post_type": "reel",
"first_comment": "Full launch notes: www.poster.ly/updates",
"collaborators": ["partnerbrand"],
"reel_cover_url": "https://example.com/reel-cover.jpg",
"is_trial_reel": true,
"graduation_strategy": "MANUAL"
}
}
Supported settings include:
For accessibility, prefer the ordered media_accessibility array. It keeps a
description attached by media position and, when available, stable asset ID:
{
"media_accessibility": [
{ "index": 0, "asset_id": "optional-asset-id", "source_url": "https://assets.poster.ly/image.png", "alt_text": "A chart showing quarterly growth." }
]
}
The older media_alt_texts URL map is still accepted and returned for backwards
compatibility. Posterly does not shorten the stored text; destination-specific
limits are applied only to the copy sent during publishing. PATCH requests merge
entries by index and preserve omitted descriptions. An empty string clears that
occurrence and suppresses older URL-map text after reload. Send an empty
media_accessibility array (or empty legacy map) to clear all descriptions. When
both forms are supplied, stable entries win for matching occurrences; an empty
stable array clears all. Repeated URLs have separate ordered descriptions; a legacy
URL-map patch applies to every occurrence of that URL.
- Instagram, feed, story, reel, carousel, collaborators, user tags, first comment, alt text, trial Reels, Reel covers, parent-container
is_ai_generated, licensed Reelaudio_id(Facebook Login / Meta-linked only), caption add-ons (a poll or a comment prompt; needs a Meta (Facebook Login) connection for that Instagram account in this workspace, so Instagram Login-only accounts cannot use them; not Stories, carousel parent only), an optional Story caption (same 2,200 character limit and up to 30 hashtags as a feed post; a Story Series sends it with the first slide only; leave it empty for a media-only Story), and optional companion Story posting. A Facebook Story does not take a caption. - Facebook, post, story, reel, cover photo intent, colored text backgrounds, and Reel covers. Facebook Reels through the Graph Reels API must be 90 seconds or shorter (
validate_post/POST /api/v1/postsreject over-limit Reels when duration is known). Usepost_type: "video"for longer clips. - YouTube, title, thumbnail, privacy status, made-for-kids, tags, category, playlist,
notify_subscribers,add_shorts_hashtag, and optionalbrand_partner - LinkedIn, document title and filename, organization mentions, eligible Page-follower person mentions (keep the resolving Community connection's
app_id), polls (question, 2-4 options, 1 day/3 days/1 week/2 weeks), video thumbnail, alt text, andcontent_call_to_action_label(including BUY_NOW and SHOP_NOW) withcontent_landing_page. Pasting a URL in the caption auto-generates a link-preview card. - TikTok, video posts and photo slideshows, direct-post privacy, comment/duet/stitch toggles, title, auto-music, commercial disclosure,
video_cover_timestamp_ms(pick the video frame used as the cover on direct-post videos; TikTok does not accept custom uploaded covers), andai_generated(AI-content label) - Pinterest,
post_type(image/video/carousel), board (required via helper), title, destination link,cover_image_url(required for video Pins: a stored, probe-verified JPEG or PNG, 20 MiB maximum; aliasesvideo_cover_urlandpinterest_cover_image_url),product_tags, andai_generated. Video Pins publish through Pinterest's registered upload and media ID, not a publicvideo_urlfetch. Image Pins and 2-5 image carousels keep the existing URL flow. - Google Business Profile, standard, event, and offer posts with event schedule, offer details, CTA, video, and EVENT/OFFER
recurrence - X, reply settings (
everyone/following/mentionedUsers/subscribers/verified), polls,paid_partnership,ai_generated(made-with-AI label), media alt text, and Articles (article: { title, cover }, where the caption is the Markdown body; needs an X Premium account; see X Articles) - Threads, reply controls (incl.
followers_only/parent_post_author_only), polls, topic tags, text attachments, ghost posts, spoilers, reply approvals, GIPHYgif_attachment, media alt text, andshare_to_instagram(share to the Instagram account linked to the Threads profile after reconnecting to grant Story sharing permission) - Telegram, native polls (
{ question, options }), inline CTA buttons ([{ text, url }]), parse mode, videocover/start_timestamp, andis_live_photo - Bluesky, languages, content warnings (
content_label), hidden discovery tags (tags), quote posts (quote_url),disable_quotes, and media alt text. Pasting a URL auto-generates a link-preview card. - Discord, webhook channel posts with text (2,000 chars), up to 10 attachments, and media alt text (
media_accessibility, preferred;media_alt_texts, legacy). Each file can be up to 20 MiB, Discord's default webhook limit. Boosted servers and Nitro can allow more; posterly does not detect those entitlements. Mention parsing is disabled so captions never ping @everyone or roles. - Slack, channel posts with optional Block Kit
markdownand customblocks. Posterly's current buffered upload path has a 10 MiB per-file safety ceiling. - Mastodon, statuses on any instance with
visibility(public/unlisted/private), content warnings (spoiler_text),language,quoted_status_id, and nativepoll(media plus poll allowed on Mastodon 4.6+). Text length (including URL counting), attachment count, types, sizes, and media descriptions follow the connected instance. The 1,500-character alt-text figure is only the conservative discovery fallback. Captions, content warnings, and alt text are validated rather than silently shortened. Retries stay idempotent within Mastodon's documented window; an ambiguous final create or upload response stops automatic retries, so a later retry is not assumed to be duplicate-safe. - Dev.to, markdown articles with
title(falls back to the first caption line),tags(up to 4), andcanonical_url. The first image is uploaded and used as the cover. Further images are uploaded and added to the article with their alt text. Each file can be up to 25 MB. Dev.to allows 10 uploads an hour by default. If the cover upload does not succeed, the original image address is used instead, and an extra image that does not upload keeps its original address in the article body. A warning is shown on the post when any image falls back. - Hashnode, markdown articles to a Hashnode Pro publication with
title,tags(up to 4), and optionalpublication_idoverride. First image becomes the cover. - WordPress, self-hosted posts with
title(falls back to the first caption line). First image is uploaded as the featured image. - Lemmy, community posts with
community(required, e.g.technology@lemmy.world),title, optional linkurl, andnsfw. First image becomes the link embed.
ai_generated: true (alias ai_disclosure) labels a post as AI-generated where the platform supports it (TikTok, X, YouTube, Pinterest, Instagram).
TikTok photo slideshows, TikTok supports either a single video or a photo slideshow of 1 to 35 images, and never multiple videos. You do not need to set post_type or media_type for images: posterly auto-detects image media and posts it as a slideshow, sending every image you provide. Provide one image for a single-image slideshow or 2 or more images for a multi-image slideshow. To be explicit you can still set post_type to "carousel" (2 or more images) or "image" (single image), or pass settings.media_type: "PHOTO". A post with one video publishes as a standard TikTok video.
On a TikTok video, TikTok has a single caption of up to 2,200 characters. posterly sends the post content as that caption. If settings.title is also set, and the title plus the caption fit, the title is placed on the first line and the caption follows. If they do not both fit, the caption is sent on its own. Photo slideshows keep a separate title (90 characters) and description (the post content, up to 4,000 characters). A video sent to the TikTok inbox (no privacy level) does not include that text. The creator adds the caption in the TikTok app.
Instagram Trial Reels use graduation_strategy: "MANUAL" by default. Set "SS_PERFORMANCE" to let Meta auto-share the Reel with followers if it performs well. Meta controls Trial Reel availability per Instagram account. If the Trial toggle does not appear for that account in the Instagram app, Meta may reject API publishing. When Instagram refuses Trial Reels on the account, the post fails once and is not retried: post the Reel as a normal Reel instead. Collaborators are not sent for Trial Reels. See Instagram Trial Reels (posterly dashboard login required) for the composer workflow and the exact message you will see.
X / Threads thread chains, to schedule a multi-post reply chain on X (Twitter) or Threads (Meta), set post_type and pass the matching array in metadata:
{
"account_id": "169",
"scheduled_at": "2026-05-01T14:00:00Z",
"post_type": "x_thread",
"metadata": {
"x_thread_tweets": [
"1/ What's new on posterly. Here's everything we shipped in April.",
"2/ Tag users in Instagram Stories...",
"3/ Threads long-form posts up to 10,000 characters..."
]
}
}
For Threads, use post_type: "threads_thread" and metadata.threads_thread_posts. Each entry is validated against per-platform limits (X: 280 chars, Threads: 500 chars) before insert. URLs are still blocked in X thread segments.
Media rules checked at create time
posterly runs the same platform rules the composer uses, so a post that could never publish is rejected here with a 400 rather than failing later on the platform. These are worth calling out because they are enforced at create time. Most of these come back with a validation_errors array; the Instagram feed-still aspect check returns a flat { error, code } body instead, with no validation_errors array.
| Rule | Code | Applies to |
|---|---|---|
PDFs can only be published to LinkedIn. A .pdf in media_url/media_urls, or post_type: "document", is rejected for every other platform. | pdf_requires_linkedin | all platforms except LinkedIn |
Instagram cannot publish .avi or .mkv video. Export as MP4 (H.264/AAC). | instagram_video_unsupported_container | |
| Instagram feed photos and each still carousel slide can be no taller than 9:16 and no wider than 1.91:1. A still outside that range is refused when posterly knows the image's dimensions; publish-time preparation is the final check. Stories, Reels, and Story Series are not held to these feed bounds. | aspect_ratio_unsupported | Instagram feed and carousel stills |
YouTube needs a video file. A still photo (JPEG, PNG, WebP, GIF, and other image files) is refused at create, validate (dry_run: true), and batch create, with the message: YouTube needs a video file, not a photo. Upload a video and try again. | youtube_still_image_unsupported | YouTube |
| The Instagram account must have its business account id. Reconnect the account in posterly if you see this. | missing_instagram_business_account_id |
Each of these previously reached the publish step and failed there. They now fail fast at creation with an actionable message.
.mov, .webm and .m4v are not rejected for Instagram. posterly converts those containers to MP4 for you before publishing, so keep sending them as-is. Only .avi and .mkv, which posterly does not convert, are refused.
Create multiple posts
POST /api/v1/posts/batch
Create up to 25 posts in one API request. Each item uses the same body shape as POST /api/v1/posts. A successfully created item can carry the same optional warnings array described above (for example tiktok_pending_inbox_cap), alongside its index, post, and workspace.
{
"posts": [
{
"account_id": "169",
"caption": "Monday launch notes.",
"scheduled_at": "2026-05-18T14:00:00Z"
},
{
"account_id": "170",
"caption": "Behind the scenes from launch week.",
"media_urls": ["https://assets.poster.ly/users/123/photo.jpg"],
"scheduled_at": "2026-05-19T14:00:00Z"
}
]
}
Batch requests can partially succeed. Successful items are returned in posts with their original index; failed items are returned in errors with index, status, and a sanitized error. Send an Idempotency-Key header to make retries safe; posterly derives a stable idempotency key per item.
Batch create checks videos you just uploaded the same way single create does. posterly registers every upload in the batch, waits once for them to finish analysis (up to about 10 seconds for the whole batch, not per post), then checks each post. A video the platform can never accept, such as one longer than the platform allows, fails only its own item in errors with the same message as single create; the other items still go through. If analysis is not finished in time, that item succeeds with the media_not_yet_analyzed warning.
Update or delete a post
PATCH /api/v1/posts/{id}
DELETE /api/v1/posts/{id}
Update scheduled or draft content, or delete a post. Updates can pass the same settings object used when creating posts, which makes API updates line up with the composer controls for every supported platform. On PATCH, settings.reel_cover_url and settings.thumbnail_url update the dedicated cover and thumbnail columns the publisher reads, not only platform metadata.
An unpublished post can also move to another account with account_id. The account has to be one you can use in the same workspace, and it has to belong to the post's brand. The platform follows that account, so a move never leaves the old platform in place. thread_posts replaces an X or Threads reply chain: at least two non-empty parts, and the first part becomes the caption. Published posts, and posts that are currently publishing, cannot be moved or rewritten.
DELETE removes a posterly record and cancels its queued publishing. Drafts and other posts with no live target are removed locally. Posts that are publishing or processing are refused (400 publishing_delete_blocked). Published posts, including failed posts with a saved live target, need an explicit deletion choice:
- Keep the live post: send
delete_mode=posterly. This removes the record and its history from posterly. Live content and its hosted media are retained. - Remove live content too: send
delete_mode=platformandconfirm_published=true. posterly asks the original connected platform to remove the post, then removes the posterly record after an acknowledgement. For older clients,confirm_published=truewithout a mode still selects this operation.
Both fields are accepted as query parameters or JSON body fields. Combining delete_mode=posterly with confirm_published=true is refused because the instructions conflict.
# Keep the published content on the platform
curl -X DELETE 'https://www.poster.ly/api/v1/posts/971?delete_mode=posterly' \
-H "Authorization: Bearer $POSTERLY_API_KEY"
# Remove the live content too, after confirming that choice
curl -X DELETE 'https://www.poster.ly/api/v1/posts/971?delete_mode=platform&confirm_published=true' \
-H "Authorization: Bearer $POSTERLY_API_KEY"
| Platform | Live removal requirements |
|---|---|
Original Page token with pages_manage_posts, pages_read_engagement and pages_show_list, already requested by our Facebook connection flow. Needs a saved Page post ID or recoverable Facebook permalink and rights to remove that Page's post. New publishes save that id. An older photo whose saved link is the post id, and is not the Page itself, can still be removed. Facebook Stories and Page cover photos are not removed from posterly yet: remove them on Facebook, then remove the record from posterly only. | |
| X | Original account's write access and saved tweet IDs. Every saved thread reply is removed before the root. Legacy threads without a complete ID list are refused. |
| Original author connection and posting permission for the member or organization. New publishes save the LinkedIn post id. Older posts can still be removed when their saved LinkedIn link already contains that id. | |
| Pin write access and a saved Pin ID. | |
| Mastodon | Original instance and account, write access and saved status ID. |
| Google Business Profile | Business management permission and the full saved local post resource name. New publishes save that name. A post that already has the name saved can be removed. A Google search link is not enough. |
| Telegram | Original bot and chat, required chat permissions, saved message IDs, and a post under 48 hours old. Albums without a complete saved ID list are refused. |
| Slack | Original posting identity or app, channel and message timestamp. Slack only permits deletion of messages that identity can remove. |
| Discord | Original webhook and saved message ID, including the thread destination where applicable. |
| Bluesky | Original DID and PDS session, plus the saved post record URI. |
| WordPress | Original site and application password, with permission to delete the saved post. The post is permanently removed rather than trashed. |
| Lemmy | Original instance session and ownership of the saved post. Lemmy uses its own soft deletion rules. |
| Hashnode | Original personal access token, author or publication admin role, and an active Pro publication. Hashnode removes the post from feeds and listings but retains a record readable through its API. |
| YouTube | Saved video ID and youtube.force-ssl, youtube, or youtubepartner permission. The usual youtube.upload permission cannot delete videos. |
| Threads | Saved post ID and an actually granted threads_delete permission. posterly's current connection flow does not request that permission. |
Live deletion is disabled. Meta requires instagram_manage_contents and instagram_basic through Facebook Login plus a Facebook user token. Our current connection flow does not request the deletion grant. Publishing and comment permissions are insufficient; direct Instagram Login is not supported by the deletion endpoint. Remove the live post in Instagram. | |
| TikTok, Dev.to | Live deletion is unavailable in this integration. Remove the platform post there, or choose local-only deletion. |
A platform refusal or missing permission keeps the posterly record. If a network result is uncertain, edits and republishing are paused. Retry after two minutes using the same live-deletion choice. For multi-part posts, posterly saves each acknowledged removal and retries only the remaining IDs. If you choose to abandon an uncertain takedown, explicitly remove the posterly record only after that wait; this does not confirm that every platform item is gone.
A successful response includes deleted, id, deleted_ids, delete_mode, and platform_delete. The last field is deleted after a platform acknowledgement (or after posterly confirmed that an Instagram post no longer exists), not_requested for local-only removal of a live post, not_published for an unpublished post, or the legacy no_live_target outcome when finishing an earlier no-target attempt. A new platform-deletion request for a published record without a saved target returns platform_delete_unsupported and keeps the record; choose delete_mode=posterly explicitly to remove it locally. If local removal fails after the platform accepted deletion, the record remains locked and a retry finishes local removal without repeating the platform request.
If a client has already approved the post through client review, an update does not keep that stale approval: a caption edit reopens caption approval and a media or thumbnail edit reopens asset approval. A reschedule, account change or settings change cannot be attributed to one axis and reopens both. Server-owned review metadata (client_approval, review_media_preparation, review_media_preparation_required, story_review_binding) cannot be set or cleared through the API and answers 400 protected_metadata.
If the post's status, destination, permission, or pending publish changes while PATCH /api/v1/posts/{id} is running (for example, while posterly waits a few seconds for a newly uploaded video's probe), the request answers 409 post_changed without writing. Reload the post with GET /api/v1/posts/{id} and try the update again.
Change post status
PUT /api/v1/posts/{id}/status
Pause a scheduled post, resume/schedule a paused, draft, or failed post, or move a scheduled/failed/paused post back to draft.
{
"status": "paused"
}
Use status: "scheduled" with scheduled_at when scheduling a draft or when a paused post's original time is already in the past. Published, publishing, and processing posts cannot be changed through this endpoint.
status: "scheduled" also resumes a post in paused_due_to_token (paused because the account's token stopped working):
- The account must be reconnected first. While it still needs a reconnect, the request answers
409 account_needs_reconnect. - If the post's original time has passed and you do not send a future
scheduled_at, the request answers409 needs_reschedulewith the missed time inoriginal_scheduled_at. Send the same request again with a new futurescheduled_at. This endpoint never publishes a missed post immediately. - X posts in
paused_due_to_tokencannot be resumed through the API yet and answer409 status_transition_not_allowed. Resume them from the dashboard.
Reconnecting the account usually makes this unnecessary: it puts future token-paused posts back on the schedule, and publishes ones that missed their time by less than 24 hours right away.
Pause and resume publishing
GET /api/v1/publishing-pause
POST /api/v1/publishing-pause
POST /api/v1/publishing-pause/publish-missed
The same switches as the Pause publishing cards in the dashboard: stop everything in a workspace, or in one brand, from going out at once. See Pause all publishing for a workspace or a brand for what a pause holds. Every call takes an optional workspace_id (query string for GET, JSON body for POST); without it, the key's workspace is used, following the workspace scoping rules.
GET (posts:read) returns whether the workspace is paused, the paused brands you can see (a seat limited to some brands only sees those), and what you may do: can_pause_workspace, can_pause_brand, can_resume, can_publish_missed.
POST (posts:write) takes { "action": "pause" | "resume", "brand_id": "...", "confirm": true }. Leave brand_id out for the whole workspace. An empty or null brand_id is never read as the workspace: it answers 400 empty_brand_id.
- Pause needs publisher access or higher. A seat limited to some brands can pause only those brands, never the workspace (
403 brand_scoped_seat). Withoutconfirm: truenothing changes: the request answers400 confirmation_requiredwith a preview (scope,already_paused, andscheduled_posts_to_hold). Show it to the person, and repeat withconfirm: trueonce they agree. Pausing twice keeps the original pause time. - Resume needs admin access or higher (
403 insufficient_workspace_permissionsotherwise) and no confirm. The response listsresumed_count(back on the schedule),missed_post_ids(their time passed while paused; they stay paused),still_held_count(held by the other switch),disconnected_count, andfailed_post_ids.
{
"ok": true,
"action": "resume",
"scope": "brand",
"workspace_id": "a1b2c3d4-...",
"brand_id": "9f8e7d6c-...",
"resumed_count": 12,
"missed_count": 2,
"missed_post_ids": [4811, 4812],
"still_held_count": 0,
"disconnected_count": 0,
"failed_post_ids": []
}
Missed posts never publish on their own. To publish them now, send POST /api/v1/publishing-pause/publish-missed with { "post_ids": [4811, 4812], "confirm": true } (admin or higher, up to 500 IDs). Each post is checked again before it goes out (still in this workspace, no longer held, eligible to publish, not already published, billing allows publishing) and claimed once, so a repeated call cannot publish a post twice. The response gives started and skipped; skipped posts stay paused, ready to reschedule. When billing blocks publishing it answers 402 billing_write_blocked and nothing is published.
Every pause and resume is logged the same way as a dashboard click, together with the API key (or AI app) that made it.
Repair and group cleanup
GET /api/v1/posts/{id}/missing
PUT /api/v1/posts/{id}/release-id
DELETE /api/v1/posts/group/{group}
Use /missing to inspect whether a post is missing required content, media, account context, or platform settings before it publishes. release-id stores external release/group metadata for agent workflows. Group delete removes only draft, scheduled, failed, or paused posts and requires confirm=true.
Product feedback (public board)
POST /api/v1/feedback
File a bug, idea, or general feedback item on the same public board as Dashboard → Feedback. This is for product requests and user-facing bug reports, not tool-failure telemetry.
Requires:
- Bearer API key with
posts:writeand active API add-on (browser session fallback is rejected) confirm: trueafter the user explicitly approves the title and category- Rate limit: 5 submissions per day per API key (
product_feedback_write)
{
"category": "bug",
"title": "LinkedIn carousel fails when third image is HEIC",
"description": "When scheduling a 3-image carousel, HEIC on image 3 returns invalid_media without saying which file.",
"confirm": true,
"source": "mcp",
"client": "claude-desktop",
"context": {
"request_id": "req_example123",
"related_tool": "create_post"
}
}
Successful creates return 201 with feedback_id, board_url, and request_id. Inserts add a default vote and follow the same Slack + Hermes triage path as dashboard submissions. Never include API keys, secrets, captions, media URLs, or personal data about third parties. Full request/response schemas are in the OpenAPI spec (ProductFeedbackRequest / ProductFeedbackResponse).
Agent workflow telemetry (private)
POST /api/v1/agent-feedback
Store a private, bounded operational event after a real workflow outcome (tool success, error, or abandoned flow). This does not appear on the public feedback board and does not trigger product Slack/Hermes triage.
Requires a bearer API key with posts:write and active API add-on; browser session fallback is rejected. Limited to 60 events/hour per API key. Payload is strictly schema-bound (source, tool, outcome, optional error_code / comment / context). Never include secrets, prompts, captions, media URLs, or personal data. Events are service-role telemetry retained for 180 days.
Use this when an agent hits a concrete API/MCP failure. Use POST /api/v1/feedback when the human wants a product bug or idea filed publicly.
MCP mirrors both endpoints as submit_product_feedback and submit_agent_feedback.
Upload media
POST /api/v1/media/upload
Upload an image or video to attach to a post. This endpoint accepts base64 JSON on a Vercel API route, so the ~4MB request body limit applies. Base64 inflates the file. It is only for small files (our relay cap is 5MB decoded). Videos and large images must use signed upload so the bytes never enter the Vercel body:
{
"filename": "launch-photo.jpg",
"content_type": "image/jpeg",
"data": "<base64 file data>"
}
For larger files, first request a signed upload URL:
POST /api/v1/media/signed-upload
{
"filename": "launch-video.mp4",
"content_type": "video/mp4",
"size": 28400000
}
The response includes upload_url, public_url, headers, expires_at, and max_size. upload_url is an object-storage signed URL (R2 or Supabase), not a posterly API route. PUT the raw file bytes to that URL with the returned headers (at least the same Content-Type). Do not send Authorization on the PUT. The body must be exactly the declared size bytes. The PUT can come from a browser or a backend; storage CORS allows PUT + Content-Type from any origin.
To upload several files in one call, send files (1 to 20 entries) instead of the single-file fields:
{
"files": [
{ "filename": "hero.jpg", "content_type": "image/jpeg", "size": 245678 },
{ "filename": "launch-video.mp4", "content_type": "video/mp4", "size": 28400000 }
]
}
The response is { "slots": [...], "summary": { "requested": 2, "ok": 2, "failed": 0 } }, with one slot per file in request order. A good slot has the same fields as the single-file response plus index and filename. A file that fails (an unsupported type such as HEIC, a file over your plan cap, or storage you no longer have room for) comes back in place as { "index": 1, "filename": "...", "ok": false, "error": "...", "code": "..." } and the other files still get their slots. Per-file failures answer HTTP 200; only a malformed body, an empty files array, more than 20 files, or files mixed with the single-file fields answers 400. Storage is counted across the whole batch, and a batch spends one request of your media rate limit per file, so 20 files use 20 of the 120 hourly media upload requests (the single-file form uses one request per call). A batch that does not fit in what is left answers 429 with Retry-After and nothing is minted. The single-file form and its response are unchanged.
Signed URLs expire after 15 minutes. After a successful PUT, pass the returned public_url as media_url or inside media_urls when creating posts. Accepted files: JPEG, PNG, GIF, and WebP images, MP4, MOV, and WebM videos, and PDF documents up to 50MB. HEIC and every other type are not accepted.
To publish a PDF, upload it the same way (content_type: "application/pdf", filename ending in .pdf), then create the post with post_type: "document", media_url set to the PDF's public_url, and a document_title in settings (required, up to 100 characters). Only LinkedIn publishes documents: a PDF sent to any other platform is rejected with pdf_requires_linkedin. The 4MB relay (POST /api/v1/media/upload) and upload-from-url still take images and videos only.
The PUT to upload_url does not hit the Vercel ~4MB body limit. Object storage accepts the plan caps below.
Lock the upload to the file type you declare. Add "pin_content_type": true to the request (single or batch) and each upload_url is signed for the exact Content-Type you declared. The PUT must then send that header exactly as returned in headers, or storage answers 403 SignatureDoesNotMatch. Without it, the stored type is whatever the PUT sends. A pinned response carries content_type_pinned: true. Check your client before turning it on: curl --data-binary sends application/x-www-form-urlencoded unless you pass the header.
curl -X PUT "$UPLOAD_URL" -H "Content-Type: video/mp4" --data-binary @launch.mp4
Pinning is available per request with pin_content_type: true. posterly may turn it on by default in the future, so always send the Content-Type header returned with the slot. The media drop page and dashboard uploads always pin. Pinning works on R2 storage only; elsewhere content_type_pinned is false and the type is not enforced.
Creating a post with a video you just uploaded can take a few seconds while posterly checks it. If the check is not finished in time, the post is still created with a media_not_yet_analyzed warning, and a video below the platform's minimum frame rate (for example, under 23 fps on Threads) is converted to a compliant frame rate when it is published. A file that cannot be converted automatically is published as uploaded, and a dry run warns you with the frame rate it found. Updating a post's media with PATCH /api/v1/posts/{id} runs the same check and rejects a video the platform can never accept with the same error. The REST response also returns the media_not_yet_analyzed warning when the check is not finished in time; a rejection surfaces everywhere, including the hosted MCP update_post tool and the npm stdio package.
Do not PUT the file to /api/v1/media/signed-upload/upload. That legacy relay is a Vercel function and rejects large videos with FUNCTION_PAYLOAD_TOO_LARGE (the 4MB body again).
Signed-upload size limits for images and videos follow the plan, not a 50MB cap (PDFs are capped at 50MB on every plan):
| Plan | Images | Videos |
|---|---|---|
| Starter | 50MB | 500MB |
| Pro | 100MB | 750MB |
| Power | 150MB | 1GB |
| Agency | 250MB | 4GB |
There is no POST /api/v1/media/upload/signed. The only signed-upload create path is POST /api/v1/media/signed-upload.
If a request body is not valid JSON, any authenticated v1 endpoint returns 400 with code: "invalid_json" and a request_id. If posterly cannot prepare a signed upload, POST /api/v1/media/signed-upload returns 500 with a request_id you can quote to support and one of these codes: billing_lookup_failed, storage_quota_lookup_failed, signed_upload_token_failed, signed_upload_url_failed. These are safe to retry. A body that is too large for POST /api/v1/media/upload still returns 413.
Human media drop (ChatGPT and Claude laptop files)
Agents that cannot PUT a laptop file should mint a no-login drop page instead of create_signed_upload. The usual agent path is MCP create_media_drop, then list_media. The REST equivalents are:
POST /api/v1/media/drop-sessions
{
"filename": "launch.mp4",
"max_files": 1
}
Both request fields are optional. filename is a hint shown to the human. max_files is an integer from 1 to 20 (default 10), so one link can take up to 20 files. The response is:
{
"session_id": "2f9b6a51-6f0e-4b58-9a5b-64b3a3f2f9d1",
"drop_url": "https://www.poster.ly/drop/exampletoken",
"expires_at": "2026-09-09T12:00:00.000Z",
"max_bytes": 4294967296,
"max_files": 10,
"instructions": "Send this link to the user: https://www.poster.ly/drop/exampletoken They open it in a browser with no posterly login and drop the file."
}
Send drop_url (https://www.poster.ly/drop/<token>) to the user. They open it with no dashboard login and drop the file. The drop lasts 24 hours. Chat paperclips never reach MCP. The drop page accepts JPEG, PNG, GIF, WebP, MP4, MOV, WebM, and PDF up to 50MB (a PDF is for LinkedIn document posts). HEIC is not accepted: export or convert it to JPG or PNG first. Plan video caps still apply (see the table above). This is not create_signed_upload: the human's browser PUTs to object storage, not the agent.
After they finish, list the uploaded assets:
GET /api/v1/media?drop_session_id={session_id}
drop_session_id is optional. Omit it to list the newest media for the authenticated user. limit is an integer from 1 to 50 (default 10, or 20 when drop_session_id is set so a full drop fits one page); page with offset (offset + limit up to 1000). The response carries the exact total, has_more, and next_offset. Each item includes id, public_url, filename, path, created_at, and may include mime, bytes, and drop_session_id. Pass public_url as media_url or inside media_urls when you validate or create a post.
One library file the caller owns:
GET /api/v1/media/{id}
DELETE /api/v1/media/{id}
Both need media:write. id is the asset id from the list. A missing or malformed id answers 400 with code: "media_id_required". A file you do not own, or that is outside the key's workspace, answers 404 not_found.
GET returns media with id, filename, mime, bytes, and created_at. It does not return the storage path.
DELETE removes that file. A file a post still uses is kept and answers 409 with code: "media_in_use", plus the id and filename. A successful delete is { "ok": true, "deleted": true, "id", "filename" }. The response never includes the storage path.
You can also fetch an existing public asset into posterly storage:
POST /api/v1/media/upload-from-url
{
"url": "https://example.com/launch-photo.jpg",
"filename": "launch-photo.jpg"
}
The fetcher blocks localhost/private IP targets and redirect chains that resolve to private networks.
Use the returned url as media_url or inside media_urls when creating or updating posts.
create_post copies third-party media_url values the same way. That path is only for small files: about 5MB on the server-relay upload, with a 60MB remote-fetch hard cap. Larger files from Cloudflare R2, S3, or another CDN fail with remote_media_too_large (Remote file is too large to fetch through the API). Download the file and use signed-upload instead.
Automatic media analysis
Uploads are automatically registered and analyzed in the background (dimensions, duration, codecs). Upload responses from /api/v1/media/upload and the signed upload PUT include an asset_id field referencing the analyzed asset; it can be null if analysis registration was skipped. You do not need to do anything with it.
Once a video has been analyzed, post creation validates that the file can actually reach the selected platform. Combinations that can never publish are rejected with a structured 400 error instead of failing at publish time:
| Code | Meaning | Example message |
|---|---|---|
video_too_long | The video exceeds the platform's maximum duration | This video is 21m but X allows up to 20m. Trim it to include X. |
video_exceeds_platform_limit | The video is too large for the platform's file size limit | This video is too large for Telegram (50 MB limit). Export it under 50 MB to include Telegram. |
Media that has not been analyzed yet (or was uploaded before this feature) is accepted as before, and anything posterly can adapt automatically is handled without any error.
Find next available slot
GET /api/v1/slots/next
Find the next available posting slot for a given account.
account_ids takes at most 50 ids (400 above that). Ids you cannot use are ignored, and the call answers 404 only when none of them is usable. Posts a teammate scheduled on a shared account count as collisions, so the slots stay clear of them.
Suggest the best times to post
GET /api/v1/slots/suggested?account_ids=12,34&timezone=Europe/London&post_type=video&count=3
Ranks the next 7 days by when these accounts get the most engagement and returns the best free times. Requires posts:read.
account_ids(required, at most 25): every id must belong to your key's workspace scope (or theworkspace_idyou pass). An id outside it returns404.timezone: IANA timezone for the slots and labels. Defaults toAmerica/New_York, so pass it explicitly.post_type(optional):image,video,carousel,storyortext. Used when an account has at least 8 posts of that type.count: 1 to 10, default 3.include(optional): passweekly_ratingsto also get the 168-entry star-rating table described below. Omitted by default to keep the response small.
Slots are at least 30 minutes out, never within 60 minutes of a post already scheduled on those accounts, and spread to one per day when possible. Each slot has a reason, a stars rating (1 to 5) with its own star_reason, and basis says what the ranking used:
yours: the accounts' own engagement over the last 180 days. Needs theanalytics:readscope and paid analytics on the account, plus at least 8 analysed posts. Star ratings can go up to 5.platform: the aggregate pattern for the platform, used only when at least 20 workspaces contribute. Never another customer's posts, only bucket scores. Star ratings are capped at 4.default: a general default for the platform. Star ratings are capped at 3 (shown as "Estimated" in the dashboard).
Stars are a 1-5 rank of that hour against every other hour of the week for the same accounts, so a 5 only ever means "one of the best times this week for this audience," never an absolute score.
{
"timezone": "Europe/London",
"basis": "yours",
"basis_label": "Based on your posts",
"slots": [
{
"time": "2026-10-07T17:00:00.000Z",
"local_time": "Tue 7 Oct, 6:00 PM",
"basis": "yours",
"reason": "Your posts get 2.1x more engagement on Tuesdays around 6pm",
"stars": 5,
"star_reason": "Your posts get 2.1x more engagement around this time (34 posts)"
}
]
}
MCP: find_available_slot with strategy: "best_engagement".
X posting quota
GET /api/v1/x-posting/quota
Returns the current workspace's managed X posting allowance, used and remaining posts, current quota period, URL-block status, active add-on details, and available X quota plans. This requires posts:read.
POST /api/v1/subscription/x-posting-addon
Prices an X posting add-on, or returns a Stripe Checkout link after the caller agrees. dry_run: true returns the monthly price and charges nothing. confirm: true with the expected status, current plan, and price from that quote returns a Checkout link. The card is charged only after that page is paid. If an add-on is already active, confirming opens the billing page for the workspace owner and does not buy a second plan. Owner or admin. Requires billing:write.
Creating or editing an X post saves the post and reserves its posting allowance together. If the allowance is unavailable, the request leaves the saved post unchanged. Retrying a successful X create with the same Idempotency-Key returns the existing result without reserving the allowance again.
Account analytics
GET /api/v1/analytics/accounts?account_id={id}&from=YYYY-MM-DD&to=YYYY-MM-DD
Daily snapshots plus a period summary. Social platforms include follower delta, reach, views, and engagement rate; platforms with native dashboards include display_metrics so clients can render exact labels such as Google Business Profile's Profile Views, Search Views, Maps Views, Customer Actions, Calls, Directions, Website clicks, and Posts. Defaults to the last 30 days. Supported platforms: Instagram, X, Facebook Pages, LinkedIn, Google Business Profile, Pinterest, YouTube, and Threads. Requires analytics:read. Add include=extras to also read saved audience breakdowns, the last analytics sync, and entity snapshots such as boards. That extra read stays off unless ENABLE_ANALYTICS_SNAPSHOT_EXTRAS is true, and it does not start a new sync.
Post analytics
GET /api/v1/analytics/posts?account_id={id}&from=YYYY-MM-DD&to=YYYY-MM-DD&limit=50&offset=0
For LinkedIn Personal connections with analytics consent, account snapshots include connections (first-degree connections); summary.display_metrics labels personal impressions and engagement. Post results include linkedin_member_metrics, with lifetime metrics for the synced post. Null means LinkedIn did not return that metric. These endpoints read saved analytics; refresh through the Analytics dashboard. See LinkedIn Analytics for connection requirements and sync coverage.
Per-post engagement rows (likes, comments, reach, impressions, saves, shares, reposts, quotes, and plays) most-recent-first. For Threads, comments represents replies, while reposts, quotes, and shares remain distinct native metrics. limit defaults to 50, max 200. Same platform support and scope requirement as above.
Performance feedback loop
These three read endpoints and one write endpoint expose the performance feedback loop (post to analytics to per-post insight to performance-aware suggestion). All require analytics:read and a Pro plan or higher; lower tiers get 403 with code: "feature_not_available". Data is scoped to the API key's workspace.
Account performance profile
GET /api/v1/accounts/{id}/performance-profile
Returns the read-only performance profile posterly derives for the account from the last 90 days of per-post analytics: stats (top formats, timing, caption-length bands), an engagement-rate trend, coaching bullets, a narrative summary, and derived_at. When the account is connected but has no profile, performance_profile is null with a reason:
insufficient_data, not enough tracked posts yet.platform_not_measurable, the platform has no per-post analytics (for example Google Business Profile).
{
"performance_profile": {
"social_account_id": 42,
"platform": "instagram",
"username": "brandhandle",
"sample_size": 38,
"window_days": 90,
"engagement_rate_avg": 0.041,
"engagement_rate_trend": "up",
"bullets": ["reel posts average 5.2% engagement (12 posts sampled)"],
"narrative_summary": "Reels posted midweek tend to outperform static images.",
"stats": { "windowDays": 90, "sampleSize": 38, "platform": "instagram" },
"derived_at": "2026-07-03T02:00:00.000Z"
},
"reason": null
}
Post feedback insights
GET /api/v1/analytics/insights?account_id={id}&post_id={id}&checkpoint=24h&limit=20
Per-post feedback insights for recently published posts, most-recent-first. Each item carries the performance tier (great / good / mixed / poor), a diagnosis, a next_action, impact_score, confidence, metrics, and the baseline it was compared against. Optional filters: account_id, post_id, and checkpoint (one of 1h, 6h, 24h, 72h, 7d). limit defaults to 20, max 100.
{
"insights": [
{
"id": "a1b2c3d4-0000-0000-0000-000000000000",
"post_id": 9182,
"platform": "instagram",
"checkpoint": "24h",
"performance_tier": "great",
"title": "Strong 24 hour signal",
"diagnosis": "This reel is ahead of your usual Instagram baseline on reach.",
"next_action": "Turn the hook into a follow-up while the topic is still fresh.",
"impact_score": 90,
"confidence": 0.72,
"metrics": { "primaryViews": 5200, "engagements": 410 },
"baseline": { "basis": "same_media_90d", "primaryViewsPercentile": 0.91 },
"created_at": "2026-07-03T14:05:00.000Z"
}
]
}
Post suggestions
GET /api/v1/suggestions?account_id={id}&status=pending&limit=20
POST /api/v1/suggestions/{id}/dismiss
POST /api/v1/suggestions/{id}/accept
GET lists proactive post suggestions (evidence-based weekly drafts written in each account's learned voice). Each item includes the caption, platform, account, a rationale tying the draft back to what has performed well, period_key, and status. Filter by account_id and status (pending default, or scheduled / dismissed). limit defaults to 20, max 100; page with offset (offset + limit up to 1000). The response carries the exact total, has_more, and next_offset.
POST /api/v1/suggestions/{id}/dismiss dismisses a suggestion so it stops appearing. It never overwrites a suggestion that was already turned into a scheduled post (returns 409). This write requires posts:write (the closest existing write scope; suggestions are draft posts). In a team workspace, dismissing also requires the Editor role or above.
POST /api/v1/suggestions/{id}/accept saves a pending suggestion as an unpublished draft. It does not schedule and it does not publish. It needs posts:write, a Pro plan or higher (403 feature_not_available otherwise), and the Editor role or above (403 insufficient_workspace_permissions). A body that includes scheduled_at, publish_at, status, or publish answers 400 with code: "suggestion_stays_a_draft". A missing suggestion answers 404 not_found. A dismissed suggestion answers 409 suggestion_dismissed and is not saved. A new save returns { "ok": true, "status": "draft", "post_id": 123, "already_saved": false }. If the suggestion was already turned into a post, nothing changes: already_saved is true and status is that post's real status, which may be scheduled or published.
{
"suggestions": [
{
"id": "f9e8d7c6-0000-0000-0000-000000000000",
"social_account_id": 42,
"platform": "instagram",
"account": { "id": 42, "username": "brandhandle", "platform": "instagram" },
"caption": "Behind the scenes of our midweek reel workflow...",
"rationale": "Reels posted midweek are your top performers over the last 90 days.",
"period_key": "2026-W27",
"status": "pending",
"created_at": "2026-07-01T09:00:00.000Z"
}
]
}
Social inbox
GET /api/v1/inbox/conversations
GET /api/v1/inbox/conversations/{id}
POST /api/v1/inbox/conversations/{id}/reply
GET /api/v1/inbox/comments
GET /api/v1/inbox/comments/{id}
PATCH /api/v1/inbox/comments/{id}
DELETE /api/v1/inbox/comments/{id}
POST /api/v1/inbox/comments/{id}/reply
POST /api/v1/inbox/sync
The public API wraps the dashboard Social Inbox for Instagram DMs and comments, Facebook Page DMs and comments, Threads replies, and LinkedIn Page comments. LinkedIn supports public comment replies only. LinkedIn DMs return linkedin_dms_unavailable. Hide, delete and automatic replies are not offered for LinkedIn. Scheduling already covers all 18 platforms. Requires a Pro plan or higher. Reads need posts:read and viewer access; replies, hide, and sync need posts:write and editor access. Comment delete needs posts:write and admin access. Delete is Instagram and Facebook Page comments only; Threads replies return 400 because they can be hidden, not deleted. DM replies honor Meta 24-hour windows. Facebook has no private comment replies. Threads has no DMs.
If a key is bound to a workspace, or you pass workspace_id explicitly, every inbox call is scoped to exactly that workspace, and access is gated on that workspace's owner's plan, matching the dashboard: a teammate on a Pro-owned workspace can use the inbox there even if their own account is on a lower tier. This cuts both ways: a Pro-plan member of a Starter-owned workspace no longer gets inbox access in that workspace either, even though their own plan would otherwise qualify, because the gate is always the workspace owner's plan, never the caller's own.
If workspace_id is omitted and the key or token is not bound to a workspace, both list and single-item calls (get/reply/update/delete a conversation or comment) look across every workspace you are an active member of, matching results from the ones where your role and the workspace owner's plan both qualify; the list responses add workspaces_searched and workspaces_skipped (each with a reason of plan or role) so a partial search is never a silent empty result. POST /api/v1/inbox/sync is different: it always acts on the connected account's own workspace (not a search), so an omitted workspace_id there just means the endpoint does not require you to name that workspace explicitly. A browser session (the dashboard, or WebMCP) keeps the existing active-workspace behavior instead of searching every workspace.
Comment list requests for YouTube or TikTok are refused. They are not part of the inbox. While YOUTUBE_INBOX_COMMENTS_ENABLED or TIKTOK_INBOX_COMMENTS_ENABLED is off, the call returns 403 and does not read comments. Turning a flag on still returns 501 platform_not_available until the external step is done: YouTube needs youtube.force-ssl, Google app review, and an inbox provider (YT-05). TikTok needs comment scopes, TikTok app review, and an inbox provider (TT-03). Neither path returns an empty list.
Both list endpoints (GET /api/v1/inbox/comments and GET /api/v1/inbox/conversations) accept optional since and until query parameters, an ISO date (2026-10-01) or datetime (2026-10-01T09:00:00Z). Comments are filtered on when they were posted and conversations on when their last message arrived. Both bounds are inclusive: a date-only since starts at the beginning of that UTC day and a date-only until runs to the end of that UTC day. A datetime with no zone is read as UTC. A value that is not a date, or a since after until, returns a 400 validation error. Each row also carries account_username and workspace_name (left out when they cannot be looked up). Conversation rows add last_message with sender_is_business, media_type, has_media, and is_unsupported, so you can tell who spoke last and whether the last message was only media.
Google Business Profile workflows
GET /api/v1/google-business/reviews
POST /api/v1/google-business/reviews/reply
DELETE /api/v1/google-business/reviews/reply
POST /api/v1/google-business/reviews/suggest-reply
GET /api/v1/google-business/review-link
GET /api/v1/google-business/audit
GET /api/v1/google-business/media
POST /api/v1/google-business/media
DELETE /api/v1/google-business/media
GET /api/v1/google-business/location
PATCH /api/v1/google-business/location
Local-business tools expose connected GBP reviews, review request links, live profile audits, review reply workflows, and profile photo/video management over the same API/MCP/CLI surface. Read endpoints require analytics:read or accounts:read; reply and media writes require posts:write plus publisher workspace access. AI reply suggestions cost 1 AI credit per request from the unified credit wallet.
GET /api/v1/google-business/reviews includes review_name on every review, in the form accounts/{account}/locations/{location}/reviews/{review}, plus account_id. That review_name is the value POST /api/v1/google-business/reviews/reply and DELETE /api/v1/google-business/reviews/reply accept. The dashboard reviews list returns the same review_name. A review Google returns with only a review id is filled in from the location's account and location names, so the list value can be sent straight back as a reply.
GET /api/v1/google-business/audit keeps overallScore (0 to 100) and overallGrade (A from 90, B from 80, C from 70, D from 60, otherwise F), plus up to eight recommendations. audit.sections breaks that same score into seven areas. Each section has label, score, maxScore, percentage, and issues. The points sum to overallScore:
| Key | Label | Max |
|---|---|---|
basicInfo | Profile basics | 20 |
hours | Hours | 15 |
categories | Categories | 15 |
description | Description | 10 |
media | Photos | 15 |
attributes | Attributes | 5 |
reviews | Review health | 20 |
Profile basics covers name, address, phone, website, and verification. Hours includes regular hours and special hours. Review health covers whether any reviews were returned, average rating, and reply rate. Recent posts are not part of this score. Name, address, and phone presence sit inside Profile basics. This route does not compare those fields with the business website. The dashboard auditor can show a different number for the same location.
Location hours and attributes read and replace regular opening hours or a named set of attributes on one location. GET /api/v1/google-business/location and PATCH /api/v1/google-business/location stay off unless GBP_LOCATION_WRITES_ENABLED is true. While it is off, neither route calls Google. A patch sends regular_hours or attributes, not both, and needs publisher access. Hours use open_day, close_day, open_time, and close_time (HH:MM, days MONDAY to SUNDAY). Attributes use a name like attributes/has_wifi, value_type BOOL, URL, or ENUM, and https URLs only. This uses the same business management connection as posting. It does not change the business name, address, phone, or website. posterly has not shipped these location edits before, so leave the switch off until you are ready to turn it on.
Profile media manages the standing photo and video gallery shown on a location's Maps/Search profile (distinct from media attached to a post). GET /api/v1/google-business/media lists items for one location (account_id or location_id) or across all GBP locations. To add one, first upload the file via the /media endpoints to get a public source_url, then POST with source_url, a category (one of COVER, PROFILE, LOGO, EXTERIOR, INTERIOR, PRODUCT, AT_WORK, FOOD_AND_DRINK, MENU, COMMON_AREA, ROOMS, TEAMS, ADDITIONAL), and optional media_format (PHOTO default, or VIDEO). DELETE takes the media_name returned by GET. COVER and PROFILE are single-slot. Photos must be JPG or PNG between 10 KB and 5 MB (Google's limit); a photo outside that range is rejected with a 400 and the code gbp_photo_invalid. Videos can be up to ~30s and 75MB and must pass Google's review before appearing.
AI generation
POST /api/v1/ai/generate-captions
GET /api/v1/ai/video-options
POST /api/v1/ai/video-function
POST /api/v1/ai/generate-video
GET /api/v1/ai/video-jobs
GET /api/v1/ai/video-jobs/{id}
POST /api/v1/ai/generate-image
GET /api/v1/ai/image-jobs
GET /api/v1/ai/image-jobs/{id}
/ai/generate-captions generates or adapts brand-aware caption suggestions for one or more platforms. It requires posts:write and costs 1 AI credit per request from the unified credit wallet (included plan credits first, then purchased credits; 402 when neither covers it). It returns suggestions only; it does not create a post.
Every caption is checked against the brief and the workspace's business facts. Prices, dates, times, phone numbers, links and handles that cannot be verified, and any never-say phrases you set on a fact, are removed after one rewrite attempt, so a platform may return fewer variants than count. Bare numbers and percentages are kept unless the brand has facts of that kind on file.
/ai/video-options is a read-only Google Veo and xAI Grok discovery endpoint for models, input modes, durations, resolutions, aspect ratios, and credit-cost estimates. It does not generate video or spend credits.
/ai/video-function runs read-only provider helpers such as estimate_cost and validate_request before generation. Use identifier google_veo or xai_grok. It does not generate video or spend credits.
/ai/generate-video queues a cost-guarded Google Veo or xAI Grok Imagine Video 1.5 job and returns 202 Accepted with a job_id. Veo supports 4/6/8-second jobs; Grok supports 1 to 15 seconds at 480p, 720p, or 1080p. It spends AI credits from the unified wallet, so show the user the provider, prompt, duration, resolution, and cost before calling it from an agent. Poll /ai/video-jobs/{id} until the job is completed and video_url is available.
{
"prompt": "A clean vertical product teaser for a social scheduling app.",
"provider": "xai",
"aspect_ratio": "9:16",
"duration_seconds": 8,
"resolution": "720p",
"generate_audio": true
}
/ai/generate-image queues a Railway Nano Banana or Grok job and returns 202 with job_id. Poll GET /api/v1/ai/image-jobs/{id} for urls. Google quality is model + resolution + thinking_level. resolution is 1K, 2K, or 4K; 512 is no longer available and returns a 400. quality: "high" on Google turns thinking on. Thinking high does not change the credit price (same as the dashboard). Pass reference_image_urls (up to 14 HTTPS images) to lock a logo or product. Included plan credits are spent first, then purchased credits.
AI credits
GET /api/v1/credits
Returns the AI credit wallet the key can spend from: available right now, the monthly included allowance with used and remaining, purchased_balance, plan_tier, and resets_at for the next included-pool reset.
Credits are a shared workspace wallet owned by the billing owner, and every active member's usage draws the same pool. This endpoint reports that wallet rather than a personal figure, so a member's key sees the balance it can actually spend. workspace.role tells you the caller's role.
Requires billing:read. Like the subscription endpoints it does not require the paid API add-on, so you can always read your own balance, including when a spent wallet is what is blocking you. If the wallet lookup degrades it returns 503 with code: "wallet_unavailable" rather than a misleading zero balance.
Publishing status
GET /api/v1/status
Reports posterly's own publishing health, so an agent can check whether posterly or a platform is having a problem before it schedules something time-critical. It uses the same numbers as the public status page, reduced to anonymous aggregates: no post ids, no user data, no raw errors.
Requires an API key with accounts:read. It does not require the paid API add-on, so you can check health even when something else is blocking you. If the status cannot be built it returns 503 with code: "status_unavailable" rather than a healthy-looking default. The figures are cached for about a minute.
{
"state": "operational",
"state_basis": "publishing success, last 4 hours",
"insufficient_data": false,
"counted_4h": 41,
"as_of": "2026-10-18T09:14:03.000Z",
"incidents": [],
"platforms": [
{ "platform": "instagram", "state": "operational", "success_rate_7d": 99.1, "publishes_7d": 212, "customer_side_failures_7d": 6 },
{ "platform": "lemmy", "state": "low_volume", "success_rate_7d": null, "publishes_7d": 3, "customer_side_failures_7d": 0 },
{ "platform": "slack", "state": "no_data", "success_rate_7d": null, "publishes_7d": 0, "customer_side_failures_7d": 0 }
],
"upcoming_scheduled_posts": 318,
"notes": "customer_side_failures are excluded from success rates. ..."
}
| Field | Meaning |
|---|---|
state | Overall publishing health from the last 4 hours: operational, degraded (success rate under 95%), partial_outage (under 85%, or an active incident), major_outage (under 70%), or unknown |
state_basis | What state is based on: publishing success over the last 4 hours |
insufficient_data | true when fewer than 10 posts were counted in the last 4 hours and no incident is active. state is then unknown: not enough recent activity to judge, which is neither an outage nor a green light |
counted_4h | How many posts were counted in the success rate over the last 4 hours: posts that published, plus failures on posterly's side or in a platform-wide problem. Failures caused by content or the account are not counted. Always the real number, even when insufficient_data is true |
as_of | When these figures were computed |
incidents | Active incidents, each with platform, state and a customer-safe message, for example X being unavailable. An incident outranks insufficient data and the thresholds |
platforms[].state | operational (95% or higher), degraded (85% to under 95%), outage (under 85%, or an active incident), low_volume (fewer than 20 publishes in 7 days), or no_data |
platforms[].success_rate_7d | Success rate over the last 7 days in percent. null for low_volume and no_data, never a made-up 100 |
platforms[].publishes_7d | Posts counted for that platform in the last 7 days |
platforms[].customer_side_failures_7d | Failures in the last 7 days that were caused by content or the account, for example an expired connection or a content limit |
upcoming_scheduled_posts | Posts across posterly that are scheduled and still in the future |
customer_side_failures are excluded from every success rate by design. They are content or account problems the customer fixes, not posterly problems, so they appear only as their own count. Each figure names its window (_4h, _7d), so nothing is ambiguous. The MCP tool posterly_status returns the same data.
Subscription management
GET /api/v1/subscription
POST /api/v1/subscription/cancel
POST /api/v1/subscription/pause
POST /api/v1/subscription/resume
POST /api/v1/subscription/downgrade
POST /api/v1/subscription/upgrade
These endpoints let you read and self-manage your own posterly subscription. GET requires billing:read; the five mutations require billing:write. They act only on the API key owner's subscription.
Unlike the rest of the API, subscription endpoints do not require the paid API add-on. Managing your own subscription is not an API-product feature, and gating it would trap paused or cancel-pending users. They still require a valid, non-revoked key with the right billing scope. If the user has no subscription, these return 404 with code: "no_subscription"; upgrade returns 409 no_subscription instead, with the billing page link, when there is no card subscription to upgrade.
GET returns the subscription summary (status, tier, cancel_at_period_end, current_period_end, trial_ends_at, pause fields, accounts with the connected accounts in use and the limit) and what to do next for this user:
upgrade_options: the plans above the user's, each withtier,name,monthly_price,yearly_price,max_accountsandincluded_credits. It is empty on Agency (the top plan), while paused, past due, canceled or cancel-pending, and for a teammate who is not the workspace owner.next_steps: ordered steps with anid, plaintext, and the realtoolandparamswhen one exists. For an owner on an active or trialing plan, moving up points toupgrade_subscription(the API endpoint below); where it cannot be done from here, the step points tobilling_url(the billing page). A paused plan points toresume; a past due, unpaid, canceled or cancel-pending plan points to the billing page.extra_accounts(Agency workspace owners only): the same body asGET /api/v1/subscription/extra-accounts. If the live lookup is unavailable the field is left out and a next step says live pricing is temporarily unavailable.billing_url: where plan changes are made.
cancel requires a reason from a fixed catalog, matching the dashboard flow: too_expensive, not_using, missing_features, technical_issues, switching_service, temporary_break, other. An invalid or missing reason returns 400 with code: "invalid_reason". Optional feedback (free text) and immediate (defaults to false, cancel at period end) are also accepted.
pause pauses for 30 days and is limited to one pause per 90-day cooldown (429 pause_cooldown otherwise). resume clears a pause. downgrade moves the plan down one tier now, or to an explicit lower tier (starter, pro, power_user, agency) if provided. The lower limits and credits apply right away, the lower price starts at the next renewal, and the rest of the current period is not refunded. It only moves strictly downward.
curl -X POST https://www.poster.ly/api/v1/subscription/cancel \
-H "Authorization: Bearer pst_live_your_key_here" \
-H "Content-Type: application/json" \
-d '{"reason":"too_expensive","immediate":false}'
To reactivate a pending cancellation, restart a lapsed plan, or accept a retention offer, use the posterly dashboard; those flows stay app-only.
Upgrade the plan
POST /api/v1/subscription/upgrade moves the workspace owner's subscription to a strictly higher plan (400 not_an_upgrade for the same or a lower one; use downgrade to go down). Only the workspace owner can call it (403 workspace_owner_required for teammates, even with a key the owner issued). It needs billing:write and not the paid API add-on. Pass workspace_id if your key can act in several workspaces.
# 1. Quote it: exact charge today and the new recurring cost, nothing is charged
curl -X POST https://www.poster.ly/api/v1/subscription/upgrade \
-H "Authorization: Bearer pst_live_your_key_here" \
-H "Content-Type: application/json" \
-d '{"tier":"pro","dry_run":true}'
# 2. Show the quote to the account owner, then apply it
curl -X POST https://www.poster.ly/api/v1/subscription/upgrade \
-H "Authorization: Bearer pst_live_your_key_here" \
-H "Content-Type: application/json" \
-d '{"tier":"pro","request_id":"upgrade-2026-10-17-a"}'
Always quote with dry_run: true first. The plan keeps its billing interval. Applying charges the saved card the amount in the quote right away, which is the prorated difference for a same-interval upgrade (nothing is charged during a trial, which keeps running with the new price from the end of the trial). With a working card the plan changes immediately (status: "applied"). If the card needs 3D Secure or is declined, nothing changes: the response is 200 with applied: false, status: "payment_required" and a Stripe-hosted hosted_invoice_url. Send the owner there; the plan changes automatically when the invoice is paid. While that payment is pending, further changes return 409 payment_pending. request_id (8 to 64 letters, digits, _ or -) makes a retry safe because nothing is charged twice: once the first attempt has landed, a retry is refused (409 quote_stale or 400 not_an_upgrade) rather than replayed. Leave it out to have one generated; a malformed one returns 400 invalid_request_id. Use a fresh one for each attempt (the same id with a different body returns 409 request_id_reused). Optionally send expected_current_tier and expected_interval from your quote: if the live plan or interval changed since, nothing is charged and you get 409 quote_stale.
Upgrading does not change the billing interval. The one exception is Agency, which is billed monthly or yearly only: to reach it from a weekly plan, send interval as month or year (otherwise 409 interval_unavailable). Any other interval change returns 409 interval_change_unsupported; change the interval on the billing page. Other errors: 400 invalid_tier, invalid_interval, invalid_request_id, 409 subscription_not_eligible (not active or trialing, or paused), cancel_at_period_end, schedule_attached (a subscription schedule would undo the upgrade), discount_requires_dashboard (any discount other than a permanent percent-off below 100, so handle it on the billing page first), api_addon_requires_dashboard (the subscription includes the API & MCP add-on, so change the plan on the billing page; or your standalone API & MCP add-on would stop working on the new plan, for example Agency needs the Agency add-on, so switch the add-on on the API page first, using the api_addon_url in the response), no_subscription (no card subscription on posterly, for example complimentary access) and 503 price_not_configured.
Agency extra accounts
GET /api/v1/subscription/extra-accounts
POST /api/v1/subscription/extra-accounts
Agency workspace owners can buy and remove extra connected-account slots through the API, on exactly the same rules as Billing in the dashboard: $0.50 per extra account per month or $4.70 per extra account per year (it follows your Agency plan's interval), every increase adds at least 20, there is no maximum, and a decrease is allowed but never below the accounts connected in any workspace you own. Only the workspace owner can call these (403 workspace_owner_required for teammates, even with a key the owner issued). GET requires billing:read, POST requires billing:write, and neither needs the paid API add-on. Pass workspace_id if your key can act in several workspaces.
GET returns what is included, granted, and paid for, the accounts in use, the current limit, the price per extra account, the minimum purchase, the lowest total allowed right now (min_paid_extra_accounts), and whether the owner can change it (eligible, with a reason code when not). If an earlier purchase is waiting on the card, payment_pending has the Stripe-hosted payment link.
POST takes extra_accounts, the desired total of paid extra accounts (0 removes them all):
# 1. Quote it: exact charge today and the new recurring cost, nothing is charged
curl -X POST https://www.poster.ly/api/v1/subscription/extra-accounts \
-H "Authorization: Bearer pst_live_your_key_here" \
-H "Content-Type: application/json" \
-d '{"extra_accounts":40,"dry_run":true}'
# 2. Show the quote to the account owner, then apply it
curl -X POST https://www.poster.ly/api/v1/subscription/extra-accounts \
-H "Authorization: Bearer pst_live_your_key_here" \
-H "Content-Type: application/json" \
-d '{"extra_accounts":40,"request_id":"order-2026-10-16-a"}'
Always quote with dry_run: true first. Applying an increase charges the saved card the prorated amount right away (nothing is charged during a trial, and decreases never charge or refund). With a working card the slots are added immediately (status: "applied"). If the card needs 3D Secure or is declined, nothing is added: the response has status: "payment_required" and a Stripe-hosted hosted_invoice_url. Send the owner there; the accounts are added automatically when the invoice is paid. While that payment is pending, further changes return 409 payment_pending. request_id (8 to 64 letters, digits, _ or -) makes a retry safe, so the same id never buys twice: use a fresh request_id per attempt, and reuse one only to retry the exact same request (the same id with a different body returns 409 request_id_reused). Optionally send expected_current_extra_accounts and expected_interval from your quote: if the live total or billing interval changed since, nothing is charged and you get 409 quote_stale, so you can show a new quote. Other errors: 400 below_minimum_purchase and below_accounts_in_use (with minAllowed), agency_required, subscription_not_eligible, cancel_at_period_end.
Activity and webhooks
GET /api/v1/activity
GET /api/v1/notifications
GET /api/v1/webhooks
POST /api/v1/webhooks
PATCH /api/v1/webhooks/{id}
DELETE /api/v1/webhooks/{id}
POST /api/v1/webhooks/{id}/test
GET /api/v1/webhooks/{id}/deliveries
/activity returns recent post activity and publish logs, newest first, so agents can audit what happened without scraping the dashboard. Page with limit (max 100) and offset (offset + limit up to 1000; use since or post_id to go further back). The response carries has_more and next_offset; total is the number of items on the page, kept for older clients. /notifications is an alias for agent and Postiz-style notification consumers. Unlike posts, /activity, /analytics/*, /analytics/insights, and /suggestions stay scoped to the calling key's own account, not every workspace it belongs to. The one exception is /activity?post_id=: for a post you can read with GET /api/v1/posts/{id}, it returns every event on that post, including ones a teammate caused, such as its creation.
GET /api/v1/webhooks/{id}/deliveries lists recent attempts for one webhook you own. It stays off unless ENABLE_API_WEBHOOK_DELIVERY_HISTORY is true. While it is off, the delivery table is not read. The response leaves out the signing secret, the request body, and the destination's response body.
Webhooks support webhook.test, post.created, post.updated, post.deleted, post.publishing, post.published, post.failed, account.disconnected, analytics.synced, approval.requested, approval.approved, approval.changes_requested, approval.rejected, and approval.commented. Deliveries are signed with X-posterly-Signature using t=<unix>,v1=<hmac_sha256>.
Webhooks support webhook.test, post.created, post.updated, post.deleted, post.publishing, post.published, post.failed, account.disconnected, account.needs_reconnect, account.reconnected, analytics.synced, approval.requested, approval.approved, approval.changes_requested, approval.rejected, and approval.commented. Deliveries are signed with X-posterly-Signature using t=<unix>,v1=<hmac_sha256>.
account.needs_reconnect is sent once when a connected account changes from healthy to needing a reconnect. account.reconnected is sent once when it changes back. Checking the account again, or failing a refresh for a temporary reason, does not send another delivery. The reason matches connection_status on GET /api/v1/accounts: reconnect_required when the account is flagged, or access_token_expired when the access token has expired and cannot refresh itself. account.reconnected sends reason: null. The payload has the account id, the platform, the brand (workspace_client_id, or null), the reason, and transitioned_at (ISO 8601 UTC, no milliseconds). It does not include tokens or the provider's error text. The event goes to the account owner's webhooks.
Example account.needs_reconnect payload:
{
"id": "6cbb86b6-8c53-43f0-85b3-cf18a9f3a4a1",
"event": "account.needs_reconnect",
"created_at": "2026-10-23T06:18:00.000Z",
"data": {
"account_id": 123,
"platform": "twitter",
"workspace_client_id": null,
"reason": "reconnect_required",
"transitioned_at": "2026-10-23T06:18:00Z"
}
}
When a teammate updates, deletes, or changes the status of someone else's post, post.updated / post.deleted events go to both the post's owner and the teammate who made the change (deduplicated if the same person subscribed twice), so either side's integration sees it.
post.updated payloads include the post's created_by object with user_id, name, and source, the same shape described under List posts. name is filled in on PATCH /api/v1/posts/{id} updates and null on status changes, and source is null for posts created before the channel was recorded. A status change delivers created_by but not created_at.
Change client approval
POST /api/v1/posts/{id}/approval
Only the API key user's workspace role of owner or admin may call this. A publisher, editor, or viewer is refused. The call never acts as a brand guest or client reviewer. The activity row and review history label the actor Agency API (email), with signed_in_via agency_api on team-side decisions. last_reviewer_guest_id and approval_completed_by_guest_id stay empty.
This is an explicit product default: agency API keys can request review, and owners or admins can also record team-side decisions. It is not a guest session and it is not the client portal sign-in link flow.
{ "action": "request" }
action | Who | What it does |
|---|---|---|
request | owner or admin | Opens both axes for client review, emails active and invited brand reviewers, and sends approval.requested. Refused for published, publishing, or processing posts, and when the brand has no reviewers. |
approve | owner or admin | Team-side approve. Optional axis: asset, caption, or omit for both. The post must already be pending_client. Asset decisions fan out across the post group the same way the portal does. An approved content-plan post is scheduled with the existing plan rules. |
request_changes | owner or admin | Team-side changes requested. note (or comment) is required. |
reject | owner or admin | Team-side reject. Moves the post back to draft. Optional note. |
approve, request_changes, and reject send approval.approved, approval.changes_requested, or approval.rejected. They do not email the agency about its own decision. The response includes the updated post, actor: "agency_api", and signed_in_via: "agency_api" on a decision (null is not used for these writes). request returns notified_reviewers instead of signed_in_via.
Decisions on posts with prepared review media require a complete, current prepared file for every affected post. A missing or stale file returns 409 review_media_preparation_stale; resend the post for review before deciding. A concurrent edit returns 409 review_snapshot_changed; reload the post before retrying.
Client approvals
Combine the List posts approval_status, approval_waiting_on, and brand_id filters with the five approval.* webhook events to build a client-portal or notification integration on top of posterly's built-in client review flow.
- List everything currently waiting on a client for a brand:
GET /api/v1/posts?brand_id={brand_id}&approval_status=pending_clientorapproval_waiting_on=client. Each post'sapproval.pending_client_approvalis true for that queue. approval.requestedfires once when the agency sends a post or an entire content plan for review.approval.approved,approval.changes_requested, andapproval.rejectedfire once when a client makes that decision, whether on a single post or an asset decision that fans out across every post in the samepost_group_id(e.g. the same Reel cross-posted to Facebook and Instagram).approval.commentedfires when a client leaves a comment without changing the decision.- The event name reports the decision the client made.
data.approval_statusreports the post's overall status after it. A post has two parts a client can approve separately, the asset and the caption (data.axisisasset,caption, ornullfor both), and it is onlyapprovedonce both are. So anapproval.approvedevent can still carryapproval_status: "pending_client". Always drive your own state fromdata.approval_statusordata.per_post_status, never from the event name alone. - Events go to the webhooks of the team member who owns the posts. If a decision touches posts created by several team members, each owner's webhooks receive one event listing only their own posts.
- One decision is always one event, never one event per post: the payload's
data.post_idsarray lists every post the decision touched (truncated at 500 withdata.truncated: trueanddata.total_posts).data.approval_statusis the shared status when every touched post landed on the same one; otherwise it isnullanddata.per_post_status(keyed by post id) is used instead. data.reviewerisnullforapproval.requested(the agency sent it, nobody has reviewed yet);data.requested_by_user_idis only meaningful there.post.updatedis NOT also sent for an approval change, so a webhook consumer that already listens forapproval.*will not see a duplicate notification throughpost.updated.
Example approval.rejected payload:
{
"id": "6cbb86b6-8c53-43f0-85b3-cf18a9f3a4a1",
"event": "approval.rejected",
"created_at": "2026-09-18T14:05:00.000Z",
"data": {
"brand_id": "9f6d99a4-b0e0-4ee0-8949-6a87786a52cc",
"content_plan_id": null,
"post_ids": [971, 972],
"truncated": false,
"total_posts": 2,
"post_group_ids": ["a1b2c3d4-e5f6-7890-abcd-ef1234567890"],
"axis": "asset",
"comment": "The product shot looks cropped wrong, can you fix and resend?",
"approval_status": "rejected",
"per_post_status": null,
"reviewer": { "id": "8c1e...", "email": "client@example.com", "display_name": "Jordan" },
"signed_in_via": null,
"requested_by_user_id": null,
"decided_at": "2026-09-18T14:05:00.000Z"
}
}
data.signed_in_via is null for an ordinary review, where the client confirmed their own email address to sign in, and for approval.requested. It is "agency_portal" when the decision was made in a session your own API key opened with a client portal sign-in link. It is "agency_api" when an owner or admin recorded approve, request_changes, or reject through POST /api/v1/posts/{id}/approval. That value means the agency acted as itself. It does not mean a guest reviewer signed in.
Client portal sign-in links
If you run your own client portal, your customers already signed in to it. Sending them to posterly and asking for an emailed code is a second sign-in they should not have to do. This endpoint mints a one-time link that drops an already-invited reviewer straight into their review page.
Before you start. Create a key in /dashboard/api and tick Create client portal sign-in links. Existing keys do not have the permission and cannot be upgraded; make a new one. This key signs your clients in, so treat it like a password: keep it on your server, never send it to a browser, never put it in client-side code.
Create a link:
POST /api/v1/brands/{brand_id}/review-links
Authorization: Bearer pst_live_...
Content-Type: application/json
{
"reviewer_email": "reviewer@clientcompany.com",
"content_plan_id": "0a3b1f0e-4d2c-4a1a-9a3f-9b1d2c3e4f50"
}
content_plan_id is optional and opens the link on that one plan. Leave it out for the brand's whole review page.
{
"url": "https://www.poster.ly/api/client-review/auth/portal?token=...",
"expires_at": "2026-09-20T12:05:00.000Z",
"expires_in_seconds": 300,
"single_use": true,
"reviewer": { "email": "reviewer@clientcompany.com", "display_name": "Sam" },
"brand_id": "9f6d99a4-b0e0-4ee0-8949-6a87786a52cc",
"content_plan_id": "0a3b1f0e-4d2c-4a1a-9a3f-9b1d2c3e4f50"
}
Redirect the browser to url immediately. Do not store it, email it, or put it in a page a crawler can reach. Mint a fresh one for every click: it works once and expires after five minutes. If a reviewer's session goes stale, mint another.
What your client sees. One confirmation screen: their own masked email address, the brand name, your workspace name, and a Continue button. Clicking it signs them in and lands them on the review page. That click is required; the link never signs anyone in on its own, so a link scanner, an email preview or a prefetch cannot burn it or open a session.
What the session can do. It opens that one brand and nothing else, even when the same person reviews for several of your clients, and it lasts 12 hours without extending. If they need another brand, mint a link for that brand.
It is labelled, to everyone. An approval made this way is recorded as made through your client portal. It shows in your activity history, in the approval email, in data.signed_in_via on approval.* webhooks, and in the client's own review history when they later sign in with their own email. That is deliberate: you are acting on their behalf, and the record says so.
Who can do it. Only for reviewers you have already invited to that brand. The API-key owner must be an owner or admin of the brand's workspace at the moment of the call, so a demoted teammate's key stops working immediately. The workspace owner's plan must include guest reviewers, and the API add-on must be active.
The check happens when the link is created, not when it is used, so a link minted moments before its creator loses owner or admin access stays usable for the rest of its five minutes. If you need someone cut off instantly, revoke the reviewer or the API key as well.
If it fails, fall back. Use the permanent review page link from the brand or plan page. It always works, it just asks for the emailed code.
| Code | Status | What it means |
|---|---|---|
api_key_required | 403 | Called from a browser session instead of with an API key. |
insufficient_scope | 403 | The key does not have review_links:write. Create a new key with the permission ticked. |
insufficient_role | 403 | The key owner is not an owner or admin of the brand's workspace. |
plan_required | 403 | The workspace owner's plan does not include guest reviewers. |
brand_not_found | 404 | No such brand, or it is outside this key's workspace. |
reviewer_not_found | 404 | That email is not a current reviewer for this brand. Invite them first. |
content_plan_not_found | 404 | That plan does not belong to this brand. |
validation_error | 400 | reviewer_email is missing or not an email address. |
rate_limited | 429 | Too many links for the same reviewer. Retry after Retry-After. |
portal_links_unavailable | 503 | Temporarily unavailable. Fall back to the permanent link. |
One honest note about the token: for the five minutes it is alive it sits in a URL, so it will appear in your own server logs and in ours, exactly like the emailed magic link already does. posterly never writes it to its database in readable form (only a SHA-256 hash is stored), never logs it itself, and never sends it to analytics or any third party. The page that consumes it blanks its referrer and loads no scripts at all, and the page the reviewer lands on never carries the token.
Team management
List your colleagues, invite new ones, change their role or brand access, and remove them, from your own code or an AI agent. It follows the same rules as Workspace → Team, with a few extra guard rails because a key can be copied.
Before you start. Create a key in /dashboard/api and tick Manage team. Existing keys cannot be upgraded; make a new one. The key acts as you, so only a current owner or admin of the workspace can make changes with it (anyone in the workspace can list). A browser session cannot call these endpoints. ChatGPT, Claude, and other connections that sign in with OAuth can use them without a key, but only while you are an owner or admin of the workspace (listing included); they never need the Manage team tick, and you do not have to reconnect.
GET /api/v1/workspaces/{workspace_id}/members
POST /api/v1/workspaces/{workspace_id}/members { email, role, brand_ids?, resend?, confirm?, preview_id? }
PATCH /api/v1/workspaces/{workspace_id}/members/{member_id} { role?, brand_ids?, confirm?, preview_id? } or { api_access, confirm?, preview_id? }
DELETE /api/v1/workspaces/{workspace_id}/members/{member_id}?confirm=true&preview_id=...
PATCH /api/v1/workspaces/{workspace_id}/invites/{invite_id} { role?, brand_ids?, confirm?, preview_id? }
DELETE /api/v1/workspaces/{workspace_id}/invites/{invite_id}?confirm=true&preview_id=...
Preview, then confirm. Every change is two calls. The first (without confirm) changes nothing and sends nothing: it returns requires_confirmation: true, a plain-language preview.summary (who, which role, which brands, whether an email goes out, and for a new invite how many of your plan's seats are in use), and a preview_id that lasts 15 minutes. Show the preview to the person, and only after they approve, repeat the call with confirm: true and the preview_id. If anything changed in between, the confirm answers 409 preview_stale and does nothing. This protects the person from an agent acting on something they did not see; it is not a security control, since every rule is checked again on confirm. Previews count toward the daily limits, so one invite (preview plus confirm) uses 2 of the 10 invite requests a workspace gets per day.
{
"requires_confirmation": true,
"preview": {
"action": "invite",
"summary": "Send an invite email to sam@agency.com to join the workspace as Editor. It counts toward your 10 seats (4 in use now).",
"email": "sam@agency.com",
"role": "editor",
"brand_ids": [],
"sends_email": true,
"seats_used": 4,
"seat_limit": 10
},
"preview_id": "mp1.1790000000000.x9...",
"next_step": "Nothing has changed yet. Show this preview to the user..."
}
Deliberate limits in this version. These are the safer defaults; each is one setting on our side, so tell us through Support if you need a different one.
- Admin seats stay in the dashboard. The API invites and sets Publisher, Editor, or Viewer. Asking for Admin answers
403 admin_role_dashboard_only. - Always an invite, never an instant add. The API sends a pending invite email even when the address already has a posterly account; they join by clicking it, or automatically the next time they sign in with that email. The preview never says whether the address has an account. The email subject names the person who invited them (their name with their email address, or just the email address), then the workspace. The dashboard works differently: it adds an address that already has an account straight away and emails them a sign-in link, but the API never does and never says whether an address has an account. See Teams and roles.
- OAuth connections: owners and admins only. ChatGPT, Claude, and other OAuth connections can list and change the team only while you are an owner or admin of that workspace; anyone else gets
403 team_management_requires_admin, even for a list. It works for connections made before this was switched on, with no reconnect, and only for connections allowed to manage your accounts (accounts:write, which every standard ChatGPT and Claude connection has). The consent screen lists it under managing your accounts, and it applies to any app you connect through posterly sign-in, not only ChatGPT and Claude, so only connect apps you trust. All the other guard rails here apply to them too. - The owner hears about every change. Each confirmed invite, role or brand change, removal and revoke made through the API emails the workspace owner, naming the address, the role, and the API key (name and prefix) that made it. The response field
owner_notifiedistrueonly when that email was actually sent.
Repeats are safe. Inviting an address that already has a pending invite with the same role and brands answers 200 already_invited and sends nothing; pass resend: true to email it again. Inviting a current member answers 409 already_member with their member_id; change them with PATCH instead. Send an Idempotency-Key header on the confirm call of an invite (POST .../members) and a retry replays the first response for 24 hours. The header is honoured on that POST only; PATCH and DELETE ignore it, and a repeated confirm there fails safely with preview_stale or 404 once the change has happened.
Responses never include an invite link or token. Invites go to the person by email only. Removing a member revokes their API keys for that workspace before freeing the seat, the same as the dashboard.
| Code | Status | What it means |
|---|---|---|
api_key_required | 403 | Called from a browser session instead of an API key. |
insufficient_scope | 403 | The key does not have Manage team. Create a new key with it ticked. |
insufficient_role | 403 | You are not an owner or admin of this workspace. |
team_management_requires_admin | 403 | Called from ChatGPT, Claude, or another OAuth connection, and you are not an owner or admin of this workspace. Applies to listing too. |
admin_role_dashboard_only | 403 | Admin seats can only be granted in the dashboard. |
seat_limit_reached | 403 | Every seat on the plan is taken; seats_used and seat_limit say how many. |
workspace_not_found | 404 | No such workspace, you are not a member, or the key is bound to another workspace. |
workspace_access_revoked | 403 | Your seat in this workspace was removed. |
cannot_change_own_access | 403 | You tried to change your own API access. Another owner or admin has to. |
owner_api_access_fixed | 403 | The owner's API access comes from their own add-on, not a switch. |
plan_required | 403 | The workspace owner's plan does not include team members. |
subscription_required | 402 | The workspace owner has no active subscription. |
validation_error | 400 | A field is missing or malformed, for example an email with spaces, commas, or no dot in the domain. |
invalid_brand_ids | 400 | One or more brand_ids are not brands in this workspace. |
cannot_invite_self | 400 | You tried to invite your own email address. |
preview_required | 400 | confirm: true was sent without a preview_id. |
preview_stale | 409 | The preview expired or no longer matches. Preview again. |
already_member | 409 | That person already holds a seat. Use PATCH. |
invite_in_progress | 409 | Another invite for this workspace is being processed. Retry in a moment. |
idempotency_key_reused | 409 | This Idempotency-Key was already used with a different request. |
rate_limited / recipient_rate_limited | 429 | See the limits below. Retry after Retry-After. |
temporarily_unavailable | 503 | Rate limiting or the invite lock is briefly unreachable, so nothing was changed. Retry after Retry-After. |
Updates feed
GET /api/v1/updates
Returns the posterly updates feed (the same product news shown at /updates), newest first. Requires accounts:read and an active posterly subscription; without one it returns 403 with code: "subscription_required", exactly like the web page.
Query parameters:
limit, integer 1 to 50, defaults to 10.offset, integer, defaults to 0. The response carriestotal,has_more, andnext_offset.since, ISO date. Only return updates published on or after this date.include_content, boolean, defaults tofalse. Whentrue, each update includes its full markdown body incontent.
curl "https://www.poster.ly/api/v1/updates?limit=5&since=2026-07-01" \
-H "Authorization: Bearer pst_live_your_key_here"
Each update includes slug, title, date, version, summary, type (feature, improvement, fix, or null), areas (product areas such as composer or api), a canonical url on poster.ly, and optional image/video links.
CLI
The public posterly CLI is the terminal interface over the same /api/v1 surface used by the REST API and MCP. Run it without installing anything globally:
npx @posterly/cli@latest auth:login
npx @posterly/cli@latest doctor --pretty
npx @posterly/cli@latest whoami --pretty
npx downloads and runs the CLI for that command. It does not leave a permanent posterly binary on your shell PATH, so keep using npx @posterly/cli@latest for each command unless you install globally with npm i -g @posterly/cli@latest.
If global install fails with an EACCES error under /usr/local/lib/node_modules, use npx or switch to a user-owned Node install such as nvm. A local npm i @posterly/cli install only exposes ./node_modules/.bin/posterly inside that folder.
After a global install, the same commands can be run as posterly:
posterly auth:login
posterly auth:key
posterly doctor --pretty
posterly whoami --pretty
posterly connect:link instagram --pretty
posterly oauth:create-client --client-name "Agency approval app" --redirect-uri https://agency.example.com/oauth/callback --scopes accounts:read,posts:read,posts:write --pretty
posterly platforms:schema --platform instagram --pretty
posterly accounts:disconnect 123 --confirm --pretty
posterly posts:create --account-id 123 --caption "Launching soon"
posterly posts:status 123 --status paused --confirm --pretty
posterly posts:missing 123 --pretty
posterly posts:release-id 123 --release-id launch-2026-05-16 --group-id campaign-42 --pretty
posterly posts:delete-group campaign-42 --confirm --pretty
posterly gbp:reviews --account-id 123 --unanswered --pretty
posterly gbp:audit 123 --pretty
posterly gbp:media --account-id 123 --pretty
posterly gbp:add-media --account-id 123 --source-url https://cdn.example.com/front.jpg --category COVER --confirm --pretty
posterly gbp:delete-media --account-id 123 --media-name accounts/123/locations/456/media/abc --confirm --pretty
posterly gbp:suggest-reply --account-id 123 --star-rating 5 --review-text "Great service" --pretty
auth:login opens a browser and uses posterly's OAuth + PKCE flow to store a local token in ~/.posterly/config.json. auth:key remains available for CI, servers, and users who prefer to paste a Personal Access Token. Use posterly doctor --pretty to validate Node, the configured API origin, the installed package version, and the API key against /api/v1/whoami. The CLI outputs JSON by default for jq-friendly scripting; use --pretty for formatted output.
For non-production deployments, set --url or POSTERLY_URL. The CLI refuses to send API keys to non-local http:// URLs by default; use https://, http://localhost for local development, or --allow-insecure-url only for a trusted private development endpoint.
Rate limits
API limits are per API key and route-aware:
POST /api/v1/postsandPOST /api/v1/posts/batch: 100 requests per hour- post creation: 1,000 created post items per hour per user across all API keys; a batch of 25 counts as 25 items
POST /api/v1/media/*: 120 requests per hour. A signed upload batch (files) counts one request per file against this limit- read endpoints, including accounts, posts, slots, analytics, and subscription status: 300 requests per hour
POST /api/v1/subscription/*(cancel, pause, resume, downgrade, extra accounts): 30 requests per hourPOST /api/v1/support/chat: 30 requests per hour per userPOST /api/v1/ai/generate-captions: 20 requests per minute per user- team invites (
POST /api/v1/workspaces/{id}/members): 10 per workspace and 30 per user per 24 hours, and at most 3 invite emails to one address per workspace per 24 hours. Previews count, so a preview plus its confirm uses 2 of the 10 daily invite requests - team changes (role, brand access, removal, invite revoke): 30 per workspace per 24 hours, previews included, so a preview plus its confirm uses 2
- authenticated API traffic: 2,000 requests per hour per user across all API keys
- per workspace, shared by every key and AI app acting in it (the owner and teammates together), set by the workspace owner's plan: Starter 500 requests and 100 post creates per hour, Pro 1,000 and 200, Power 2,000 and 300, Agency 4,000 and 600. The 429 message names the cap
Free trial limits. While the workspace owner is on a free trial that includes API and MCP access, they replace the per-key limits above: 20 write requests and 100 read requests per hour (separate buckets, answered with 429), and 25 posts created through the API and MCP over the whole trial. Batch posting, AI video generation, webhooks and OAuth client management answer 403 with code: "trial_feature_locked" until the paid plan starts, and the post cap answers 403 with code: "trial_post_cap_reached". These errors carry trial_limit: true and an upgrade_url; retrying does not help, so start the paid plan to lift them.
API access blocked by the add-on. A call from an account without the API and MCP add-on (or whose add-on is the wrong plan, for example Agency without the Agency add-on) answers 403. The body carries code (api_addon_required, paid_base_subscription_required, agency_api_plan_required or api_trial_requires_checkout), required_plan_code, required_plan_name, required_price_usd and required_price_interval. These describe the self-serve standalone purchase only, and required_price_interval is always month (Starter $3, Pro $4, Power $5, Agency $29 a month). They are not the price of the add-on on a yearly plan: the add-on that joins a yearly free-trial seat is $29, Pro $38, Power $48, Agency $278 a year, and the 403 does not quote it. GET /api/subscription/api-addon (signed in) reports price_usd with price_interval for the add-on you hold, so a yearly add-on ($29 a year) is not mistaken for Agency's monthly $29. When a paid combined subscription holds the individual add-on and the plan needs the Agency one, the same endpoint adds addon_swap_offer (amount_due_now_cents, currency, interval, target_plan_code, new_price_usd, renewal_price_usd, proration_date): the upgrade swaps the add-on item on the same subscription and charges the saved card the prorated difference now, so POST /api/subscription/api-addon returns that quote (mode: "swap_quote") and charges nothing until it is sent again with confirm: true, the quoted expected_amount_cents and the quote's proration_date (honoured for 10 minutes inside the billing period, so the invoice equals the quoted amount to the cent). If the amount changed, the date is missing or too old, it answers 409 quote_changed with a fresh quote and charges nothing. 409 api_addon_tier_mismatch means the stored plan needs more than the live plan can give; contact support.
Each API response includes X-Request-Id, X-RateLimit-Limit, X-RateLimit-Remaining, and X-RateLimit-Reset. A 429 response also includes Retry-After, retry_after, limit, remaining, and reset. Pre-authenticated requests are also IP-limited before API key lookup to protect the API from invalid-token floods.
Full reference
See the interactive API Reference for complete endpoint documentation with request/response examples.