# PostNinja developer docs (full) > PostNinja schedules and publishes social media posts. This file is every page of https://postninja.app/docs as Markdown, for pasting into an AI agent's context. AI agents connect to the MCP server at https://postninja.app/api/mcp; code uses the REST API at https://postninja.app/api/v1. Pages in this file: - [PostNinja developer docs](https://postninja.app/docs) (Markdown: https://postninja.app/docs/index.md) - [Connect an AI agent (MCP)](https://postninja.app/docs/mcp) (Markdown: https://postninja.app/docs/mcp.md) - [MCP tools](https://postninja.app/docs/mcp/tools) (Markdown: https://postninja.app/docs/mcp/tools.md) - [Recipes for AI agents](https://postninja.app/docs/mcp/recipes) (Markdown: https://postninja.app/docs/mcp/recipes.md) - [REST API reference](https://postninja.app/docs/api) (Markdown: https://postninja.app/docs/api.md) - [Webhooks](https://postninja.app/docs/webhooks) (Markdown: https://postninja.app/docs/webhooks.md) - [Platform rules and options](https://postninja.app/docs/platforms) (Markdown: https://postninja.app/docs/platforms.md) - [Plans, credits and limits](https://postninja.app/docs/limits) (Markdown: https://postninja.app/docs/limits.md) --- # PostNinja developer docs Source: https://postninja.app/docs Schedule and publish posts to X, Instagram, Threads, Facebook, TikTok, YouTube and Pinterest from an AI agent over MCP, or from your own code with the REST API. Both run the same rules as the app, so a post they accept is a post that can go out. ## Use these docs with your AI agent Every docs page is also plain Markdown: add `.md` to its address (this page: https://postninja.app/docs/index.md), or use "Copy page as Markdown". All pages together are at https://postninja.app/llms-full.txt. Paste either into your agent, or tell it: ```text Read https://postninja.app/llms-full.txt and set up PostNinja for me. ``` ## What you can do - **Create posts** for any mix of connected accounts: schedule them, publish now, save drafts, or drop them into the next free slot of your posting queue. - **Write per platform**: one text for every platform, or a different version for each (a short X post, hashtags on Instagram). - **Use every format**: photo and video uploads, X threads with their own photos, Instagram Stories, TikTok privacy and disclosure settings, YouTube titles and thumbnails, and first comments. - **Work as a team**: profiles (one per client or brand) with their own brand voice, post templates, and approvals for members' posts. - **See results**: analytics for a date range, an inbox of comments and Instagram and Facebook direct messages you can reply from, and webhooks that tell your systems when posts go out or fail. ## Two ways in | | MCP server | REST API | | --- | --- | --- | | For | AI agents: Claude, ChatGPT, Cursor, Codex, Gemini, Grok and others | Your code, scripts and automation tools (n8n, Make, Zapier) | | Address | `https://postninja.app/api/mcp` | `https://postninja.app/api/v1` | | Sign-in | Sign in with your account (OAuth), or an API key | An API key | | Docs | [Connect an agent](https://postninja.app/docs/mcp), [Tools](https://postninja.app/docs/mcp/tools), [Recipes](https://postninja.app/docs/mcp/recipes) | [API reference](https://postninja.app/docs/api), [Webhooks](https://postninja.app/docs/webhooks) | ## Quick start: AI agent ### 1. Get access Apps that sign in (ChatGPT, Claude.ai and Claude Desktop connectors, the Gemini and Grok apps) only need the server URL, `https://postninja.app/api/mcp`: you log in and click Allow. For everything else, create an API key under **Settings → API** in PostNinja. ### 2. Add the server to your agent The setup for each agent is on [Connect an AI agent](https://postninja.app/docs/mcp). For Claude Code it's one command: Terminal: ```bash claude mcp add --transport http postninja https://postninja.app/api/mcp \ --header "Authorization: Bearer $POSTNINJA_API_KEY" ``` ### 3. Ask Start with "List my connected accounts", then try "Schedule a post about our launch for tomorrow at 9am on X and Threads, with a shorter X version." More ideas: [Recipes](https://postninja.app/docs/mcp/recipes). ## Quick start: REST API ### 1. Create an API key Under **Settings → API** in PostNinja (owners and admins can). It's shown once; keep it secret, e.g. in `POSTNINJA_API_KEY`. ### 2. Get your account ids GET /api/v1/accounts: ```bash curl "https://postninja.app/api/v1/accounts" \ -H "Authorization: Bearer $POSTNINJA_API_KEY" ``` ### 3. Schedule a post POST /api/v1/posts: ```bash curl -X POST "https://postninja.app/api/v1/posts" \ -H "Authorization: Bearer $POSTNINJA_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "content": "Dark mode is here. Turn it on in Settings.", "accountIds": [ "3f2c8a10-6d1e-4b7a-9c55-1e2f3a4b5c6d", "8b7e1d22-0c4f-4e8a-a1b2-9d8c7b6a5f40" ], "mediaIds": [ "5b9d1c2e-3f4a-4b5c-9d6e-7f8a9b0c1d2e" ], "scheduledAt": "2026-10-12T09:00:00Z" }' ``` > **Note:** Instagram, TikTok, YouTube and Pinterest posts need a photo or video. Upload it first with `POST /api/v1/media` and pass its id in `mediaIds`. See [Platform rules](https://postninja.app/docs/platforms). ## Where to next - [Connect an AI agent](https://postninja.app/docs/mcp): Setup for Claude, ChatGPT, Cursor, Codex, Gemini, Grok, Windsurf and more. - [MCP tools](https://postninja.app/docs/mcp/tools): Every tool with its parameters, an example call and its result. - [Recipes](https://postninja.app/docs/mcp/recipes): Copy-paste prompts for a week of posts, threads, approvals, reports and more. - [REST API](https://postninja.app/docs/api): Authentication, errors and every endpoint with curl examples. - [Webhooks](https://postninja.app/docs/webhooks): Events, payloads and signature checks for your automations. - [Platform rules](https://postninja.app/docs/platforms): Text limits, media rules and the options each platform takes. - [Plans, credits and limits](https://postninja.app/docs/limits): What each plan includes, what X posts cost, and rate limits. --- # Connect an AI agent (MCP) Source: https://postninja.app/docs/mcp PostNinja runs a remote MCP server. Connect your AI agent once and it can see your accounts, write and schedule posts to X, Instagram, Threads, Facebook, TikTok, YouTube and Pinterest, handle approvals, read analytics and answer comments and direct messages, with 19 tools. ## Server details | | | | --- | --- | | Endpoint | `https://postninja.app/api/mcp` | | Transport | Streamable HTTP, stateless (POST). Works with 2025 and 2026 MCP clients. | | Sign-in | OAuth 2.1 (apps that sign in), or `Authorization: Bearer pn_…` with an API key | | Tools | 19: see [MCP tools](https://postninja.app/docs/mcp/tools) | | Rate limit | 120 requests a minute per API key, or per app and person | ## Sign in or use an API key | | Sign in (OAuth) | API key | | --- | --- | --- | | Used by | ChatGPT, Claude.ai and Claude Desktop connectors, the Gemini and Grok apps | Claude Code, Cursor, VS Code, Windsurf, Codex, Gemini CLI, n8n, SDKs and scripts | | Setup | Paste the server URL, log in, click Allow | Create a key under **Settings → API**, add it as a header | | Acts as | You, with your role in the workspace | An admin of the workspace | | Access | What you allow: `posts:read`, or `posts:read` and `posts:write` | Read & write, or Read only; optionally limited to some profiles | | Disconnect | **Settings → API → Connected apps** | Revoke the key under **Settings → API** | ## Permissions - **`posts:read`** lets an app use the read tools: `list_accounts`, `get_workspace`, `list_profiles`, `list_templates`, `list_posts`, `get_post`, `get_queue`, `get_analytics`, `list_comments`, `list_messages`. - **`posts:write`** is needed for tools that change something: `create_post`, `update_post`, `delete_post`, `approve_post`, `request_changes`, `update_queue`, `reply_to_comment`, `reply_to_message`, `upload_image`. An app that only has `posts:read` gets an error asking you to reconnect and allow changes. - **Read only API keys** can call every read tool; tools that change something return an error saying the key can only read. - **Keys limited to some profiles** (Pro and Agency) only see and post to those profiles' accounts, and can only change those profiles' queues. - **Roles still apply to apps that sign in.** When approvals are on, a member's posts wait for approval (`awaiting_approval`), and only owners and admins can approve, ask for changes or change the queue. API keys act as admins, so their posts are scheduled straight away. - Members whose access is limited to some profiles can't connect apps; ask a workspace admin for a limited API key instead. ## Connect your agent Replace `pn_YOUR_KEY` with a key from **Settings → API**, or keep it in an environment variable (`POSTNINJA_API_KEY`) where the client supports that. After connecting, ask your agent "List my connected accounts" to check it works. Jump to: [Claude Desktop](#setup-claude-desktop) · [ChatGPT](#setup-chatgpt) · [Claude Code](#setup-claude-code) · [Cursor](#setup-cursor) · [OpenAI Codex](#setup-codex) · [VS Code](#setup-vs-code) · [Windsurf](#setup-windsurf) · [Gemini](#setup-gemini) · [Gemini CLI](#setup-gemini-cli) · [Grok](#setup-grok) · [DeepSeek](#setup-deepseek) · [OpenClaw](#setup-openclaw) · [Cline](#setup-cline) · [Zed](#setup-zed) · [n8n](#setup-n8n) · [OpenAI Agents SDK](#setup-openai-agents-sdk) · [Claude Agent SDK](#setup-claude-agent-sdk) · [Goose](#setup-goose) · [Any MCP client](#setup-mcp-server) ### Claude Desktop Add PostNinja as a custom connector and sign in; no API key needed. This works in the desktop app and on claude.ai. Prefer an API key? Use the config file instead (second box). 1. In Claude, open Customize → Connectors and click Add custom connector. 2. Name it PostNinja and paste the server URL below. 3. Click Add, then Connect. Log in to PostNinja and click Allow. Server URL: ``` https://postninja.app/api/mcp ``` Or with an API key: claude_desktop_config.json (Settings → Developer → Edit Config) (macOS: ~/Library/Application Support/Claude/ · Windows: %APPDATA%\Claude\): ``` { "mcpServers": { "postninja": { "command": "npx", "args": ["mcp-remote", "https://postninja.app/api/mcp", "--header", "Authorization:${AUTH_HEADER}"], "env": { "AUTH_HEADER": "Bearer pn_YOUR_KEY" } } } } ``` > **Note:** The API key route uses the mcp-remote bridge, which needs Node.js 18 or newer. Quit Claude completely and reopen it after editing the file. > > The header is written without a space (Authorization:${AUTH_HEADER}) on purpose: it avoids a known bug with spaces in arguments on Windows. Example prompts and answers to common questions: [Claude Desktop guide](https://postninja.app/ai-agents/claude-desktop). ### ChatGPT **Sign in, no API key.** In ChatGPT on the web, with developer mode on (Plus, Pro, Business, Enterprise or Edu): 1. Open Settings → Security and login and turn on Developer mode. 2. Go to chatgpt.com/plugins, click +, then Create MCP App. 3. Name it PostNinja and paste the server URL below as the connection. 4. Click Create, then Connect. Log in to PostNinja and click Allow. 5. In a chat, pick PostNinja from the + menu and ask away. Server URL: ``` https://postninja.app/api/mcp ``` > **Note:** We're submitting PostNinja to ChatGPT's app directory. Once it's listed, you'll be able to add it in one click without developer mode. > > ChatGPT asks before each action that changes something, like scheduling a post. Example prompts and answers to common questions: [ChatGPT guide](https://postninja.app/ai-agents/chatgpt). ### Claude Code Run this in your terminal. Add --scope user to make it available in every project. Terminal: ``` claude mcp add --transport http postninja https://postninja.app/api/mcp \ --header "Authorization: Bearer pn_YOUR_KEY" ``` Or share it with your team in .mcp.json (reads the key from your environment) (.mcp.json in your project): ``` { "mcpServers": { "postninja": { "type": "http", "url": "https://postninja.app/api/mcp", "headers": { "Authorization": "Bearer ${POSTNINJA_API_KEY}" } } } } ``` Example prompts and answers to common questions: [Claude Code guide](https://postninja.app/ai-agents/claude-code). ### Cursor Add PostNinja to your MCP config, for this project or for every project. mcp.json (.cursor/mcp.json (this project) or ~/.cursor/mcp.json (all projects)): ``` { "mcpServers": { "postninja": { "url": "https://postninja.app/api/mcp", "headers": { "Authorization": "Bearer ${env:POSTNINJA_API_KEY}" } } } } ``` > **Note:** Set POSTNINJA_API_KEY in your environment, or paste the key in place of ${env:POSTNINJA_API_KEY}. Example prompts and answers to common questions: [Cursor guide](https://postninja.app/ai-agents/cursor). ### OpenAI Codex Set the key in your shell, then add the server with one command: Terminal: ``` export POSTNINJA_API_KEY=pn_YOUR_KEY codex mcp add postninja --url https://postninja.app/api/mcp --bearer-token-env-var POSTNINJA_API_KEY ``` Or in config.toml (~/.codex/config.toml): ``` [mcp_servers.postninja] url = "https://postninja.app/api/mcp" bearer_token_env_var = "POSTNINJA_API_KEY" ``` > **Note:** POSTNINJA_API_KEY must be set in the shell that starts Codex. Example prompts and answers to common questions: [OpenAI Codex guide](https://postninja.app/ai-agents/codex). ### VS Code Add this to your workspace's .vscode/mcp.json, or run MCP: Open User Configuration to make it global. VS Code asks for the key the first time. .vscode/mcp.json (.vscode/mcp.json or your user mcp.json): ``` { "inputs": [ { "type": "promptString", "id": "postninja-key", "description": "PostNinja API key (pn_…)", "password": true } ], "servers": { "postninja": { "type": "http", "url": "https://postninja.app/api/mcp", "headers": { "Authorization": "Bearer ${input:postninja-key}" } } } } ``` > **Note:** The top-level key is "servers" in VS Code, not "mcpServers". Example prompts and answers to common questions: [VS Code guide](https://postninja.app/ai-agents/vs-code). ### Windsurf In the Cascade panel, open the … menu, go to MCPs and click the icon to open the MCP config file. Add PostNinja: mcp_config.json: ``` { "mcpServers": { "postninja": { "serverUrl": "https://postninja.app/api/mcp", "headers": { "Authorization": "Bearer ${env:POSTNINJA_API_KEY}" } } } } ``` > **Note:** Windsurf uses "serverUrl" (not "url") for remote servers. Windsurf was renamed Devin Desktop in 2026; the setup is the same. Example prompts and answers to common questions: [Windsurf guide](https://postninja.app/ai-agents/windsurf). ### Gemini In the Gemini app, add PostNinja as a custom app and sign in. Building your own agent? Pass PostNinja to the Gemini API as an MCP server instead (code below). 1. On gemini.google.com, open Settings → Connected Apps → Custom apps. 2. Click Add a custom app and paste the server URL from the first box below. 3. Log in to PostNinja when asked and click Allow. Server URL for the Gemini app: ``` https://postninja.app/api/mcp ``` Interactions API (Antigravity agent): ``` curl "https://generativelanguage.googleapis.com/v1beta/interactions" \ -H "x-goog-api-key: $GEMINI_API_KEY" -H "Content-Type: application/json" \ -d '{ "agent": "antigravity-preview-09-2026", "environment": "remote", "input": "Schedule a post about our launch for tomorrow 9am on X and Threads", "tools": [{ "type": "mcp_server", "name": "postninja", "url": "https://postninja.app/api/mcp", "headers": { "Authorization": "Bearer pn_YOUR_KEY" } }] }' ``` Python (google-genai + mcp): ``` import asyncio, os from google import genai from mcp import ClientSession from mcp.client.streamable_http import streamablehttp_client client = genai.Client() async def main(): headers = {"Authorization": f"Bearer {os.environ['POSTNINJA_API_KEY']}"} async with streamablehttp_client("https://postninja.app/api/mcp", headers=headers) as (read, write, _): async with ClientSession(read, write) as session: await session.initialize() response = await client.aio.models.generate_content( model="gemini-flash-latest", contents="Schedule a post about our launch for tomorrow 9am on X", config=genai.types.GenerateContentConfig(tools=[session]), ) print(response.text) asyncio.run(main()) ``` > **Note:** MCP in the Interactions API is in preview, and the SDK's MCP support is experimental, so details may change. > > Custom apps in the Gemini app are for personal Google accounts, 18 and over, in the US, in English, with Keep Activity on. Example prompts and answers to common questions: [Gemini guide](https://postninja.app/ai-agents/gemini). ### Gemini CLI Run this once: Terminal: ``` gemini mcp add --transport http --header "Authorization: Bearer pn_YOUR_KEY" postninja https://postninja.app/api/mcp ``` Or in settings.json (~/.gemini/settings.json (or .gemini/settings.json in a project)): ``` { "mcpServers": { "postninja": { "httpUrl": "https://postninja.app/api/mcp", "headers": { "Authorization": "Bearer pn_YOUR_KEY" } } } } ``` > **Note:** Use "httpUrl" (not "url", which means the older SSE transport). Gemini CLI doesn't expand environment variables in headers, so paste the key itself. Example prompts and answers to common questions: [Gemini CLI guide](https://postninja.app/ai-agents/gemini-cli). ### Grok In the Grok app, add PostNinja as a custom connector and sign in. In Grok Bot, add it as a custom MCP server with an API key. From code, pass it to the xAI API. 1. On grok.com, go to Connectors → New Connector → Custom. 2. Paste the server URL from the first box below. 3. Log in to PostNinja when asked and click Allow. Server URL for the Grok app: ``` https://postninja.app/api/mcp ``` Grok Bot: custom MCP server: ``` Name: postninja URL: https://postninja.app/api/mcp Header: Authorization: Bearer pn_YOUR_KEY ``` xAI API (Responses API, remote MCP tool): ``` curl https://api.x.ai/v1/responses \ -H "Authorization: Bearer $XAI_API_KEY" -H "Content-Type: application/json" \ -d '{ "model": "grok-4.7", "input": "List my scheduled posts for this week", "tools": [{ "type": "mcp", "server_url": "https://postninja.app/api/mcp", "server_label": "postninja", "authorization": "pn_YOUR_KEY" }] }' ``` > **Note:** Grok Bot is in beta, so its plugin settings may move. Servers you've set up in the Cursor editor aren't carried over to Grok Bot automatically. > > The xAI API runs the MCP calls on xAI's servers, so your PostNinja address must be public. Example prompts and answers to common questions: [Grok guide](https://postninja.app/ai-agents/grok). ### DeepSeek For example, run Claude Code on DeepSeek's Anthropic-compatible API, then add PostNinja: Terminal: Claude Code on DeepSeek: ``` export ANTHROPIC_BASE_URL=https://api.deepseek.com/anthropic export ANTHROPIC_AUTH_TOKEN=YOUR_DEEPSEEK_KEY export ANTHROPIC_MODEL=deepseek-flash claude mcp add --transport http postninja https://postninja.app/api/mcp \ --header "Authorization: Bearer pn_YOUR_KEY" ``` > **Note:** The DeepSeek chat app can't connect to MCP servers. Use one of the agents DeepSeek lists under Agent integrations in its API docs. > > Prefer a chat-app agent? OpenClaw also runs DeepSeek models and connects to PostNinja; see the OpenClaw guide. Example prompts and answers to common questions: [DeepSeek guide](https://postninja.app/ai-agents/deepseek). ### OpenClaw Add PostNinja to OpenClaw from the CLI, then check the connection: Terminal: ``` openclaw mcp set postninja '{"url":"https://postninja.app/api/mcp","transport":"streamable-http","headers":{"Authorization":"Bearer pn_YOUR_KEY"}}' openclaw mcp probe postninja ``` Or in openclaw.json (~/.openclaw/openclaw.json): ``` { "mcp": { "servers": { "postninja": { "url": "https://postninja.app/api/mcp", "transport": "streamable-http", "headers": { "Authorization": "Bearer pn_YOUR_KEY" } } } } } ``` > **Note:** Use OpenClaw 2026.5.12 or later: older versions could forward custom headers, like your API key, across a redirect. > > openclaw mcp doctor warns about keys written straight into the config; OpenClaw's secret settings keep it out of the file. Example prompts and answers to common questions: [OpenClaw guide](https://postninja.app/ai-agents/openclaw). ### Cline Click the MCP Servers icon in Cline, choose Configure → Configure MCP Servers, and add: cline_mcp_settings.json: ``` { "mcpServers": { "postninja": { "type": "streamableHttp", "url": "https://postninja.app/api/mcp", "headers": { "Authorization": "Bearer pn_YOUR_KEY" }, "disabled": false } } } ``` > **Note:** Set "type": "streamableHttp" explicitly. The Remote Servers form has no headers field, so edit the JSON. Example prompts and answers to common questions: [Cline guide](https://postninja.app/ai-agents/cline). ### Zed Open Settings → AI → MCP Servers → Add Remote Server, or add this to settings.json: settings.json: ``` { "context_servers": { "postninja": { "url": "https://postninja.app/api/mcp", "headers": { "Authorization": "Bearer pn_YOUR_KEY" } } } } ``` > **Note:** With the Authorization header, Zed connects with your API key. Leave it out and Zed can sign in with OAuth instead, if your Zed version supports it. Example prompts and answers to common questions: [Zed guide](https://postninja.app/ai-agents/zed). ### n8n In your workflow, add an AI Agent node, then add the MCP Client Tool sub-node with these settings: MCP Client Tool settings: ``` Endpoint: https://postninja.app/api/mcp Server Transport: HTTP Streamable Authentication: Bearer Auth Token: pn_YOUR_KEY Tools to Include: All ``` > **Note:** Use a recent n8n version: older MCP Client Tool versions only spoke SSE. You can also call the REST API from an HTTP Request node. Example prompts and answers to common questions: [n8n guide](https://postninja.app/ai-agents/n8n). ### OpenAI Agents SDK Install openai-agents, set POSTNINJA_API_KEY, and connect over Streamable HTTP: Python: ``` import os from agents import Agent, Runner from agents.mcp import MCPServerStreamableHttp async with MCPServerStreamableHttp( name="PostNinja", params={ "url": "https://postninja.app/api/mcp", "headers": {"Authorization": f"Bearer {os.environ['POSTNINJA_API_KEY']}"}, }, cache_tools_list=True, ) as postninja: agent = Agent(name="Social", instructions="You schedule social posts.", mcp_servers=[postninja]) result = await Runner.run(agent, "Schedule a post about our launch for tomorrow 9am on X.") ``` Example prompts and answers to common questions: [OpenAI Agents SDK guide](https://postninja.app/ai-agents/openai-agents-sdk). ### Claude Agent SDK Add the server to your query options and allow its tools: TypeScript: ``` import { query } from "@anthropic-ai/claude-agent-sdk"; for await (const message of query({ prompt: "Schedule tomorrow's launch post on X and Threads at 9am", options: { mcpServers: { postninja: { type: "http", url: "https://postninja.app/api/mcp", headers: { Authorization: `Bearer ${process.env.POSTNINJA_API_KEY}` }, }, }, allowedTools: ["mcp__postninja__*"], }, })) { if (message.type === "result") console.log(message.result); } ``` > **Note:** Without allowedTools: ["mcp__postninja__*"], the agent sees the tools but isn't allowed to call them. Example prompts and answers to common questions: [Claude Agent SDK guide](https://postninja.app/ai-agents/claude-agent-sdk). ### Goose Run goose configure → Add Extension → Remote Extension (Streamable HTTP), or add it to your config file: config.yaml (~/.config/goose/config.yaml (Windows: %APPDATA%\Block\goose\config\config.yaml)): ``` extensions: postninja: type: streamable_http name: postninja enabled: true uri: https://postninja.app/api/mcp headers: Authorization: "Bearer pn_YOUR_KEY" timeout: 300 ``` > **Note:** Restart Goose after editing the file. Example prompts and answers to common questions: [Goose guide](https://postninja.app/ai-agents/goose). ### Any MCP client Point your client at the endpoint below. Apps that sign in with OAuth only need the URL; everything else sends an API key as a Bearer token: Connection details: ``` Endpoint: https://postninja.app/api/mcp Transport: Streamable HTTP (stateless) Auth: OAuth 2.1 sign-in, or Authorization: Bearer pn_YOUR_KEY ``` Typical JSON config: ``` { "mcpServers": { "postninja": { "type": "http", "url": "https://postninja.app/api/mcp", "headers": { "Authorization": "Bearer pn_YOUR_KEY" } } } } ``` > **Note:** Apps that sign in (ChatGPT, Claude.ai, the Gemini and Grok apps) use OAuth 2.1 instead of a key: discovery starts at /.well-known/oauth-protected-resource, with dynamic client registration, client ID metadata documents and PKCE. Example prompts and answers to common questions: [Any MCP client guide](https://postninja.app/ai-agents/mcp-server). ### Not available yet - **OpenAI Dots**: Dots reach other tools through ChatGPT's apps. PostNinja already works as a ChatGPT app you add yourself; Dots will pick it up once it's listed in ChatGPT's app directory, which we've applied for. Until then, use it in ChatGPT directly. ## OAuth details for client developers Building your own MCP client? PostNinja follows the MCP authorization spec. A request without a valid token gets `401` with a `WWW-Authenticate: Bearer resource_metadata="…"` header pointing at the protected resource metadata. From there: | What | Where | | --- | --- | | Protected resource metadata (RFC 9728) | `https://postninja.app/.well-known/oauth-protected-resource` (also `…/oauth-protected-resource/api/mcp`) | | Authorization server metadata (RFC 8414) | `https://postninja.app/.well-known/oauth-authorization-server` (also `…/oauth-authorization-server/api/auth`) | | OpenID configuration | `https://postninja.app/.well-known/openid-configuration` | | Issuer | `https://postninja.app/api/auth` | | Resource (token audience) | `https://postninja.app/api/mcp` | | Scopes | `posts:read`, `posts:write`, plus `offline_access` for refresh tokens | | Client registration | Dynamic client registration (RFC 7591), or a client ID metadata document (your client_id is an https URL) | | Flow | Authorization code with PKCE. The person logs in and approves your app on a consent page. | Access tokens are JWTs tied to one workspace. They stop working as soon as the person disconnects the app, leaves the workspace or is suspended. A tool that needs more access answers with an error whose `_meta` carries a `WWW-Authenticate` challenge with `error="insufficient_scope"`. ## What the server tells your agent MCP clients receive these instructions when they connect, so your agent already knows the basics: Server instructions: ```text PostNinja schedules posts to X, Instagram, Threads, Facebook Pages, TikTok, YouTube Shorts and Pinterest. Call list_accounts first to get account ids. Instagram, TikTok, YouTube and Pinterest posts need media (upload_image); YouTube needs exactly one video. Use platformContent to give a platform its own text, e.g. a shorter version for X (280 characters) or hashtags for Instagram. For an X thread, put the follow-up posts in xThread (each costs credits). For an Instagram Story, set instagramFormat to "story" and attach exactly one photo or video. For TikTok, ask the user who can see the post (tiktok.privacy) and whether it's commercial content (tiktok.yourBrand / tiktok.brandedContent). Times are ISO 8601 with a time zone. get_workspace gives the brand voice to write in and whether members' posts need approval; accounts may belong to profiles (list_profiles, e.g. one per client or brand), each with its own brand voice, which wins for that profile's accounts. Posts with status awaiting_approval wait for an owner or admin: approve_post schedules them, request_changes sends them back to the author with a note. list_templates has ready-made posts with {blanks} you can fill in before create_post. Set queue: true on create_post to use the next free slot of the posting queue (get_queue shows the weekly slots) instead of a time. firstComment adds a first comment under the post; youtubeTitle sets a YouTube video's title. get_analytics reports impressions, engagements, follower growth and top posts for a date range; list_comments and reply_to_comment read and answer comments on Instagram, Facebook, Threads and YouTube posts; list_messages and reply_to_message read and answer Instagram and Facebook Page direct messages (replies only within 24 hours of the person's last message). ``` ## Errors - Mistakes the agent can fix (text too long, a missing photo, a time in the past, not enough credits) come back as tool results with `isError: true` and a message written for people. Good agents read it and try again. - A missing, revoked or expired key or token gets HTTP `401`. Too many requests get HTTP `429` with a `Retry-After` header. --- # MCP tools Source: https://postninja.app/docs/mcp/tools The 19 tools PostNinja's MCP server gives your agent. Parameter tables come straight from the server's own input schemas. Results are a short summary followed by JSON; the same data is in `structuredContent.result`. ## All tools | Tool | What it does | Access | | --- | --- | --- | | [`list_accounts`](#list_accounts) | List accounts | read | | [`get_workspace`](#get_workspace) | Get workspace settings | read | | [`list_profiles`](#list_profiles) | List profiles | read | | [`list_templates`](#list_templates) | List post templates | read | | [`list_posts`](#list_posts) | List posts | read | | [`get_post`](#get_post) | Get a post | read | | [`create_post`](#create_post) | Create or schedule a post | write | | [`update_post`](#update_post) | Change a post | write | | [`delete_post`](#delete_post) | Delete a post | write | | [`approve_post`](#approve_post) | Approve a post | write | | [`request_changes`](#request_changes) | Ask for changes to a post | write | | [`get_queue`](#get_queue) | Get the posting queue | read | | [`update_queue`](#update_queue) | Change the posting queue | write | | [`get_analytics`](#get_analytics) | Get analytics | read | | [`list_comments`](#list_comments) | List comments | read | | [`reply_to_comment`](#reply_to_comment) | Reply to a comment | write | | [`list_messages`](#list_messages) | List direct messages | read | | [`reply_to_message`](#reply_to_message) | Reply to a direct message | write | | [`upload_image`](#upload_image) | Upload an image | write | > **A typical flow:** `list_accounts` → (`get_workspace` / `list_profiles` for the brand voice) → `upload_image` if there's a photo → `create_post` → later, `get_post` to check it went out. ## list_accounts **List accounts.** The connected social accounts you can post to (every platform), with their ids. Read-only. Needs `posts:read`. **When to use it:** Call it first in almost every conversation: other tools take account ids. `status: needs_reconnect` means the account must be reconnected in the app before it can post. `profileId` says which profile (client or brand) an account belongs to. Parameters: none. Example arguments: ```json {} ``` Example result (the text your agent receives): ```text Connected accounts: [ { "id": "3f2c8a10-6d1e-4b7a-9c55-1e2f3a4b5c6d", "platform": "x", "username": "acme", "displayName": "Acme", "status": "active", "profileId": null }, { "id": "8b7e1d22-0c4f-4e8a-a1b2-9d8c7b6a5f40", "platform": "instagram", "username": "acme.studio", "displayName": "Acme Studio", "status": "active", "profileId": "5a6b7c8d-9e0f-4a1b-8c2d-3e4f5a6b7c8d" }, { "id": "c41d9e7a-2b3c-4d5e-8f60-718293a4b5c6", "platform": "tiktok", "username": "acmehq", "displayName": "Acme", "status": "needs_reconnect", "profileId": "5a6b7c8d-9e0f-4a1b-8c2d-3e4f5a6b7c8d" } ] ``` ## get_workspace **Get workspace settings.** The workspace's name, its brand voice (how posts should sound) and whether members' posts need approval. Read-only. Needs `posts:read`. **When to use it:** Before writing posts, to match the workspace's brand voice, and to know whether members' posts need approval. Parameters: none. Example arguments: ```json {} ``` Example result (the text your agent receives): ```text Workspace: { "name": "Acme", "approvalRequired": true, "brandVoice": "Friendly and plain-spoken. Short sentences, no jargon, one emoji at most." } ``` ## list_profiles **List profiles.** The profiles in this workspace (usually one per client or brand: a set of connected accounts), each with its own brand voice. Use a profile's brand voice when writing for its accounts, and its id (profileId) to filter list_posts. Read-only. Needs `posts:read`. **When to use it:** When the workspace runs several brands or clients. Write in a profile's `brandVoice` when posting to its accounts (it wins over the workspace's), and pass its id as `profileId` to `list_posts`, `get_analytics` or `update_queue`. Profiles are part of the Pro and Agency plans; on Personal the list is empty. Parameters: none. Example arguments: ```json {} ``` Example result (the text your agent receives): ```text Profiles: [ { "id": "5a6b7c8d-9e0f-4a1b-8c2d-3e4f5a6b7c8d", "name": "Fern & Clay", "color": "green", "brandVoice": "Warm, handmade, a little playful. Talk about the makers.", "accountIds": [ "8b7e1d22-0c4f-4e8a-a1b2-9d8c7b6a5f40", "c41d9e7a-2b3c-4d5e-8f60-718293a4b5c6" ], "reviewLink": true } ] ``` ## list_templates **List post templates.** Ready-made post templates (the built-in library and the team's saved ones). Each has {key} blanks described in fields: replace them with real details (drop lines whose optional blank you leave empty), then pass the text to create_post. Read-only. Needs `posts:read`. **When to use it:** When the user wants a post of a known kind (a sale, a launch, a restock, an event) or asks for ideas. Fill every `{key}` blank with real details, drop lines whose optional blank you leave empty, then pass the text to `create_post`. Parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `category` | string | no | One of: promote (Sales and offers), launch (Launches and news), engage (Engagement), educate (Tips and how-tos), proof (Social proof), behind (Behind the scenes), events (Events and seasons), story (Stories and personal brand), community (Community), creator (Creators and influencers), or saved (the team's own). | | `search` | string | no | Words to look for in the name and description. Up to 100 characters. | | `limit` | integer | no | 1 to 100. Default `30`. | Example arguments: ```json { "category": "launch", "search": "stock", "limit": 5 } ``` Example result (the text your agent receives): ```text Templates: [ { "id": "t_back-in-stock", "name": "Back in stock", "category": "launch", "description": "Let people know a popular item is available again.", "fields": [ { "key": "product", "label": "Product", "example": "Our speckled stoneware mugs", "optional": false }, { "key": "quantity", "label": "How many", "example": "We made 40 this round, and they went fast last time.", "optional": true }, { "key": "link", "label": "Link", "example": "fernandclay.com/mugs", "optional": true } ], "content": "Back in stock: {product} 🙌\n\n{quantity}\n\nThank you to everyone who asked us to bring them back. If you missed out before, now's your chance.\n{link}\n\n#BackInStock", "platformContent": {} } ] ``` ## list_posts **List posts.** Posts in this workspace, newest scheduled time first. Filter by time range, status or profile. Read-only. Needs `posts:read`. **When to use it:** To see what's scheduled, find drafts, check what failed, or review posts waiting for approval (`status: awaiting_approval`). Times are compared with each post's scheduled time. Parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `from` | string (ISO 8601) | no | Only posts scheduled at or after this time. | | `to` | string (ISO 8601) | no | Only posts scheduled before this time. | | `status` | string | no | awaiting_approval: posts waiting for an owner or admin to approve them. One of `draft`, `awaiting_approval`, `scheduled`, `publishing`, `published`, `partial`, `failed`. | | `profileId` | string (uuid) | no | Only posts going to at least one of this profile's accounts (see list_profiles). | | `limit` | integer | no | 1 to 100. Default `50`. | Example arguments: ```json { "from": "2026-10-12T00:00:00Z", "to": "2026-10-19T00:00:00Z", "status": "scheduled", "limit": 20 } ``` Example result (the text your agent receives): ```text Posts: [ { "id": "0d3c4b5a-6978-4e1f-a2b3-c4d5e6f7a8b9", "status": "scheduled", "content": "We just shipped dark mode. Here's the full story.", "platformContent": { "x": "Dark mode is here 🌙" }, "xThread": [], "xThreadMedia": [], "instagramFormat": "post", "tiktok": {}, "firstComment": null, "youtubeTitle": null, "youtubeThumbnail": null, "queued": false, "scheduledAt": "2026-10-12T09:00:00.000Z", "publishedAt": null, "createdAt": "2026-10-06T14:21:07.512Z", "media": [ { "id": "5b9d1c2e-3f4a-4b5c-9d6e-7f8a9b0c1d2e", "kind": "image", "url": "https://postninja.app/media/5b9d1c2e-3f4a-4b5c-9d6e-7f8a9b0c1d2e.jpg", "previewUrl": null } ], "targets": [ { "accountId": "3f2c8a10-6d1e-4b7a-9c55-1e2f3a4b5c6d", "platform": "x", "username": "acme", "status": "pending", "url": null, "error": null }, { "accountId": "8b7e1d22-0c4f-4e8a-a1b2-9d8c7b6a5f40", "platform": "instagram", "username": "acme.studio", "status": "pending", "url": null, "error": null } ] } ] ``` ## get_post **Get a post.** One post with its per-account status, links to the published posts, any errors, and its approval history. Read-only. Needs `posts:read`. **When to use it:** To check whether a post went out: each target has its own `status`, a link (`url`) once published, or the reason it failed (`error`). Also returns the approval history. Parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `postId` | string (uuid) | yes | The post's id. | Example arguments: ```json { "postId": "0d3c4b5a-6978-4e1f-a2b3-c4d5e6f7a8b9" } ``` Example result (the text your agent receives): ```text Post: { "id": "0d3c4b5a-6978-4e1f-a2b3-c4d5e6f7a8b9", "status": "awaiting_approval", "content": "We just shipped dark mode. Here's the full story.", "platformContent": { "x": "Dark mode is here 🌙" }, "xThread": [], "xThreadMedia": [], "instagramFormat": "post", "tiktok": {}, "firstComment": null, "youtubeTitle": null, "youtubeThumbnail": null, "queued": false, "scheduledAt": "2026-10-12T09:00:00.000Z", "publishedAt": null, "createdAt": "2026-10-06T14:21:07.512Z", "media": [ { "id": "5b9d1c2e-3f4a-4b5c-9d6e-7f8a9b0c1d2e", "kind": "image", "url": "https://postninja.app/media/5b9d1c2e-3f4a-4b5c-9d6e-7f8a9b0c1d2e.jpg", "previewUrl": null } ], "targets": [ { "accountId": "3f2c8a10-6d1e-4b7a-9c55-1e2f3a4b5c6d", "platform": "x", "username": "acme", "status": "pending", "url": null, "error": null }, { "accountId": "8b7e1d22-0c4f-4e8a-a1b2-9d8c7b6a5f40", "platform": "instagram", "username": "acme.studio", "status": "pending", "url": null, "error": null } ], "approvalHistory": [ { "action": "submitted", "by": "Sam Lee", "note": null, "at": "2026-10-06T10:02:11.000Z" }, { "action": "changes_requested", "by": "Alex Kim", "note": "Use the photo from Saturday.", "at": "2026-10-06T11:40:52.000Z" } ] } ``` ## create_post **Create or schedule a post.** Creates a post for one or more accounts. Set scheduledAt to schedule it, queue to put it in the next free queue slot, publishNow to post right away, or draft to save it without scheduling. Every platform's rules are checked (X: 280 characters, up to 4 photos or 1 video; Instagram: 1-10 photos/videos required, 2,200-character caption; Threads: 500 characters; TikTok: a video or photos; YouTube Shorts: one vertical video up to 3 minutes; Pinterest: photos or a video, 800-character description). Changes data. Needs `posts:write` (or a Read & write API key). **When to use it:** To schedule (`scheduledAt`), publish right away (`publishNow: true`), queue (`queue: true`) or save a draft (`draft: true`). Every selected platform's rules are checked first, so an error message tells you exactly what to fix. Give platforms their own text with `platformContent`. Parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `content` | string | no | The post's text. Used on every platform that doesn't have its own text in platformContent. Up to 10,000 characters. | | `platformContent` | object | no | Optional text per platform that replaces `content` there, e.g. {"x": "Short version"}. Keys: `x`, `instagram`, `threads`, `facebook`, `tiktok`, `youtube`, `pinterest`. | | `xThread` | (string or object)[] | no | X only: follow-up posts, in order, that turn the X version into a thread. The first post is the X text (platformContent.x or content). Each is a string, or { "text": "...", "mediaIds": [...] } to give that post its own photos or video. Each is up to 280 characters and costs credits like any X post. Up to 24 items. | | `xThread[].text` | string | yes | Up to 10,000 characters. | | `xThread[].mediaIds` | string (uuid)[] | no | This post's own photos (up to 4) or one video. Up to 4 items. | | `instagramFormat` | string | no | Instagram only: "post" (feed photo, carousel or Reel, the default) or "story" (exactly one photo or video; captions are not shown on Stories). Default `"post"`. | | `tiktok` | object | no | TikTok only: who can see the post, which interactions to allow, and commercial content disclosure. | | `tiktok.privacy` | string | no | Who can see the TikTok post. Left out: public if the account allows it. Ask the user rather than guessing. One of `PUBLIC_TO_EVERYONE`, `MUTUAL_FOLLOW_FRIENDS`, `FOLLOWER_OF_CREATOR`, `SELF_ONLY`. | | `tiktok.allowComments` | boolean | no | Allow comments. Default true. | | `tiktok.allowDuet` | boolean | no | Allow Duet (videos only). Default true. | | `tiktok.allowStitch` | boolean | no | Allow Stitch (videos only). Default true. | | `tiktok.yourBrand` | boolean | no | Commercial content promoting the user's own business. TikTok labels it "Promotional content". | | `tiktok.brandedContent` | boolean | no | Paid partnership promoting a third party. TikTok labels it "Paid partnership"; it can't be private (SELF_ONLY). | | `tiktok.coverTimestampMs` | integer | no | Videos: which moment of the video is the cover, in milliseconds from the start. | | `firstComment` | string | no | Optional first comment, posted under the post right after it goes out, where the platform allows it (e.g. links or hashtags). Empty removes it. Up to 2,200 characters. | | `youtubeTitle` | string | no | YouTube only: the video title (up to 100 characters). Left out: the first line of the text. | | `youtubeThumbnailId` | string (uuid) or null | no | YouTube only: an uploaded photo (under 2 MB) to use as the video's custom thumbnail. null removes it. | | `queue` | boolean | no | Put the post in the next free slot of the posting queue (the accounts' profile's slots, else the workspace's) instead of at scheduledAt. | | `accountIds` | string (uuid)[] | no | Accounts to post to (from list accounts). Up to 200 items. | | `mediaIds` | string (uuid)[] | no | Uploaded media, in display order (from upload media). Up to 10 items. | | `scheduledAt` | string (ISO 8601) | no | When to publish, ISO 8601 with a time zone, e.g. 2026-10-05T09:00:00Z. | | `publishNow` | boolean | no | Publish as soon as possible instead of at scheduledAt. | | `draft` | boolean | no | Save as a draft without scheduling it. | Example arguments: ```json { "content": "We just shipped dark mode. Here's the full story.", "platformContent": { "x": "Dark mode is here 🌙" }, "accountIds": [ "3f2c8a10-6d1e-4b7a-9c55-1e2f3a4b5c6d", "8b7e1d22-0c4f-4e8a-a1b2-9d8c7b6a5f40" ], "mediaIds": [ "5b9d1c2e-3f4a-4b5c-9d6e-7f8a9b0c1d2e" ], "scheduledAt": "2026-10-12T09:00:00Z" } ``` Example result (the text your agent receives): ```text Created post 0d3c4b5a-6978-4e1f-a2b3-c4d5e6f7a8b9. Scheduled for 2026-10-12T09:00:00.000Z. { "id": "0d3c4b5a-6978-4e1f-a2b3-c4d5e6f7a8b9", "status": "scheduled", "content": "We just shipped dark mode. Here's the full story.", "platformContent": { "x": "Dark mode is here 🌙" }, "xThread": [], "xThreadMedia": [], "instagramFormat": "post", "tiktok": {}, "firstComment": null, "youtubeTitle": null, "youtubeThumbnail": null, "queued": false, "scheduledAt": "2026-10-12T09:00:00.000Z", "publishedAt": null, "createdAt": "2026-10-06T14:21:07.512Z", "media": [ { "id": "5b9d1c2e-3f4a-4b5c-9d6e-7f8a9b0c1d2e", "kind": "image", "url": "https://postninja.app/media/5b9d1c2e-3f4a-4b5c-9d6e-7f8a9b0c1d2e.jpg", "previewUrl": null } ], "targets": [ { "accountId": "3f2c8a10-6d1e-4b7a-9c55-1e2f3a4b5c6d", "platform": "x", "username": "acme", "status": "pending", "url": null, "error": null }, { "accountId": "8b7e1d22-0c4f-4e8a-a1b2-9d8c7b6a5f40", "platform": "instagram", "username": "acme.studio", "status": "pending", "url": null, "error": null } ] } ``` > **Note:** When approvals are on and the person who signed in is a member, the summary says the post was sent for approval and `status` is `awaiting_approval`. > **Note:** X posts use credits; if the workspace can't afford them the call fails with a message saying how many are needed. ## update_post **Change a post.** Changes a draft, scheduled or waiting-for-approval post. Only the fields you pass change; the rest stay as they are. When an owner or admin (or an API key) schedules a post that was waiting for approval, that approves it. Changes data. Needs `posts:write` (or a Read & write API key). **When to use it:** To change the text, accounts, media, time or options of a draft, scheduled or waiting post. Only the fields you pass change. Send `scheduledAt` to move a post, `draft: true` to unschedule it, or `queue: true` to put it in the queue. Posts that already went out can't be changed. Parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `content` | string | no | The post's text. Used on every platform that doesn't have its own text in platformContent. Up to 10,000 characters. | | `platformContent` | object | no | Optional text per platform that replaces `content` there, e.g. {"x": "Short version"}. Keys: `x`, `instagram`, `threads`, `facebook`, `tiktok`, `youtube`, `pinterest`. | | `xThread` | (string or object)[] | no | X only: follow-up posts, in order, that turn the X version into a thread. The first post is the X text (platformContent.x or content). Each is a string, or { "text": "...", "mediaIds": [...] } to give that post its own photos or video. Each is up to 280 characters and costs credits like any X post. Up to 24 items. | | `xThread[].text` | string | yes | Up to 10,000 characters. | | `xThread[].mediaIds` | string (uuid)[] | no | This post's own photos (up to 4) or one video. Up to 4 items. | | `instagramFormat` | string | no | Instagram only: "post" (feed photo, carousel or Reel, the default) or "story" (exactly one photo or video; captions are not shown on Stories). | | `tiktok` | object | no | TikTok only: who can see the post, which interactions to allow, and commercial content disclosure. | | `tiktok.privacy` | string | no | Who can see the TikTok post. Left out: public if the account allows it. Ask the user rather than guessing. One of `PUBLIC_TO_EVERYONE`, `MUTUAL_FOLLOW_FRIENDS`, `FOLLOWER_OF_CREATOR`, `SELF_ONLY`. | | `tiktok.allowComments` | boolean | no | Allow comments. Default true. | | `tiktok.allowDuet` | boolean | no | Allow Duet (videos only). Default true. | | `tiktok.allowStitch` | boolean | no | Allow Stitch (videos only). Default true. | | `tiktok.yourBrand` | boolean | no | Commercial content promoting the user's own business. TikTok labels it "Promotional content". | | `tiktok.brandedContent` | boolean | no | Paid partnership promoting a third party. TikTok labels it "Paid partnership"; it can't be private (SELF_ONLY). | | `tiktok.coverTimestampMs` | integer | no | Videos: which moment of the video is the cover, in milliseconds from the start. | | `firstComment` | string | no | Optional first comment, posted under the post right after it goes out, where the platform allows it (e.g. links or hashtags). Empty removes it. Up to 2,200 characters. | | `youtubeTitle` | string | no | YouTube only: the video title (up to 100 characters). Left out: the first line of the text. | | `youtubeThumbnailId` | string (uuid) or null | no | YouTube only: an uploaded photo (under 2 MB) to use as the video's custom thumbnail. null removes it. | | `queue` | boolean | no | Put the post in the next free slot of the posting queue (the accounts' profile's slots, else the workspace's) instead of at scheduledAt. | | `accountIds` | string (uuid)[] | no | Accounts to post to (from list accounts). Up to 200 items. | | `mediaIds` | string (uuid)[] | no | Uploaded media, in display order (from upload media). Up to 10 items. | | `scheduledAt` | string (ISO 8601) | no | When to publish, ISO 8601 with a time zone, e.g. 2026-10-05T09:00:00Z. | | `publishNow` | boolean | no | Publish as soon as possible instead of at scheduledAt. | | `draft` | boolean | no | Save as a draft without scheduling it. | | `postId` | string (uuid) | yes | The post's id. | Example arguments: ```json { "postId": "0d3c4b5a-6978-4e1f-a2b3-c4d5e6f7a8b9", "scheduledAt": "2026-10-13T09:00:00Z", "platformContent": { "x": "Dark mode is finally here 🌙" } } ``` Example result (the text your agent receives): ```text Updated post 0d3c4b5a-6978-4e1f-a2b3-c4d5e6f7a8b9. { "id": "0d3c4b5a-6978-4e1f-a2b3-c4d5e6f7a8b9", "status": "scheduled", "content": "We just shipped dark mode. Here's the full story.", "platformContent": { "x": "Dark mode is finally here 🌙" }, "xThread": [], "xThreadMedia": [], "instagramFormat": "post", "tiktok": {}, "firstComment": null, "youtubeTitle": null, "youtubeThumbnail": null, "queued": false, "scheduledAt": "2026-10-13T09:00:00.000Z", "publishedAt": null, "createdAt": "2026-10-06T14:21:07.512Z", "media": [ { "id": "5b9d1c2e-3f4a-4b5c-9d6e-7f8a9b0c1d2e", "kind": "image", "url": "https://postninja.app/media/5b9d1c2e-3f4a-4b5c-9d6e-7f8a9b0c1d2e.jpg", "previewUrl": null } ], "targets": [ { "accountId": "3f2c8a10-6d1e-4b7a-9c55-1e2f3a4b5c6d", "platform": "x", "username": "acme", "status": "pending", "url": null, "error": null }, { "accountId": "8b7e1d22-0c4f-4e8a-a1b2-9d8c7b6a5f40", "platform": "instagram", "username": "acme.studio", "status": "pending", "url": null, "error": null } ] } ``` ## delete_post **Delete a post.** Deletes a post (draft, scheduled, waiting or sent). A post that was already published stays live on the platform; it's only removed from PostNinja. Changes data. Needs `posts:write` (or a Read & write API key). Destructive: it can't be undone. **When to use it:** To cancel a draft or scheduled post. Deleting a post that already went out only removes it from PostNinja; it stays live on the platforms. Credits held for accounts it hadn't reached yet are given back. Parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `postId` | string (uuid) | yes | The post's id. | Example arguments: ```json { "postId": "0d3c4b5a-6978-4e1f-a2b3-c4d5e6f7a8b9" } ``` Example result (the text your agent receives): ```text Deleted post 0d3c4b5a-6978-4e1f-a2b3-c4d5e6f7a8b9. ``` ## approve_post **Approve a post.** Approves a post waiting for approval (status awaiting_approval), so it's scheduled for its time, or goes out now if that time has passed. Only the workspace owner or an admin can approve. Changes data. Needs `posts:write` (or a Read & write API key). **When to use it:** When an owner or admin asks you to review what the team submitted: list posts with `status: awaiting_approval`, show them, and approve the ones they're happy with. A post whose time has passed goes out right away. Parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `postId` | string (uuid) | yes | The post's id. | | `note` | string | no | Optional note for the author. Up to 1,000 characters. | Example arguments: ```json { "postId": "0d3c4b5a-6978-4e1f-a2b3-c4d5e6f7a8b9", "note": "Looks great." } ``` Example result (the text your agent receives): ```text Approved post 0d3c4b5a-6978-4e1f-a2b3-c4d5e6f7a8b9. { "id": "0d3c4b5a-6978-4e1f-a2b3-c4d5e6f7a8b9", "status": "scheduled", "content": "We just shipped dark mode. Here's the full story.", "platformContent": { "x": "Dark mode is here 🌙" }, "xThread": [], "xThreadMedia": [], "instagramFormat": "post", "tiktok": {}, "firstComment": null, "youtubeTitle": null, "youtubeThumbnail": null, "queued": false, "scheduledAt": "2026-10-12T09:00:00.000Z", "publishedAt": null, "createdAt": "2026-10-06T14:21:07.512Z", "media": [ { "id": "5b9d1c2e-3f4a-4b5c-9d6e-7f8a9b0c1d2e", "kind": "image", "url": "https://postninja.app/media/5b9d1c2e-3f4a-4b5c-9d6e-7f8a9b0c1d2e.jpg", "previewUrl": null } ], "targets": [ { "accountId": "3f2c8a10-6d1e-4b7a-9c55-1e2f3a4b5c6d", "platform": "x", "username": "acme", "status": "pending", "url": null, "error": null }, { "accountId": "8b7e1d22-0c4f-4e8a-a1b2-9d8c7b6a5f40", "platform": "instagram", "username": "acme.studio", "status": "pending", "url": null, "error": null } ], "approvalHistory": [ { "action": "submitted", "by": "Sam Lee", "note": null, "at": "2026-10-06T10:02:11.000Z" }, { "action": "approved", "by": "Alex Kim", "note": "Looks great.", "at": "2026-10-06T12:01:09.000Z" } ] } ``` ## request_changes **Ask for changes to a post.** Sends a post waiting for approval back to its author's drafts with a note saying what to change; the author gets it by email. Only the workspace owner or an admin can do this. Changes data. Needs `posts:write` (or a Read & write API key). **When to use it:** To send a waiting post back to its author's drafts. The note is required and emailed to the author, so say exactly what to change. Parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `postId` | string (uuid) | yes | The post's id. | | `note` | string | yes | What needs changing. The author gets it by email. Up to 1,000 characters. | Example arguments: ```json { "postId": "0d3c4b5a-6978-4e1f-a2b3-c4d5e6f7a8b9", "note": "Use the photo from Saturday and mention free delivery." } ``` Example result (the text your agent receives): ```text Sent post 0d3c4b5a-6978-4e1f-a2b3-c4d5e6f7a8b9 back to its author. { "id": "0d3c4b5a-6978-4e1f-a2b3-c4d5e6f7a8b9", "status": "draft", "content": "We just shipped dark mode. Here's the full story.", "platformContent": { "x": "Dark mode is here 🌙" }, "xThread": [], "xThreadMedia": [], "instagramFormat": "post", "tiktok": {}, "firstComment": null, "youtubeTitle": null, "youtubeThumbnail": null, "queued": false, "scheduledAt": "2026-10-12T09:00:00.000Z", "publishedAt": null, "createdAt": "2026-10-06T14:21:07.512Z", "media": [ { "id": "5b9d1c2e-3f4a-4b5c-9d6e-7f8a9b0c1d2e", "kind": "image", "url": "https://postninja.app/media/5b9d1c2e-3f4a-4b5c-9d6e-7f8a9b0c1d2e.jpg", "previewUrl": null } ], "targets": [ { "accountId": "3f2c8a10-6d1e-4b7a-9c55-1e2f3a4b5c6d", "platform": "x", "username": "acme", "status": "pending", "url": null, "error": null }, { "accountId": "8b7e1d22-0c4f-4e8a-a1b2-9d8c7b6a5f40", "platform": "instagram", "username": "acme.studio", "status": "pending", "url": null, "error": null } ], "approvalHistory": [ { "action": "submitted", "by": "Sam Lee", "note": null, "at": "2026-10-06T10:02:11.000Z" }, { "action": "changes_requested", "by": "Alex Kim", "note": "Use the photo from Saturday.", "at": "2026-10-06T11:40:52.000Z" } ] } ``` ## get_queue **Get the posting queue.** The posting queue's weekly time slots (weekday 0 = Monday … 6 = Sunday, 24-hour HH:MM in the queue's time zone): the workspace's (profileId null) and each profile's own. create_post with queue: true takes the next free slot of the accounts' profile, else the workspace's. Read-only. Needs `posts:read`. **When to use it:** Before queueing posts, to see the weekly slots and their time zone. `profileId: null` slots are the workspace's; a profile with its own slots uses those instead. Parameters: none. Example arguments: ```json {} ``` Example result (the text your agent receives): ```text Posting queue: { "timeZone": "Europe/London", "slots": [ { "weekday": 0, "time": "09:00", "profileId": null }, { "weekday": 2, "time": "09:00", "profileId": null }, { "weekday": 4, "time": "12:30", "profileId": null }, { "weekday": 1, "time": "18:00", "profileId": "5a6b7c8d-9e0f-4a1b-8c2d-3e4f5a6b7c8d" } ] } ``` ## update_queue **Change the posting queue.** Replaces one set of weekly queue slots: a profile's (profileId) or the workspace's (profileId null). Send the complete new list; an empty list removes a profile's own slots so it uses the workspace's. Only the workspace owner or an admin can do this. Posts already queued keep their times. Changes data. Needs `posts:write` (or a Read & write API key). **When to use it:** To set up or change posting times. Send the complete list for one set (it replaces the old one). An empty list removes a profile's own slots, so it falls back to the workspace's. Posts already queued keep their times. Parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `profileId` | string (uuid) or null | no | Whose slots to replace: a profile's id (from list_profiles), or null for the workspace's own slots. Default `null`. | | `slots` | object[] | yes | The complete new list of weekly slots. An empty list removes them (a profile then uses the workspace's slots). Up to 100 items. | | `slots[].weekday` | integer | yes | 0 = Monday … 6 = Sunday. 0 to 6. | | `slots[].time` | string | yes | 24-hour HH:MM in the queue's time zone. | Example arguments: ```json { "profileId": null, "slots": [ { "weekday": 0, "time": "09:00" }, { "weekday": 2, "time": "09:00" }, { "weekday": 4, "time": "12:30" } ] } ``` Example result (the text your agent receives): ```text Updated the posting queue. { "timeZone": "Europe/London", "slots": [ { "weekday": 0, "time": "09:00", "profileId": null }, { "weekday": 2, "time": "09:00", "profileId": null }, { "weekday": 4, "time": "12:30", "profileId": null } ] } ``` ## get_analytics **Get analytics.** Published posts' stats and follower growth for a date range (UTC days, default the last 30), compared with the period before: headline numbers, one row per account and the top posts. Filter by profile, account or platform. Read-only. Needs `posts:read`. **When to use it:** For reports: how posts did over a period, which accounts grew, and the top posts. Numbers come from stats already synced, so this costs nothing and is fast. Compare `kpis` with `kpis.previous` (the same number of days before). Parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `from` | string | no | First day, YYYY-MM-DD (send with to). | | `to` | string | no | Last day, YYYY-MM-DD (send with from). | | `profileId` | string (uuid) | no | Only this profile's accounts (see list_profiles). | | `accountId` | string (uuid) | no | Only this account (see list_accounts). | | `platform` | string | no | Only this platform, e.g. instagram or youtube. | Example arguments: ```json { "from": "2026-09-07", "to": "2026-10-06" } ``` Example result (the text your agent receives): ```text Analytics: { "range": { "from": "2026-09-07", "to": "2026-10-06", "days": 30, "timeZone": "UTC" }, "previousRange": { "from": "2026-08-08", "to": "2026-09-06" }, "kpis": { "postsPublished": 12, "postsWithStats": 11, "impressions": 48210, "engagements": 2391, "likes": 1874, "reposts": 143, "replies": 262, "quotes": 0, "bookmarks": 112, "engagementRate": 0.0496, "followers": 18422, "followerGrowth": 611, "bestPlatform": { "platform": "instagram", "engagementRate": 0.0712, "posts": 6 }, "previous": { "postsPublished": 12, "postsWithStats": 11, "impressions": 39120, "engagements": 1830, "likes": 1874, "reposts": 143, "replies": 262, "quotes": 0, "bookmarks": 112, "engagementRate": 0.0468, "followerGrowth": 402 } }, "accounts": [ { "id": "8b7e1d22-0c4f-4e8a-a1b2-9d8c7b6a5f40", "platform": "instagram", "username": "acme.studio", "displayName": "Acme Studio", "profileId": "5a6b7c8d-9e0f-4a1b-8c2d-3e4f5a6b7c8d", "followers": 12904, "followersCountedOn": "2026-10-06", "followerGrowth": 488, "postsPublished": 12, "postsWithStats": 11, "impressions": 48210, "engagements": 2391, "likes": 1874, "reposts": 143, "replies": 262, "quotes": 0, "bookmarks": 112, "engagementRate": 0.0496, "averageImpressions": 4383, "topPostId": "0d3c4b5a-6978-4e1f-a2b3-c4d5e6f7a8b9" } ], "topPosts": [ { "id": "0d3c4b5a-6978-4e1f-a2b3-c4d5e6f7a8b9", "text": "Back in stock: our speckled stoneware mugs 🙌", "platforms": [ "instagram", "threads" ], "type": "photo", "publishedAt": "2026-09-18T09:00:04.000Z", "impressions": 9120, "engagements": 811, "engagementRate": 0.0889, "url": "https://www.instagram.com/p/DA1b2C3d4E5/" } ], "notes": { "…": "Short caveats per platform, e.g. which platforms have no post stats." } } ``` > **Note:** X posts have no stats in PostNinja, so they count as published without numbers. Instagram, Threads, Facebook, TikTok, YouTube and Pinterest posts have stats. ## list_comments **List comments.** Comment conversations on the connected accounts' posts (Instagram, Facebook Pages, Threads, YouTube), latest first. Pass commentId to get one conversation with every message. Reply with reply_to_comment. Read-only. Needs `posts:read`. **When to use it:** To answer comments: list conversations with `filter: needs_reply`, then open one with `commentId` to read every message and the post it's on. Reading here doesn't mark comments as read in the app. Parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `filter` | string | no | needs_reply: the latest message isn't ours. One of `all`, `unread`, `needs_reply`, `replied`, `hidden`. Default `"all"`. | | `platform` | string | no | One of `instagram`, `facebook`, `threads`, `youtube`. | | `accountId` | string (uuid) | no | | | `commentId` | string (uuid) | no | Get this conversation in full instead of the list. | | `limit` | integer | no | 1 to 100. Default `30`. | Example arguments: ```json { "filter": "needs_reply", "platform": "instagram", "limit": 10 } ``` Example result (the text your agent receives): ```text Comment conversations: { "threads": [ { "id": "9e2a4f5b-6c7d-4e8f-a091-0c1d2e3f4a5b", "accountId": "8b7e1d22-0c4f-4e8a-a1b2-9d8c7b6a5f40", "provider": "instagram", "authorName": "Jamie", "authorHandle": "jamie.makes", "authorAvatarUrl": null, "text": "Do these ship to Canada?", "postedAt": "2026-10-06T08:14:22.000Z", "count": 1, "unread": 1, "needsReply": true, "replied": false, "hidden": false, "postId": "0d3c4b5a-6978-4e1f-a2b3-c4d5e6f7a8b9" } ], "hasMore": false } ``` Example result with commentId: one conversation, every message: ```text Conversation: { "id": "9e2a4f5b-6c7d-4e8f-a091-0c1d2e3f4a5b", "accountId": "8b7e1d22-0c4f-4e8a-a1b2-9d8c7b6a5f40", "provider": "instagram", "post": { "text": "Back in stock: our speckled stoneware mugs 🙌", "thumbnail": null, "permalink": "https://www.instagram.com/p/DA1b2C3d4E5/", "postedAt": "2026-10-05T09:00:04.000Z", "postId": "0d3c4b5a-6978-4e1f-a2b3-c4d5e6f7a8b9" }, "messages": [ { "id": "9e2a4f5b-6c7d-4e8f-a091-0c1d2e3f4a5b", "kind": "comment", "fromUs": false, "authorName": "Jamie", "authorHandle": "jamie.makes", "authorAvatarUrl": null, "text": "Do these ship to Canada?", "postedAt": "2026-10-06T08:14:22.000Z", "hidden": false, "permalink": null, "sentByName": null, "readAt": null } ], "canWrite": true, "writeNote": null, "hideLabel": "Hide", "maxLength": 2200 } ``` ## reply_to_comment **Reply to a comment.** Replies publicly to a comment as the connected account it was left on. Use a commentId from list_comments. Changes data. Needs `posts:write` (or a Read & write API key). **When to use it:** To reply publicly as the account the comment was left on. Use the conversation's `id` from `list_comments`. Keep replies within the platform's limit (`maxLength` in the conversation). Parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `commentId` | string (uuid) | yes | | | `text` | string | yes | Up to 2,200 characters. | Example arguments: ```json { "commentId": "9e2a4f5b-6c7d-4e8f-a091-0c1d2e3f4a5b", "text": "They do! Shipping to Canada takes 5-7 days." } ``` Example result (the text your agent receives): ```text Reply sent. { "id": "a3b4c5d6-e7f8-4a9b-8c0d-1e2f3a4b5c6d", "kind": "reply", "fromUs": true, "authorName": "Acme Studio", "authorHandle": "acme.studio", "authorAvatarUrl": null, "text": "They do! Shipping to Canada takes 5-7 days.", "postedAt": "2026-10-06T09:02:40.000Z", "hidden": false, "permalink": null, "sentByName": null, "readAt": "2026-10-06T09:02:40.000Z" } ``` ## list_messages **List direct messages.** Direct-message conversations on the connected Instagram and Facebook Page accounts, latest first, with each one's reply window. Pass conversationId to get one conversation with its messages. Reply with reply_to_message while window.state isn't "closed". Read-only. Needs `posts:read`. **When to use it:** To answer direct messages on Instagram and Facebook Pages: list conversations with `filter: needs_reply`, then open one with `conversationId` to read its messages. Check `window`: replies are only possible while `window.state` is `open` (24 hours after the person's last message). Reading here doesn't mark messages as read in the app. Parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `filter` | string | no | needs_reply: they wrote last. One of `all`, `unread`, `needs_reply`. Default `"all"`. | | `platform` | string | no | One of `instagram`, `facebook`. | | `accountId` | string (uuid) | no | | | `conversationId` | string (uuid) | no | Get this conversation in full instead of the list. | | `limit` | integer | no | 1 to 100. Default `30`. | Example arguments: ```json { "filter": "needs_reply", "limit": 10 } ``` Example result (the text your agent receives): ```text Message conversations: { "conversations": [ { "id": "4e5f6a7b-8c9d-4e0f-a1b2-c3d4e5f6a7b8", "accountId": "8b7e1d22-0c4f-4e8a-a1b2-9d8c7b6a5f40", "provider": "instagram", "participantName": "Jamie Lee", "participantUsername": "jamie.makes", "participantAvatarUrl": null, "lastText": "Hi! Is the large mug back in stock?", "lastFromUs": false, "lastAttachments": 0, "lastMessageAt": "2026-10-06T08:14:22.000Z", "unread": 1, "needsReply": true, "window": { "state": "open", "closesAt": "2026-10-07T08:14:22.000Z" } } ], "hasMore": false } ``` Example result with conversationId: one conversation with its messages: ```text Conversation: { "id": "4e5f6a7b-8c9d-4e0f-a1b2-c3d4e5f6a7b8", "accountId": "8b7e1d22-0c4f-4e8a-a1b2-9d8c7b6a5f40", "provider": "instagram", "participant": { "id": "1784001234567890", "name": "Jamie Lee", "username": "jamie.makes", "avatarUrl": null }, "permalink": "https://ig.me/m/jamie.makes", "messages": [ { "id": "6f7a8b9c-0d1e-4f2a-8b3c-4d5e6f7a8b9c", "fromUs": false, "text": "Hi! Is the large mug back in stock?", "attachments": [ { "type": "image", "url": "https://lookaside.fbsbx.com/ig_messaging_cdn/?asset_id=…", "previewUrl": null, "name": null } ], "sentAt": "2026-10-06T08:14:22.000Z", "readAt": null, "sentByName": null } ], "window": { "state": "open", "closesAt": "2026-10-07T08:14:22.000Z" }, "canReply": true, "replyNote": null, "maxLength": 1000, "lengthUnit": "bytes" } ``` > **Note:** Attachment links point at Instagram's or Facebook's servers and stop working after a while. ## reply_to_message **Reply to a direct message.** Sends a private direct message in a conversation, as the connected account. Instagram and Messenger only allow it within 24 hours of the person's last message; after that it's refused. Instagram messages can be up to 1,000 bytes, Facebook up to 2,000 characters. Changes data. Needs `posts:write` (or a Read & write API key). **When to use it:** To answer a direct message privately as the account it was sent to. Use the conversation's `id` from `list_messages`. Only while the reply window is open; once it has closed the tool returns an error and the person has to write again first. Parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `conversationId` | string (uuid) | yes | | | `text` | string | yes | Up to 2,000 characters. | Example arguments: ```json { "conversationId": "4e5f6a7b-8c9d-4e0f-a1b2-c3d4e5f6a7b8", "text": "It is! It's back on the shop today." } ``` Example result (the text your agent receives): ```text Message sent. { "id": "2b3c4d5e-6f7a-4b8c-9d0e-1f2a3b4c5d6e", "fromUs": true, "text": "It is! It's back on the shop today.", "attachments": [], "sentAt": "2026-10-06T09:02:40.000Z", "readAt": "2026-10-06T09:02:40.000Z", "sentByName": null } ``` ## upload_image **Upload an image.** Uploads a JPEG, PNG or WebP image (base64, up to about 3 MB) and returns a media id to use in create_post. For videos and larger files, use the REST API: POST /api/v1/media. Changes data. Needs `posts:write` (or a Read & write API key). **When to use it:** When the user gives the agent an image to post. Send it base64-encoded (no `data:` prefix), then pass the returned id in `mediaIds`. For videos or images over about 3 MB, upload with the REST API (`POST /api/v1/media`) and give the agent the id. Parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `data` | string | yes | The image file, base64-encoded (no data: prefix). | | `mimeType` | string | yes | One of `image/jpeg`, `image/png`, `image/webp`. | Example arguments: ```json { "data": "/9j/4AAQSkZJRgABAQAAAQABAAD…", "mimeType": "image/jpeg" } ``` Example result (the text your agent receives): ```text Uploaded. Use mediaIds: ["5b9d1c2e-3f4a-4b5c-9d6e-7f8a9b0c1d2e"] in create_post. { "id": "5b9d1c2e-3f4a-4b5c-9d6e-7f8a9b0c1d2e", "kind": "image", "width": 1080, "height": 1350, "url": "https://postninja.app/media/5b9d1c2e-3f4a-4b5c-9d6e-7f8a9b0c1d2e.jpg" } ``` --- # Recipes for AI agents Source: https://postninja.app/docs/mcp/recipes Copy-paste prompts for the things people do most with PostNinja and an AI agent, with the tools each one uses. Replace the [brackets] with your details. > **Tip:** Agents do better when they show you the plan before scheduling. Ending a prompt with "Show me first, then schedule it" costs one message and saves fixing posts later. ## Plan and schedule a week of posts Turn a few notes into a full week across every account, with each platform's own version. Prompt: ```text Here's what we have going on next week: [notes]. Plan one post a day, Monday to Friday at 9am my time, for all my accounts. Use our brand voice. Write a short version for X (under 280 characters), add 3-5 hashtags on Instagram, and keep Threads conversational. Show me the plan first, then schedule it. ``` **Tools:** `list_accounts`, `get_workspace`, `create_post` - Instagram, TikTok, YouTube and Pinterest need a photo or video. Text-only posts can go to X, Threads and Facebook; ask the agent to leave the others out or to wait for images. - Times are sent as ISO 8601 with a time zone, so tell the agent your time zone if it doesn't know it. ## Fill the posting queue Write a batch of posts and let the queue spread them over your weekly time slots, instead of picking times. Prompt: ```text Check my posting queue. If it has no slots, set Monday, Wednesday and Friday at 09:00 and Tuesday and Thursday at 17:30. Then write 8 evergreen tips about [topic] and add each one to the queue for my X and Threads accounts. ``` **Tools:** `get_queue`, `update_queue`, `list_accounts`, `create_post` (queue: true) - Each queued post takes the next free slot of its accounts' profile, or the workspace's slots. Only owners, admins and API keys can change the slots. - Changing a queued post keeps it in the queue (it can move up to an earlier free slot) unless you give it a time. ## Repurpose one video into Reels, TikTok and Shorts Post one vertical video everywhere video works, with a caption written for each platform. Prompt: ```text I uploaded a video (media id [id]). Post it tomorrow at 6pm to Instagram as a Reel, to TikTok and to YouTube Shorts. Write a punchy Instagram caption with hashtags, a short TikTok caption, and a YouTube title under 100 characters with a 2-line description. For TikTok, ask me who can see it and whether it promotes my own brand. ``` **Tools:** `list_accounts`, `create_post` (platformContent, youtubeTitle, tiktok) Upload the video first: ```bash curl "https://postninja.app/api/v1/media?width=1080&height=1920&durationMs=42000" \ -H "Authorization: Bearer $POSTNINJA_API_KEY" \ -H "Content-Type: video/mp4" \ --data-binary @reel.mp4 ``` - Upload the video with `POST /api/v1/media` (the MCP `upload_image` tool takes images only) and pass `?width=&height=&durationMs=` so length and shape are checked up front. - YouTube Shorts need exactly one vertical or square video up to 3 minutes; TikTok videos run 3 seconds to 10 minutes; a single Instagram video is published as a Reel (3 seconds to 15 minutes). - Add `youtubeThumbnailId` (an uploaded photo under 2 MB) for a custom thumbnail, and `tiktok.coverTimestampMs` to choose the TikTok cover frame. ## Write an X thread with images Turn an article or announcement into a thread, with photos on the posts that need them. Prompt: ```text Turn this blog post into an X thread of 5-7 posts, each under 280 characters, and schedule it for Thursday at noon on my X account. Attach the chart I uploaded (media id [id]) to the third post. [paste the article] ``` **Tools:** `upload_image`, `create_post` (xThread) - The first post is the X text (`platformContent.x` or `content`); `xThread` holds the follow-ups. Give a follow-up its own photos with `{ "text": "…", "mediaIds": ["…"] }`. - Every post in a thread costs credits like a single X post (more if it has a link). See [Plans, credits and limits](https://postninja.app/docs/limits). ## Add first comments Keep links and hashtags out of the caption by posting them as the first comment. Prompt: ```text Schedule this Instagram and Facebook post for Saturday at 10am, and put the shop link and our hashtags in the first comment instead of the caption: [text] ``` **Tools:** `create_post` (firstComment) - First comments are posted on X (as a reply, billed like a post), Instagram (not Stories), Facebook, Threads and YouTube. Other platforms skip it. ## Instagram Story tonight Post a single photo or short video as a Story. Prompt: ```text Post this photo to my Instagram as a Story tonight at 7pm. ``` **Tools:** `upload_image`, `create_post` (instagramFormat: story) - Stories take exactly one photo or a 3-60 second video. Instagram doesn't show captions on Stories. ## Bulk-create posts from a list Schedule many posts at once from a spreadsheet, a list of products or a content calendar. Prompt: ```text Here's a list of 20 products with names, prices and links: [list]. For each one, write a short post and schedule them one a day at 11am starting Monday on X, Threads and Facebook. Skip weekends. Save any you're unsure about as drafts instead. ``` **Tools:** `list_accounts`, `create_post` (one call per post) - There's no batch endpoint: the agent calls `create_post` once per post. Each key or app can make 120 calls a minute. - Prefer a file? The **Import posts** page in PostNinja (`/posts/import`) creates posts from a CSV. ## Write in a profile's brand voice with templates Use a ready-made template and the right brand voice for each client or brand. Prompt: ```text Find a template for a restock announcement. Fill it in for Fern & Clay's speckled mugs (40 made, link fernandclay.com/mugs) in that profile's brand voice, and schedule it for Friday at 9am on Fern & Clay's accounts. ``` **Tools:** `list_templates`, `list_profiles`, `get_workspace`, `create_post` - A profile's brand voice wins for its accounts; otherwise use the workspace's (`get_workspace`). Drop lines whose optional blanks you leave empty. ## Agency: work client by client Keep every client's posts, voice and queue separate. Prompt: ```text List my profiles. For each client, tell me what's scheduled next week and flag any day with nothing planned. Then draft two posts for each empty day in that client's brand voice, and save them as drafts for review. ``` **Tools:** `list_profiles`, `list_posts` (profileId), `create_post` (draft: true) - Give each client's automation an API key limited to that profile: it only sees and posts to that client's accounts. - Each profile can have its own queue slots (`update_queue` with its `profileId`). ## Review and approve what the team submitted Go through posts waiting for approval, approve the good ones and send the rest back with notes. Prompt: ```text Show me every post waiting for approval with its text, accounts and time. I'll tell you which to approve. For the others, ask for changes with my notes. ``` **Tools:** `list_posts` (status: awaiting_approval), `get_post`, `approve_post`, `request_changes` - Only owners and admins can approve or ask for changes (API keys count as admins). The author is emailed the note. - Approving a post whose time has passed sends it right away. ## Weekly analytics report A short report of what worked, what didn't, and what to post next. Prompt: ```text Give me a report for the last 7 days compared with the week before: impressions, engagements, engagement rate and follower growth per account, the top 3 posts and why they might have worked, and 3 ideas for next week based on them. ``` **Tools:** `get_analytics`, `get_post` - Dates are UTC days (`YYYY-MM-DD`); without them you get the last 30 days. Up to 366 days at a time. - X posts have no stats in PostNinja. Ask the agent to note that rather than read zeros as a bad week. - For the best times to post, look at the **Best time** card on the dashboard; there's no API for it yet. The `publishedAt` of top posts is a useful hint. ## Reply to comments Clear the comments that need an answer, in your voice. Prompt: ```text Show me Instagram and Facebook comments that need a reply. Draft an answer for each in our brand voice. Send the ones I approve. ``` **Tools:** `list_comments` (filter: needs_reply), `list_comments` (commentId), `reply_to_comment` - The inbox covers Instagram, Facebook Pages, Threads and YouTube. Replies are public and sent as the account the comment was left on. - Have the agent show you replies before sending: replying can't be undone from here. ## Answer direct messages Answer the Instagram and Facebook messages that are waiting, before the reply window closes. Prompt: ```text Show me Instagram and Facebook messages that need a reply, soonest-closing reply window first. Draft an answer for each in our brand voice and send the ones I approve. ``` **Tools:** `list_messages` (filter: needs_reply), `list_messages` (conversationId), `get_workspace`, `reply_to_message` - Instagram and Messenger only let businesses answer within 24 hours of the person's last message. `window.closesAt` says when; after that `reply_to_message` is refused until they write again. - Messages are private and sent as the account they were sent to. Instagram messages can be up to 1,000 bytes (emoji count as several). - For automation, subscribe a webhook to `message.received` and answer from your own code with `POST /api/v1/inbox/messages/:id/reply`. ## Check failed posts and fix them Find out what didn't go out and why, then get it out. Prompt: ```text Which posts failed or only partly went out this week? For each, tell me the account and the error in plain words, and suggest a fix. If the fix is in the post itself, create a corrected post for the accounts that failed. ``` **Tools:** `list_posts` (status: failed / partial), `get_post`, `list_accounts`, `create_post` - Each target has its own `error`. `needs_reconnect` on an account means it has to be reconnected in the app first. - There's no retry tool yet: either create a corrected post for the failed accounts, or open the post in PostNinja and click **Retry**. Original files are kept for 30 days after a failure. ## Set up webhooks for automation Get a signed request in n8n, Zapier, Make or your own server whenever something happens. Prompt: ```text I want a Slack message whenever a post fails or an account needs reconnecting. Write an n8n workflow with a Webhook trigger that checks the PostNinja-Signature header, then posts to Slack. ``` **Tools:** REST API: POST /api/v1/webhooks Create the endpoint: ```bash curl -X POST "https://postninja.app/api/v1/webhooks" \ -H "Authorization: Bearer $POSTNINJA_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "url": "https://n8n.example.com/webhook/postninja", "description": "Slack alerts", "events": [ "post.failed", "account.needs_reconnect" ] }' ``` - Webhooks are managed with the REST API or under **Settings → API → Webhooks**, not over MCP. The signing secret is shown once. - See [Webhooks](https://postninja.app/docs/webhooks) for events, the payload and signature checks. ## Announce releases from your repo From a coding agent (Claude Code, Cursor, Codex), post about what you just shipped. Prompt: ```text Read CHANGELOG.md and schedule a launch post for tomorrow at 9am on X and Threads, with a shorter X version. Save a longer Facebook version as a draft. ``` **Tools:** `list_accounts`, `create_post` --- # REST API reference Source: https://postninja.app/docs/api Schedule posts, upload media, handle approvals, read analytics and answer comments and direct messages from your own code or automation tools. The MCP server uses the same operations, so both behave the same way. ## Base URL and format - Base URL: `https://postninja.app/api/v1` - Requests and responses are JSON (media uploads send the raw file). Successful responses wrap results in `{ "data": … }`. - Times are ISO 8601 with a time zone, like `2026-10-12T09:00:00Z` or `2026-10-12T10:00:00+01:00`. Responses use UTC. - Ids are UUIDs. Accounts, profiles, media, comments and message conversations all have their own. ## Authentication Create a key under **Settings → API** (owners and admins can) and send it as a bearer token. Keys belong to a workspace and act as an admin of it. Only a hash is stored, so copy the key when it's shown. Every request: ```http Authorization: Bearer pn_… ``` | Key option | What it does | | --- | --- | | Read & write | Everything an admin can do through the API, including approving posts and managing webhooks. | | Read only | `GET` requests only. Anything else returns `403 forbidden`. | | Only some profiles (Pro, Agency) | Sees and posts to those profiles' accounts only, changes only their queues, and can't manage webhooks. | ## Rate limits Each key can make 120 requests a minute, shared between the REST API and MCP. Over the limit you get `429 rate_limited` with a `Retry-After` header in seconds. Platforms have their own limits on how often an account can post; PostNinja spaces and retries those for you. ## Errors Errors use HTTP status codes and one body format. The message is written to be shown to a person. 400 Bad Request: ```json { "error": { "code": "invalid", "message": "X: posts can be up to 280 characters. This one is 312." } } ``` | Status | Code | When | | --- | --- | --- | | 400 | `invalid` | Bad input, a platform rule, a time in the past, not enough credits, no plan. | | 401 | `unauthorized` | Missing, unknown or revoked API key. | | 403 | `forbidden` | Read-only key making a change, a member approving, a limited key outside its profiles. | | 404 | `not_found` | No such post, comment, conversation, profile or endpoint in this workspace. | | 409 | `conflict` | Changing a post that already went out, deleting one that's being sent. | | 413 | `too_large` | Media file over the size limit. | | 415 | `invalid` | Media file type isn't supported. | | 429 | `rate_limited` | Too many requests; see `Retry-After`. | | 502 | `platform` | The platform refused a comment reply or direct message. | | 500 | `server_error` | Something broke on our side. Try again. | ## Accounts ### GET /api/v1/accounts The connected social accounts you can post to, with their ids. `status` is `active` or `needs_reconnect` (reconnect it in the app before posting). `profileId` is the profile the account belongs to, or null. Request: ```bash curl "https://postninja.app/api/v1/accounts" \ -H "Authorization: Bearer $POSTNINJA_API_KEY" ``` 200 OK: ```json { "data": [ { "id": "3f2c8a10-6d1e-4b7a-9c55-1e2f3a4b5c6d", "platform": "x", "username": "acme", "displayName": "Acme", "status": "active", "profileId": null }, { "id": "8b7e1d22-0c4f-4e8a-a1b2-9d8c7b6a5f40", "platform": "instagram", "username": "acme.studio", "displayName": "Acme Studio", "status": "active", "profileId": "5a6b7c8d-9e0f-4a1b-8c2d-3e4f5a6b7c8d" }, { "id": "c41d9e7a-2b3c-4d5e-8f60-718293a4b5c6", "platform": "tiktok", "username": "acmehq", "displayName": "Acme", "status": "needs_reconnect", "profileId": "5a6b7c8d-9e0f-4a1b-8c2d-3e4f5a6b7c8d" } ] } ``` ## Posts | Post status | Meaning | | --- | --- | | `draft` | Saved, not scheduled. | | `awaiting_approval` | Waiting for an owner or admin to approve it (approvals on, created by a member). | | `scheduled` | Will go out at `scheduledAt`. | | `publishing` | Going out now, or waiting to retry after a platform's rate limit. | | `published` | Went out to every account. | | `partial` | Went out to some accounts; see each target's `error`. | | `failed` | Didn't go out to any account. | Each post has one target per account. A target's `status` is `pending`, `publishing`, `published` (with a link in `url`) or `failed` (with the reason in `error`). ### GET /api/v1/posts Posts in the workspace, newest scheduled time first (drafts: most recently edited first). There's no cursor: narrow the time range with `from` and `to` to page through history. Query parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `from` | string (ISO 8601) | no | Only posts scheduled at or after this time. | | `to` | string (ISO 8601) | no | Only posts scheduled before this time. | | `status` | string | no | awaiting_approval: posts waiting for an owner or admin to approve them. One of `draft`, `awaiting_approval`, `scheduled`, `publishing`, `published`, `partial`, `failed`. | | `profileId` | string (uuid) | no | Only posts going to at least one of this profile's accounts (see list_profiles). | | `limit` | integer | no | 1 to 100. Default `50`. | Request: ```bash curl "https://postninja.app/api/v1/posts?status=scheduled&from=2026-10-12T00:00:00Z&to=2026-10-19T00:00:00Z" \ -H "Authorization: Bearer $POSTNINJA_API_KEY" ``` 200 OK: ```json { "data": [ { "id": "0d3c4b5a-6978-4e1f-a2b3-c4d5e6f7a8b9", "status": "scheduled", "content": "We just shipped dark mode. Here's the full story.", "platformContent": { "x": "Dark mode is here 🌙" }, "xThread": [], "xThreadMedia": [], "instagramFormat": "post", "tiktok": {}, "firstComment": null, "youtubeTitle": null, "youtubeThumbnail": null, "queued": false, "scheduledAt": "2026-10-12T09:00:00.000Z", "publishedAt": null, "createdAt": "2026-10-06T14:21:07.512Z", "media": [ { "id": "5b9d1c2e-3f4a-4b5c-9d6e-7f8a9b0c1d2e", "kind": "image", "url": "https://postninja.app/media/5b9d1c2e-3f4a-4b5c-9d6e-7f8a9b0c1d2e.jpg", "previewUrl": null } ], "targets": [ { "accountId": "3f2c8a10-6d1e-4b7a-9c55-1e2f3a4b5c6d", "platform": "x", "username": "acme", "status": "pending", "url": null, "error": null }, { "accountId": "8b7e1d22-0c4f-4e8a-a1b2-9d8c7b6a5f40", "platform": "instagram", "username": "acme.studio", "status": "pending", "url": null, "error": null } ] } ] } ``` ### POST /api/v1/posts Creates a post. Send exactly one of `scheduledAt`, `queue: true`, `publishNow: true` or `draft: true`. Every selected platform's rules are checked first (see [Platform rules](https://postninja.app/docs/platforms)); the first problem comes back as a `400` with a message you can show to people. Body (JSON): | Name | Type | Required | Description | | --- | --- | --- | --- | | `content` | string | no | The post's text. Used on every platform that doesn't have its own text in platformContent. Up to 10,000 characters. | | `platformContent` | object | no | Optional text per platform that replaces `content` there, e.g. {"x": "Short version"}. Keys: `x`, `instagram`, `threads`, `facebook`, `tiktok`, `youtube`, `pinterest`. | | `xThread` | (string or object)[] | no | X only: follow-up posts, in order, that turn the X version into a thread. The first post is the X text (platformContent.x or content). Each is a string, or { "text": "...", "mediaIds": [...] } to give that post its own photos or video. Each is up to 280 characters and costs credits like any X post. Up to 24 items. | | `xThread[].text` | string | yes | Up to 10,000 characters. | | `xThread[].mediaIds` | string (uuid)[] | no | This post's own photos (up to 4) or one video. Up to 4 items. | | `instagramFormat` | string | no | Instagram only: "post" (feed photo, carousel or Reel, the default) or "story" (exactly one photo or video; captions are not shown on Stories). Default `"post"`. | | `tiktok` | object | no | TikTok only: who can see the post, which interactions to allow, and commercial content disclosure. | | `tiktok.privacy` | string | no | Who can see the TikTok post. Left out: public if the account allows it. Ask the user rather than guessing. One of `PUBLIC_TO_EVERYONE`, `MUTUAL_FOLLOW_FRIENDS`, `FOLLOWER_OF_CREATOR`, `SELF_ONLY`. | | `tiktok.allowComments` | boolean | no | Allow comments. Default true. | | `tiktok.allowDuet` | boolean | no | Allow Duet (videos only). Default true. | | `tiktok.allowStitch` | boolean | no | Allow Stitch (videos only). Default true. | | `tiktok.yourBrand` | boolean | no | Commercial content promoting the user's own business. TikTok labels it "Promotional content". | | `tiktok.brandedContent` | boolean | no | Paid partnership promoting a third party. TikTok labels it "Paid partnership"; it can't be private (SELF_ONLY). | | `tiktok.coverTimestampMs` | integer | no | Videos: which moment of the video is the cover, in milliseconds from the start. | | `firstComment` | string | no | Optional first comment, posted under the post right after it goes out, where the platform allows it (e.g. links or hashtags). Empty removes it. Up to 2,200 characters. | | `youtubeTitle` | string | no | YouTube only: the video title (up to 100 characters). Left out: the first line of the text. | | `youtubeThumbnailId` | string (uuid) or null | no | YouTube only: an uploaded photo (under 2 MB) to use as the video's custom thumbnail. null removes it. | | `queue` | boolean | no | Put the post in the next free slot of the posting queue (the accounts' profile's slots, else the workspace's) instead of at scheduledAt. | | `accountIds` | string (uuid)[] | no | Accounts to post to (from list accounts). Up to 200 items. | | `mediaIds` | string (uuid)[] | no | Uploaded media, in display order (from upload media). Up to 10 items. | | `scheduledAt` | string (ISO 8601) | no | When to publish, ISO 8601 with a time zone, e.g. 2026-10-05T09:00:00Z. | | `publishNow` | boolean | no | Publish as soon as possible instead of at scheduledAt. | | `draft` | boolean | no | Save as a draft without scheduling it. | Request: ```bash curl -X POST "https://postninja.app/api/v1/posts" \ -H "Authorization: Bearer $POSTNINJA_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "content": "We just shipped dark mode. Here is the full story.", "platformContent": { "x": "Dark mode is here 🌙" }, "accountIds": [ "3f2c8a10-6d1e-4b7a-9c55-1e2f3a4b5c6d", "8b7e1d22-0c4f-4e8a-a1b2-9d8c7b6a5f40" ], "mediaIds": [ "5b9d1c2e-3f4a-4b5c-9d6e-7f8a9b0c1d2e" ], "scheduledAt": "2026-10-12T09:00:00Z" }' ``` 201 Created: ```json { "data": { "id": "0d3c4b5a-6978-4e1f-a2b3-c4d5e6f7a8b9", "status": "scheduled", "content": "We just shipped dark mode. Here's the full story.", "platformContent": { "x": "Dark mode is here 🌙" }, "xThread": [], "xThreadMedia": [], "instagramFormat": "post", "tiktok": {}, "firstComment": null, "youtubeTitle": null, "youtubeThumbnail": null, "queued": false, "scheduledAt": "2026-10-12T09:00:00.000Z", "publishedAt": null, "createdAt": "2026-10-06T14:21:07.512Z", "media": [ { "id": "5b9d1c2e-3f4a-4b5c-9d6e-7f8a9b0c1d2e", "kind": "image", "url": "https://postninja.app/media/5b9d1c2e-3f4a-4b5c-9d6e-7f8a9b0c1d2e.jpg", "previewUrl": null } ], "targets": [ { "accountId": "3f2c8a10-6d1e-4b7a-9c55-1e2f3a4b5c6d", "platform": "x", "username": "acme", "status": "pending", "url": null, "error": null }, { "accountId": "8b7e1d22-0c4f-4e8a-a1b2-9d8c7b6a5f40", "platform": "instagram", "username": "acme.studio", "status": "pending", "url": null, "error": null } ] } } ``` > **Note:** API keys act as workspace admins, so posts they create are scheduled straight away even when approvals are on. Apps that sign in act as the person: a member's posts come back as `awaiting_approval`. ### GET /api/v1/posts/:id One post with each account's status, links to the published posts, errors, and its approval history (`submitted`, `approved`, `changes_requested`, with who and any note). Path: | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | string (uuid) | yes | The post's id, in the path. | Request: ```bash curl "https://postninja.app/api/v1/posts/0d3c4b5a-6978-4e1f-a2b3-c4d5e6f7a8b9" \ -H "Authorization: Bearer $POSTNINJA_API_KEY" ``` 200 OK: ```json { "data": { "id": "0d3c4b5a-6978-4e1f-a2b3-c4d5e6f7a8b9", "status": "partial", "content": "We just shipped dark mode. Here's the full story.", "platformContent": { "x": "Dark mode is here 🌙" }, "xThread": [], "xThreadMedia": [], "instagramFormat": "post", "tiktok": {}, "firstComment": null, "youtubeTitle": null, "youtubeThumbnail": null, "queued": false, "scheduledAt": "2026-10-12T09:00:00.000Z", "publishedAt": "2026-10-12T09:00:06.000Z", "createdAt": "2026-10-06T14:21:07.512Z", "media": [ { "id": "5b9d1c2e-3f4a-4b5c-9d6e-7f8a9b0c1d2e", "kind": "image", "url": "https://postninja.app/media/5b9d1c2e-3f4a-4b5c-9d6e-7f8a9b0c1d2e.jpg", "previewUrl": null } ], "targets": [ { "accountId": "3f2c8a10-6d1e-4b7a-9c55-1e2f3a4b5c6d", "platform": "x", "username": "acme", "status": "published", "url": "https://x.com/acme/status/1843210987654321098", "error": null }, { "accountId": "8b7e1d22-0c4f-4e8a-a1b2-9d8c7b6a5f40", "platform": "instagram", "username": "acme.studio", "status": "failed", "url": null, "error": "Instagram: photos must be between 4:5 (portrait) and 1.91:1 (landscape). Crop this one first." } ], "approvalHistory": [] } } ``` ### PATCH /api/v1/posts/:id Changes a draft, scheduled or waiting post. Fields you leave out keep their values. A post that already went out returns `409 conflict`. An owner, admin or API key scheduling a waiting post approves it. Body (JSON): any of the create fields: | Name | Type | Required | Description | | --- | --- | --- | --- | | `content` | string | no | The post's text. Used on every platform that doesn't have its own text in platformContent. Up to 10,000 characters. | | `platformContent` | object | no | Optional text per platform that replaces `content` there, e.g. {"x": "Short version"}. Keys: `x`, `instagram`, `threads`, `facebook`, `tiktok`, `youtube`, `pinterest`. | | `xThread` | (string or object)[] | no | X only: follow-up posts, in order, that turn the X version into a thread. The first post is the X text (platformContent.x or content). Each is a string, or { "text": "...", "mediaIds": [...] } to give that post its own photos or video. Each is up to 280 characters and costs credits like any X post. Up to 24 items. | | `xThread[].text` | string | yes | Up to 10,000 characters. | | `xThread[].mediaIds` | string (uuid)[] | no | This post's own photos (up to 4) or one video. Up to 4 items. | | `instagramFormat` | string | no | Instagram only: "post" (feed photo, carousel or Reel, the default) or "story" (exactly one photo or video; captions are not shown on Stories). | | `tiktok` | object | no | TikTok only: who can see the post, which interactions to allow, and commercial content disclosure. | | `tiktok.privacy` | string | no | Who can see the TikTok post. Left out: public if the account allows it. Ask the user rather than guessing. One of `PUBLIC_TO_EVERYONE`, `MUTUAL_FOLLOW_FRIENDS`, `FOLLOWER_OF_CREATOR`, `SELF_ONLY`. | | `tiktok.allowComments` | boolean | no | Allow comments. Default true. | | `tiktok.allowDuet` | boolean | no | Allow Duet (videos only). Default true. | | `tiktok.allowStitch` | boolean | no | Allow Stitch (videos only). Default true. | | `tiktok.yourBrand` | boolean | no | Commercial content promoting the user's own business. TikTok labels it "Promotional content". | | `tiktok.brandedContent` | boolean | no | Paid partnership promoting a third party. TikTok labels it "Paid partnership"; it can't be private (SELF_ONLY). | | `tiktok.coverTimestampMs` | integer | no | Videos: which moment of the video is the cover, in milliseconds from the start. | | `firstComment` | string | no | Optional first comment, posted under the post right after it goes out, where the platform allows it (e.g. links or hashtags). Empty removes it. Up to 2,200 characters. | | `youtubeTitle` | string | no | YouTube only: the video title (up to 100 characters). Left out: the first line of the text. | | `youtubeThumbnailId` | string (uuid) or null | no | YouTube only: an uploaded photo (under 2 MB) to use as the video's custom thumbnail. null removes it. | | `queue` | boolean | no | Put the post in the next free slot of the posting queue (the accounts' profile's slots, else the workspace's) instead of at scheduledAt. | | `accountIds` | string (uuid)[] | no | Accounts to post to (from list accounts). Up to 200 items. | | `mediaIds` | string (uuid)[] | no | Uploaded media, in display order (from upload media). Up to 10 items. | | `scheduledAt` | string (ISO 8601) | no | When to publish, ISO 8601 with a time zone, e.g. 2026-10-05T09:00:00Z. | | `publishNow` | boolean | no | Publish as soon as possible instead of at scheduledAt. | | `draft` | boolean | no | Save as a draft without scheduling it. | Request: ```bash curl -X PATCH "https://postninja.app/api/v1/posts/0d3c4b5a-6978-4e1f-a2b3-c4d5e6f7a8b9" \ -H "Authorization: Bearer $POSTNINJA_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "scheduledAt": "2026-10-13T09:00:00Z" }' ``` 200 OK: ```json { "data": { "id": "0d3c4b5a-6978-4e1f-a2b3-c4d5e6f7a8b9", "status": "scheduled", "content": "We just shipped dark mode. Here's the full story.", "platformContent": { "x": "Dark mode is here 🌙" }, "xThread": [], "xThreadMedia": [], "instagramFormat": "post", "tiktok": {}, "firstComment": null, "youtubeTitle": null, "youtubeThumbnail": null, "queued": false, "scheduledAt": "2026-10-13T09:00:00.000Z", "publishedAt": null, "createdAt": "2026-10-06T14:21:07.512Z", "media": [ { "id": "5b9d1c2e-3f4a-4b5c-9d6e-7f8a9b0c1d2e", "kind": "image", "url": "https://postninja.app/media/5b9d1c2e-3f4a-4b5c-9d6e-7f8a9b0c1d2e.jpg", "previewUrl": null } ], "targets": [ { "accountId": "3f2c8a10-6d1e-4b7a-9c55-1e2f3a4b5c6d", "platform": "x", "username": "acme", "status": "pending", "url": null, "error": null }, { "accountId": "8b7e1d22-0c4f-4e8a-a1b2-9d8c7b6a5f40", "platform": "instagram", "username": "acme.studio", "status": "pending", "url": null, "error": null } ] } } ``` - `draft: true` unschedules a post; `scheduledAt`, `publishNow` or `queue` schedules a draft. - A queued post stays in the queue (and may move up to an earlier free slot) unless you send a time of its own. - Sending `xThread` as plain strings keeps each reply's photos; send objects to change them. ### DELETE /api/v1/posts/:id Removes a post from PostNinja. A published post stays live on the platforms. Credits held for accounts it hadn't reached yet are refunded. A post that's being sent this second returns `409`; try again in a minute. Path: | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | string (uuid) | yes | The post's id, in the path. | Request: ```bash curl -X DELETE "https://postninja.app/api/v1/posts/0d3c4b5a-6978-4e1f-a2b3-c4d5e6f7a8b9" \ -H "Authorization: Bearer $POSTNINJA_API_KEY" ``` 204 No Content: ```json (no body) ``` ## Approvals When approval is on (`approvalRequired` in [`GET /api/v1/workspace`](#get-api-v1-workspace)), posts members create wait as `awaiting_approval`. Owners, admins and API keys can approve them or send them back. ### POST /api/v1/posts/:id/approve Approves a waiting post: it's scheduled for its time, or goes out now if that time has passed. The body is optional. Body (JSON, optional): | Name | Type | Required | Description | | --- | --- | --- | --- | | `note` | string | no | Optional note for the author. Up to 1,000 characters. | Request: ```bash curl -X POST "https://postninja.app/api/v1/posts/0d3c4b5a-6978-4e1f-a2b3-c4d5e6f7a8b9/approve" \ -H "Authorization: Bearer $POSTNINJA_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "note": "Looks great." }' ``` 200 OK: ```json { "data": { "id": "0d3c4b5a-6978-4e1f-a2b3-c4d5e6f7a8b9", "status": "scheduled", "content": "We just shipped dark mode. Here's the full story.", "platformContent": { "x": "Dark mode is here 🌙" }, "xThread": [], "xThreadMedia": [], "instagramFormat": "post", "tiktok": {}, "firstComment": null, "youtubeTitle": null, "youtubeThumbnail": null, "queued": false, "scheduledAt": "2026-10-12T09:00:00.000Z", "publishedAt": null, "createdAt": "2026-10-06T14:21:07.512Z", "media": [ { "id": "5b9d1c2e-3f4a-4b5c-9d6e-7f8a9b0c1d2e", "kind": "image", "url": "https://postninja.app/media/5b9d1c2e-3f4a-4b5c-9d6e-7f8a9b0c1d2e.jpg", "previewUrl": null } ], "targets": [ { "accountId": "3f2c8a10-6d1e-4b7a-9c55-1e2f3a4b5c6d", "platform": "x", "username": "acme", "status": "pending", "url": null, "error": null }, { "accountId": "8b7e1d22-0c4f-4e8a-a1b2-9d8c7b6a5f40", "platform": "instagram", "username": "acme.studio", "status": "pending", "url": null, "error": null } ], "approvalHistory": [ { "action": "submitted", "by": "Sam Lee", "note": null, "at": "2026-10-06T10:02:11.000Z" }, { "action": "approved", "by": "Alex Kim", "note": "Looks great.", "at": "2026-10-06T12:01:09.000Z" } ] } } ``` ### POST /api/v1/posts/:id/request-changes Sends a waiting post back to its author's drafts. The note is required and emailed to the author. Body (JSON): | Name | Type | Required | Description | | --- | --- | --- | --- | | `note` | string | yes | What needs changing. The author gets it by email. Up to 1,000 characters. | Request: ```bash curl -X POST "https://postninja.app/api/v1/posts/0d3c4b5a-6978-4e1f-a2b3-c4d5e6f7a8b9/request-changes" \ -H "Authorization: Bearer $POSTNINJA_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "note": "Use the photo from Saturday and mention free delivery." }' ``` 200 OK: ```json { "data": { "id": "0d3c4b5a-6978-4e1f-a2b3-c4d5e6f7a8b9", "status": "draft", "content": "We just shipped dark mode. Here's the full story.", "platformContent": { "x": "Dark mode is here 🌙" }, "xThread": [], "xThreadMedia": [], "instagramFormat": "post", "tiktok": {}, "firstComment": null, "youtubeTitle": null, "youtubeThumbnail": null, "queued": false, "scheduledAt": "2026-10-12T09:00:00.000Z", "publishedAt": null, "createdAt": "2026-10-06T14:21:07.512Z", "media": [ { "id": "5b9d1c2e-3f4a-4b5c-9d6e-7f8a9b0c1d2e", "kind": "image", "url": "https://postninja.app/media/5b9d1c2e-3f4a-4b5c-9d6e-7f8a9b0c1d2e.jpg", "previewUrl": null } ], "targets": [ { "accountId": "3f2c8a10-6d1e-4b7a-9c55-1e2f3a4b5c6d", "platform": "x", "username": "acme", "status": "pending", "url": null, "error": null }, { "accountId": "8b7e1d22-0c4f-4e8a-a1b2-9d8c7b6a5f40", "platform": "instagram", "username": "acme.studio", "status": "pending", "url": null, "error": null } ], "approvalHistory": [ { "action": "submitted", "by": "Sam Lee", "note": null, "at": "2026-10-06T10:02:11.000Z" }, { "action": "changes_requested", "by": "Alex Kim", "note": "Use the photo from Saturday.", "at": "2026-10-06T11:40:52.000Z" } ] } } ``` ## Media ### POST /api/v1/media Uploads one photo or video as the raw request body, with its `Content-Type`. Photos (JPEG, PNG or WebP, up to 30 MB) are converted to JPEG. Videos (MP4 or MOV, up to 512 MB) are stored as they are. Use the returned `id` in `mediaIds`; a post takes up to 10. Query parameters (videos, optional): | Name | Type | Required | Description | | --- | --- | --- | --- | | `width` | integer | no | Video width in pixels. | | `height` | integer | no | Video height in pixels. | | `durationMs` | integer | no | Video length in milliseconds. With width and height, platform limits are checked when you create the post. | Request: ```bash curl "https://postninja.app/api/v1/media" \ -H "Authorization: Bearer $POSTNINJA_API_KEY" \ -H "Content-Type: image/jpeg" \ --data-binary @photo.jpg ``` 201 Created: ```json { "data": { "id": "5b9d1c2e-3f4a-4b5c-9d6e-7f8a9b0c1d2e", "kind": "image", "bytes": 284113, "width": 1080, "height": 1350, "durationMs": null, "url": "https://postninja.app/media/5b9d1c2e-3f4a-4b5c-9d6e-7f8a9b0c1d2e.jpg" } } ``` An unsupported file type returns `415` (code `invalid`); a file over the limit returns `413 too_large`. Uploads that no post uses are deleted after two days. ## Workspace, profiles and templates ### GET /api/v1/workspace The workspace's name, whether members' posts need approval, and its brand voice (how posts should sound). Request: ```bash curl "https://postninja.app/api/v1/workspace" \ -H "Authorization: Bearer $POSTNINJA_API_KEY" ``` 200 OK: ```json { "data": { "name": "Acme", "approvalRequired": true, "brandVoice": "Friendly and plain-spoken. Short sentences, no jargon, one emoji at most." } } ``` ### GET /api/v1/profiles Profiles (usually one per client or brand) with their brand voice and account ids. `reviewLink` says whether a client review link is on. Profiles are part of the Pro and Agency plans; on Personal this is an empty list. Request: ```bash curl "https://postninja.app/api/v1/profiles" \ -H "Authorization: Bearer $POSTNINJA_API_KEY" ``` 200 OK: ```json { "data": [ { "id": "5a6b7c8d-9e0f-4a1b-8c2d-3e4f5a6b7c8d", "name": "Fern & Clay", "color": "green", "brandVoice": "Warm, handmade, a little playful. Talk about the makers.", "accountIds": [ "8b7e1d22-0c4f-4e8a-a1b2-9d8c7b6a5f40", "c41d9e7a-2b3c-4d5e-8f60-718293a4b5c6" ], "reviewLink": true } ] } ``` ### GET /api/v1/templates Post templates: the built-in library and the team's saved ones (`category: saved`). Fill each `{key}` blank before posting. Query parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `category` | string | no | One of: promote (Sales and offers), launch (Launches and news), engage (Engagement), educate (Tips and how-tos), proof (Social proof), behind (Behind the scenes), events (Events and seasons), story (Stories and personal brand), community (Community), creator (Creators and influencers), or saved (the team's own). | | `search` | string | no | Words to look for in the name and description. Up to 100 characters. | | `limit` | integer | no | 1 to 100. Default `30`. | Request: ```bash curl "https://postninja.app/api/v1/templates?category=launch&search=stock" \ -H "Authorization: Bearer $POSTNINJA_API_KEY" ``` 200 OK: ```json { "data": [ { "id": "t_back-in-stock", "name": "Back in stock", "category": "launch", "description": "Let people know a popular item is available again.", "fields": [ { "key": "product", "label": "Product", "example": "Our speckled stoneware mugs", "optional": false }, { "key": "quantity", "label": "How many", "example": "We made 40 this round, and they went fast last time.", "optional": true }, { "key": "link", "label": "Link", "example": "fernandclay.com/mugs", "optional": true } ], "content": "Back in stock: {product} 🙌\n\n{quantity}\n\nThank you to everyone who asked us to bring them back. If you missed out before, now's your chance.\n{link}\n\n#BackInStock", "platformContent": {} } ] } ``` ## Posting queue The queue is a set of weekly time slots. `POST /api/v1/posts` with `queue: true` takes the next free slot: the accounts' profile's slots if it has its own, else the workspace's. Slot times are wall-clock times in `timeZone` (the workspace owner's), so 09:00 stays 09:00 when the clocks change. `weekday` is 0 for Monday to 6 for Sunday. ### GET /api/v1/queue Every set of slots: the workspace's (`profileId: null`) and each profile's own. Request: ```bash curl "https://postninja.app/api/v1/queue" \ -H "Authorization: Bearer $POSTNINJA_API_KEY" ``` 200 OK: ```json { "data": { "timeZone": "Europe/London", "slots": [ { "weekday": 0, "time": "09:00", "profileId": null }, { "weekday": 2, "time": "09:00", "profileId": null }, { "weekday": 4, "time": "12:30", "profileId": null }, { "weekday": 1, "time": "18:00", "profileId": "5a6b7c8d-9e0f-4a1b-8c2d-3e4f5a6b7c8d" } ] } } ``` ### PUT /api/v1/queue Replaces one set of slots completely. An empty list removes a profile's own slots so it uses the workspace's. Owners, admins and API keys only; a key limited to some profiles can only change those profiles' slots. Posts already queued keep their times. Body (JSON): | Name | Type | Required | Description | | --- | --- | --- | --- | | `profileId` | string (uuid) or null | no | Whose slots to replace: a profile's id (from list_profiles), or null for the workspace's own slots. Default `null`. | | `slots` | object[] | yes | The complete new list of weekly slots. An empty list removes them (a profile then uses the workspace's slots). Up to 100 items. | | `slots[].weekday` | integer | yes | 0 = Monday … 6 = Sunday. 0 to 6. | | `slots[].time` | string | yes | 24-hour HH:MM in the queue's time zone. | Request: ```bash curl -X PUT "https://postninja.app/api/v1/queue" \ -H "Authorization: Bearer $POSTNINJA_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "profileId": null, "slots": [ { "weekday": 0, "time": "09:00" }, { "weekday": 2, "time": "09:00" }, { "weekday": 4, "time": "12:30" } ] }' ``` 200 OK: ```json { "data": { "timeZone": "Europe/London", "slots": [ { "weekday": 0, "time": "09:00", "profileId": null }, { "weekday": 2, "time": "09:00", "profileId": null }, { "weekday": 4, "time": "12:30", "profileId": null } ] } } ``` ## Analytics ### GET /api/v1/analytics Published posts' stats and follower growth for a range of UTC days, compared with the same number of days before: headline numbers (`kpis`), one row per account, and the top 10 posts by engagement. Read from stats already synced, so it's fast. X posts have no stats; Instagram, Threads, Facebook, TikTok, YouTube and Pinterest posts do. Query parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `from` | string (YYYY-MM-DD) | no | First day. Send with `to`. Without both: the last 30 days. | | `to` | string (YYYY-MM-DD) | no | Last day, inclusive. Up to 366 days after `from`. | | `profileId` | string (uuid) | no | Only this profile's accounts. | | `accountId` | string (uuid) | no | Only this account. | | `platform` | string | no | Only this platform, e.g. `instagram`. | Request: ```bash curl "https://postninja.app/api/v1/analytics?from=2026-09-07&to=2026-10-06" \ -H "Authorization: Bearer $POSTNINJA_API_KEY" ``` 200 OK: ```json { "data": { "range": { "from": "2026-09-07", "to": "2026-10-06", "days": 30, "timeZone": "UTC" }, "previousRange": { "from": "2026-08-08", "to": "2026-09-06" }, "kpis": { "postsPublished": 12, "postsWithStats": 11, "impressions": 48210, "engagements": 2391, "likes": 1874, "reposts": 143, "replies": 262, "quotes": 0, "bookmarks": 112, "engagementRate": 0.0496, "followers": 18422, "followerGrowth": 611, "bestPlatform": { "platform": "instagram", "engagementRate": 0.0712, "posts": 6 }, "previous": { "postsPublished": 12, "postsWithStats": 11, "impressions": 39120, "engagements": 1830, "likes": 1874, "reposts": 143, "replies": 262, "quotes": 0, "bookmarks": 112, "engagementRate": 0.0468, "followerGrowth": 402 } }, "accounts": [ { "id": "8b7e1d22-0c4f-4e8a-a1b2-9d8c7b6a5f40", "platform": "instagram", "username": "acme.studio", "displayName": "Acme Studio", "profileId": "5a6b7c8d-9e0f-4a1b-8c2d-3e4f5a6b7c8d", "followers": 12904, "followersCountedOn": "2026-10-06", "followerGrowth": 488, "postsPublished": 12, "postsWithStats": 11, "impressions": 48210, "engagements": 2391, "likes": 1874, "reposts": 143, "replies": 262, "quotes": 0, "bookmarks": 112, "engagementRate": 0.0496, "averageImpressions": 4383, "topPostId": "0d3c4b5a-6978-4e1f-a2b3-c4d5e6f7a8b9" } ], "topPosts": [ { "id": "0d3c4b5a-6978-4e1f-a2b3-c4d5e6f7a8b9", "text": "Back in stock: our speckled stoneware mugs 🙌", "platforms": [ "instagram", "threads" ], "type": "photo", "publishedAt": "2026-09-18T09:00:04.000Z", "impressions": 9120, "engagements": 811, "engagementRate": 0.0889, "url": "https://www.instagram.com/p/DA1b2C3d4E5/" } ], "notes": { "…": "Short caveats per platform, e.g. which platforms have no post stats." } } } ``` `engagementRate` is engagements divided by impressions (0.0496 = 4.96%). `notes` holds short caveats about platforms whose numbers are missing or limited. ## Comments inbox Comments on your accounts' posts on Instagram, Facebook, Threads, Youtube. A conversation's `id` is its latest comment from someone else; reply to that id. ### GET /api/v1/inbox Conversations, latest activity first. With `?id=`, one conversation with every message and the post it's on (reading it doesn't mark it read). Query parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `filter` | string | no | One of `all`, `unread`, `needs_reply`, `replied`, `hidden`. Default `all`. `needs_reply`: the latest message isn't yours. | | `platform` | string | no | One of `instagram`, `facebook`, `threads`, `youtube`. | | `accountId` | string (uuid) | no | Only this account. | | `limit` | integer | no | 1 to 100. Default 50. | | `offset` | integer | no | Skip this many (use `nextOffset` from the last page). | | `id` | string (uuid) | no | Return this one conversation instead of the list. | Request: ```bash curl "https://postninja.app/api/v1/inbox?filter=needs_reply&limit=20" \ -H "Authorization: Bearer $POSTNINJA_API_KEY" ``` 200 OK: ```json { "data": [ { "id": "9e2a4f5b-6c7d-4e8f-a091-0c1d2e3f4a5b", "accountId": "8b7e1d22-0c4f-4e8a-a1b2-9d8c7b6a5f40", "provider": "instagram", "authorName": "Jamie", "authorHandle": "jamie.makes", "authorAvatarUrl": null, "text": "Do these ship to Canada?", "postedAt": "2026-10-06T08:14:22.000Z", "count": 1, "unread": 1, "needsReply": true, "replied": false, "hidden": false, "postId": "0d3c4b5a-6978-4e1f-a2b3-c4d5e6f7a8b9" } ], "hasMore": false, "nextOffset": null } ``` GET /api/v1/inbox?id=9e2a4f5b-6c7d-4e8f-a091-0c1d2e3f4a5b: ```json { "data": { "id": "9e2a4f5b-6c7d-4e8f-a091-0c1d2e3f4a5b", "accountId": "8b7e1d22-0c4f-4e8a-a1b2-9d8c7b6a5f40", "provider": "instagram", "post": { "text": "Back in stock: our speckled stoneware mugs 🙌", "thumbnail": null, "permalink": "https://www.instagram.com/p/DA1b2C3d4E5/", "postedAt": "2026-10-05T09:00:04.000Z", "postId": "0d3c4b5a-6978-4e1f-a2b3-c4d5e6f7a8b9" }, "messages": [ { "id": "9e2a4f5b-6c7d-4e8f-a091-0c1d2e3f4a5b", "kind": "comment", "fromUs": false, "authorName": "Jamie", "authorHandle": "jamie.makes", "authorAvatarUrl": null, "text": "Do these ship to Canada?", "postedAt": "2026-10-06T08:14:22.000Z", "hidden": false, "permalink": null, "sentByName": null, "readAt": null } ], "canWrite": true, "writeNote": null, "hideLabel": "Hide", "maxLength": 2200 } } ``` ### POST /api/v1/inbox/:id/reply Replies publicly as the account the comment was left on. `maxLength` in the conversation is the platform's limit. If the platform refuses, you get `502` with code `platform`. Path and body: | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | string (uuid) | yes | The comment's id, in the path. | | `text` | string | yes | The reply. | Request: ```bash curl -X POST "https://postninja.app/api/v1/inbox/9e2a4f5b-6c7d-4e8f-a091-0c1d2e3f4a5b/reply" \ -H "Authorization: Bearer $POSTNINJA_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "text": "They do! Shipping to Canada takes 5-7 days." }' ``` 201 Created: ```json { "data": { "id": "a3b4c5d6-e7f8-4a9b-8c0d-1e2f3a4b5c6d", "kind": "reply", "fromUs": true, "authorName": "Acme Studio", "authorHandle": "acme.studio", "authorAvatarUrl": null, "text": "They do! Shipping to Canada takes 5-7 days.", "postedAt": "2026-10-06T09:02:40.000Z", "hidden": false, "permalink": null, "sentByName": null, "readAt": "2026-10-06T09:02:40.000Z" } } ``` ## Direct messages Direct messages to your Instagram professional accounts and Facebook Pages. One conversation per person and account. Instagram and Messenger let businesses answer only within 24 hours of the person's last message: `window.state` is `open` until `window.closesAt`, then `closed`, and replies are refused with `403 forbidden` until they write again. ### GET /api/v1/inbox/messages Conversations, latest message first. With `?id=`, one conversation with its messages (reading it doesn't mark it read). Attachments are links to the platform's files, which expire after a while. Query parameters: | Name | Type | Required | Description | | --- | --- | --- | --- | | `filter` | string | no | One of `all`, `unread`, `needs_reply`. Default `all`. `needs_reply`: they wrote last. | | `platform` | string | no | One of `instagram`, `facebook`. | | `accountId` | string (uuid) | no | Only this account. | | `limit` | integer | no | 1 to 100. Default 50. | | `offset` | integer | no | Skip this many (use `nextOffset` from the last page). | | `id` | string (uuid) | no | Return this one conversation instead of the list. | Request: ```bash curl "https://postninja.app/api/v1/inbox/messages?filter=needs_reply&limit=20" \ -H "Authorization: Bearer $POSTNINJA_API_KEY" ``` 200 OK: ```json { "data": [ { "id": "4e5f6a7b-8c9d-4e0f-a1b2-c3d4e5f6a7b8", "accountId": "8b7e1d22-0c4f-4e8a-a1b2-9d8c7b6a5f40", "provider": "instagram", "participantName": "Jamie Lee", "participantUsername": "jamie.makes", "participantAvatarUrl": null, "lastText": "Hi! Is the large mug back in stock?", "lastFromUs": false, "lastAttachments": 0, "lastMessageAt": "2026-10-06T08:14:22.000Z", "unread": 1, "needsReply": true, "window": { "state": "open", "closesAt": "2026-10-07T08:14:22.000Z" } } ], "hasMore": false, "nextOffset": null } ``` GET /api/v1/inbox/messages?id=4e5f6a7b-8c9d-4e0f-a1b2-c3d4e5f6a7b8: ```json { "data": { "id": "4e5f6a7b-8c9d-4e0f-a1b2-c3d4e5f6a7b8", "accountId": "8b7e1d22-0c4f-4e8a-a1b2-9d8c7b6a5f40", "provider": "instagram", "participant": { "id": "1784001234567890", "name": "Jamie Lee", "username": "jamie.makes", "avatarUrl": null }, "permalink": "https://ig.me/m/jamie.makes", "messages": [ { "id": "6f7a8b9c-0d1e-4f2a-8b3c-4d5e6f7a8b9c", "fromUs": false, "text": "Hi! Is the large mug back in stock?", "attachments": [ { "type": "image", "url": "https://lookaside.fbsbx.com/ig_messaging_cdn/?asset_id=…", "previewUrl": null, "name": null } ], "sentAt": "2026-10-06T08:14:22.000Z", "readAt": null, "sentByName": null } ], "window": { "state": "open", "closesAt": "2026-10-07T08:14:22.000Z" }, "canReply": true, "replyNote": null, "maxLength": 1000, "lengthUnit": "bytes" } } ``` ### POST /api/v1/inbox/messages/:id/reply Sends a private text message in the conversation as the connected account. Instagram takes up to 1,000 bytes (UTF-8), Facebook up to 2,000 characters (`maxLength` and `lengthUnit` in the conversation). A closed reply window or a missing permission is `403 forbidden`; a refusal from the platform is `502 platform`. Path and body: | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | string (uuid) | yes | The conversation's id, in the path. | | `text` | string | yes | The message. | Request: ```bash curl -X POST "https://postninja.app/api/v1/inbox/messages/4e5f6a7b-8c9d-4e0f-a1b2-c3d4e5f6a7b8/reply" \ -H "Authorization: Bearer $POSTNINJA_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "text": "It is! It'\''s back on the shop today." }' ``` 201 Created: ```json { "data": { "id": "2b3c4d5e-6f7a-4b8c-9d0e-1f2a3b4c5d6e", "fromUs": true, "text": "It is! It's back on the shop today.", "attachments": [], "sentAt": "2026-10-06T09:02:40.000Z", "readAt": "2026-10-06T09:02:40.000Z", "sentByName": null } } ``` ## Webhook endpoints Manage where events are sent; see [Webhooks](https://postninja.app/docs/webhooks) for events and signatures. Up to 10 endpoints per workspace. Owners, admins and API keys that aren't limited to some profiles only. Editing, switching off, rotating secrets, test events and redelivery are in **Settings → API → Webhooks**. ### GET /api/v1/webhooks The workspace's endpoints. `events: []` means every event. Request: ```bash curl "https://postninja.app/api/v1/webhooks" \ -H "Authorization: Bearer $POSTNINJA_API_KEY" ``` 200 OK: ```json { "data": [ { "id": "1a2b3c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d", "url": "https://hooks.example.com/postninja", "description": "Slack alerts", "events": [ "post.published", "post.failed" ], "active": true, "failureCount": 0, "lastSuccessAt": null, "lastFailureAt": null, "createdAt": "2026-10-06T14:30:00.000Z" } ] } ``` ### POST /api/v1/webhooks Adds an endpoint. The response includes its signing `secret`, once; store it. Body (JSON): | Name | Type | Required | Description | | --- | --- | --- | --- | | `url` | string | yes | A public `https://` URL. | | `description` | string | no | A note for you. Up to 200 characters. | | `events` | string[] | no | Event names to send. Empty or left out: every event. | Request: ```bash curl -X POST "https://postninja.app/api/v1/webhooks" \ -H "Authorization: Bearer $POSTNINJA_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "url": "https://hooks.example.com/postninja", "description": "Slack alerts", "events": [ "post.published", "post.failed" ] }' ``` 201 Created: ```json { "data": { "id": "1a2b3c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d", "url": "https://hooks.example.com/postninja", "description": "Slack alerts", "events": [ "post.published", "post.failed" ], "active": true, "failureCount": 0, "lastSuccessAt": null, "lastFailureAt": null, "createdAt": "2026-10-06T14:30:00.000Z", "secret": "whsec_3q2-7Yd9kXo0bVt1sNcL5mWfA8eRzP4u" } } ``` ### DELETE /api/v1/webhooks/:id Removes an endpoint and its delivery history. Path: | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | string (uuid) | yes | The endpoint's id, in the path. | Request: ```bash curl -X DELETE "https://postninja.app/api/v1/webhooks/1a2b3c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d" \ -H "Authorization: Bearer $POSTNINJA_API_KEY" ``` 204 No Content: ```json (no body) ``` --- # Webhooks Source: https://postninja.app/docs/webhooks Get a signed HTTPS request the moment something happens in PostNinja: a post goes out or fails, a teammate submits a post for approval, an account needs reconnecting, a comment arrives. Ideal for n8n, Zapier, Make, Slack alerts or your own backend. ## Set up an endpoint 1. Add the URL under **Settings → API → Webhooks**, or with [`POST /api/v1/webhooks`](https://postninja.app/docs/api#post-api-v1-webhooks). It must be a public `https://` address. Up to 10 per workspace. 2. Pick the events you want, or leave it empty for all of them. 3. Copy the signing secret (`whsec_…`). It's shown once; you can rotate it in Settings. 4. Click **Send test event**: PostNinja sends one `webhook.test` so you can check your receiver and its signature check. ## Events | Event | When | In `data` | | --- | --- | --- | | `post.created` | Posts: Created (draft or waiting) | `post`; `status` (`draft` or `awaiting_approval`) | | `post.scheduled` | Posts: Scheduled | `post`; `status` | | `post.updated` | Posts: Changed | `post`; `status` | | `post.rescheduled` | Posts: Moved to a new time | `post` | | `post.deleted` | Posts: Deleted | `postId` only (the post is gone) | | `post.retried` | Posts: Retried | `post` | | `post.published` | Posts: Published | `post` (every target published, with links) | | `post.partial` | Posts: Partly published | `post` (see each target's `status` and `error`) | | `post.failed` | Posts: Failed | `post` (see each target's `error`) | | `post.submitted` | Approvals: Submitted for approval | `post` | | `post.approved` | Approvals: Approved | `post`; `note` if one was left; `profileId` when approved from a client review link | | `post.changes_requested` | Approvals: Changes requested | `post`; `note`; `profileId` when sent back from a client review link | | `account.connected` | Accounts: Connected | `accountId`, `platform`, `username` | | `account.disconnected` | Accounts: Disconnected | `accountId`, `platform` | | `account.needs_reconnect` | Accounts: Needs reconnecting | `accountId`, `platform`, `username`, `note` (why) | | `member.joined` | Team: Member joined | `role` | | `comment.received` | Comments: New comment | `accountId`, `platform`, `inboxItemId`, `author`, `text` (first 280 characters), `reply`, `permalink`, and `post` when it's on a post from PostNinja | | `message.received` | Direct messages: New message | `accountId`, `platform`, `conversationId`, `messageId`, `from` (username or name), `text` (first 280 characters), `attachments` (how many) | | `message.sent` | Direct messages: Reply sent from PostNinja | `accountId`, `platform`, `conversationId`, `messageId`, `to`, `text` (first 280 characters); sent from PostNinja by a person or through the API | Post events carry the post exactly as [`GET /api/v1/posts/:id`](https://postninja.app/docs/api#get-api-v1-posts-id) returns it, in `data.post`. When many comments or messages arrive at once, `comment.received` and `message.received` send one summary instead (`count`, `platforms`, `summary: true`). ## The request | Header | Value | | --- | --- | | `Content-Type` | `application/json` | | `PostNinja-Event` | The event name, e.g. `post.published`. | | `PostNinja-Delivery` | This attempt's id. A redelivery gets a new one; the body's `id` stays the same. | | `PostNinja-Signature` | `t=,v1=` | Body: ```json { "id": "c2a1b3d4-e5f6-4a7b-9c8d-e9f0a1b2c3d4", "event": "post.published", "createdAt": "2026-10-12T09:00:06.512Z", "data": { "postId": "0d3c4b5a-6978-4e1f-a2b3-c4d5e6f7a8b9", "post": { "id": "0d3c4b5a-6978-4e1f-a2b3-c4d5e6f7a8b9", "status": "published", "content": "We just shipped dark mode. Here's the full story.", "platformContent": { "x": "Dark mode is here 🌙" }, "xThread": [], "xThreadMedia": [], "instagramFormat": "post", "tiktok": {}, "firstComment": null, "youtubeTitle": null, "youtubeThumbnail": null, "queued": false, "scheduledAt": "2026-10-12T09:00:00.000Z", "publishedAt": "2026-10-12T09:00:06.000Z", "createdAt": "2026-10-06T14:21:07.512Z", "media": [ { "id": "5b9d1c2e-3f4a-4b5c-9d6e-7f8a9b0c1d2e", "kind": "image", "url": "https://postninja.app/media/5b9d1c2e-3f4a-4b5c-9d6e-7f8a9b0c1d2e.jpg", "previewUrl": null } ], "targets": [ { "accountId": "3f2c8a10-6d1e-4b7a-9c55-1e2f3a4b5c6d", "platform": "x", "username": "acme", "status": "published", "url": "https://x.com/acme/status/1843210987654321098", "error": null }, { "accountId": "8b7e1d22-0c4f-4e8a-a1b2-9d8c7b6a5f40", "platform": "instagram", "username": "acme.studio", "status": "failed", "url": null, "error": "Instagram: photos must be between 4:5 (portrait) and 1.91:1 (landscape). Crop this one first." } ] } } } ``` ## Verify the signature `v1` is the HMAC-SHA256 of `{t}.{raw body}` with your endpoint's secret, in hex. Compute it over the raw body exactly as received (before parsing JSON), compare in constant time, and reject requests whose `t` is more than 5 minutes old. Node.js (Express): ```js import crypto from "node:crypto"; import express from "express"; const app = express(); const SECRET = process.env.POSTNINJA_WEBHOOK_SECRET; // whsec_… app.post("/webhooks/postninja", express.raw({ type: "application/json" }), (req, res) => { const parts = Object.fromEntries((req.get("PostNinja-Signature") ?? "").split(",").map((p) => p.split("="))); const expected = crypto.createHmac("sha256", SECRET).update(`${parts.t}.${req.body}`).digest("hex"); const fresh = Math.abs(Date.now() / 1000 - Number(parts.t)) < 300; const valid = fresh && typeof parts.v1 === "string" && parts.v1.length === expected.length && crypto.timingSafeEqual(Buffer.from(parts.v1), Buffer.from(expected)); if (!valid) return res.status(400).send("Bad signature"); const event = JSON.parse(req.body.toString("utf8")); // Skip events you've already handled: deliveries can repeat. console.log(event.id, event.event, event.data.postId); res.sendStatus(204); }); ``` Python (Flask): ```python import hashlib, hmac, json, os, time from flask import Flask, request, abort app = Flask(__name__) SECRET = os.environ["POSTNINJA_WEBHOOK_SECRET"].encode() # whsec_… @app.post("/webhooks/postninja") def postninja(): raw = request.get_data() # the raw body, before parsing parts = dict(p.split("=", 1) for p in request.headers.get("PostNinja-Signature", "").split(",") if "=" in p) t, v1 = parts.get("t", "0"), parts.get("v1", "") expected = hmac.new(SECRET, f"{t}.".encode() + raw, hashlib.sha256).hexdigest() if abs(time.time() - int(t)) > 300 or not hmac.compare_digest(v1, expected): abort(400) event = json.loads(raw) print(event["id"], event["event"], event["data"].get("postId")) return "", 204 ``` ## Retries and switching off - Answer with any 2xx status within 10 seconds. Do slow work after answering. - Anything else is retried after 1 min, 5 min, 30 min, 2 h, 6 h, 12 h, 24 h; after that the delivery is marked failed. - After 50 failed attempts in a row, the endpoint is switched off and the workspace owner gets an email. Switch it back on in Settings; that resets the count. - Deliveries can arrive more than once or out of order. Use the body's `id` to skip events you've handled, and `createdAt` to order them. - Settings shows each endpoint's recent deliveries (status code and the start of your answer) and can **Redeliver** any of them. History is kept 30 days. ## No-code tools In n8n, Zapier or Make, create a webhook trigger, paste its URL as the endpoint, and choose the events. To verify signatures there, compute the HMAC in a code step with the raw body; if your tool only gives you parsed JSON, keep the URL secret instead and check the event by fetching the post with the REST API. --- # Platform rules and options Source: https://postninja.app/docs/platforms What each platform accepts through the API and MCP: text limits, photos and videos, and the options only some platforms have. PostNinja checks all of this before scheduling, so a post that's accepted can go out. ## Text for each platform `content` goes to every platform unless one has its own text in `platformContent`. Use it for a short X version, hashtags on Instagram, or anything else that should differ. Rules are checked against each platform's own text. Request body: ```json { "content": "Our biggest update yet: dark mode, faster search and a new editor.", "platformContent": { "x": "Dark mode is here 🌙", "instagram": "Our biggest update yet ✨ #productupdate #darkmode", "threads": "Dark mode, faster search and a new editor. What should we build next?" }, "accountIds": [ "…" ], "publishNow": true } ``` ## Photos and videos - Upload with [`POST /api/v1/media`](https://postninja.app/docs/api#post-api-v1-media) or the MCP `upload_image` tool, then pass ids in `mediaIds` (up to 10, in display order). - Photos: JPEG, PNG or WebP up to 30 MB, converted to JPEG. Videos: MP4 or MOV up to 512 MB. - For videos sent through the API, pass `width`, `height` and `durationMs` when uploading so length and shape are checked before scheduling. - Seven days after a post has gone out everywhere, its original files are deleted (the platforms keep their copies); 30 days after a failure that nobody retried. Media that was cleaned up can't be attached to a new post. ## At a glance | Platform | Text | Media | First comment | | --- | --- | --- | --- | | X | 280 characters (weighted) | Up to 4 photos, or 1 video (up to 20 minutes, 512 MB); not both | Yes, up to 280 characters: posted as a reply to the post (or the thread's last post) | | Instagram | 2,200 characters, at most 30 hashtags and 20 @mentions | Required: 1 to 10 photos or videos. Photos between 4:5 (portrait) and 1.91:1 (landscape) | Yes, up to 2,200 characters (not on Stories) | | Threads | 500 characters, up to 5 links | Optional: up to 20 items; photos up to 8 MB, videos up to 5 minutes and 1 GB | Yes, up to 500 characters: posted as a reply | | Facebook | 63,206 characters | Optional: up to 10 photos, or 1 video up to 1 GB; not both | Yes, up to 8,000 characters | | TikTok | 2,200 characters | Required: 1 video (3 seconds to 10 minutes) or up to 35 photos; not both | Not supported | | YouTube | `youtubeTitle`, up to 100 characters. Left out: the first line of the text | 5,000 characters (counted in bytes) | Yes, up to 10,000 characters: a comment on the video | | Pinterest | 800 characters | Required: up to 5 photos, or 1 video (4 seconds to 15 minutes); not both | Not supported | ## X Text counts by X's weights: most CJK characters and emoji count as 2, and every link counts as 23. Each X post costs 10 credits, or 80 when it contains a link, for every selected X account. Thread posts and an X first comment are charged the same way, up front; anything that doesn't go out is refunded. | | | | --- | --- | | Text | 280 characters (weighted) | | Media | Up to 4 photos, or 1 video (up to 20 minutes, 512 MB); not both | | Threads | `xThread`: up to 25 posts in all, each up to 280 characters, each with its own photos or video | | First comment | Yes, up to 280 characters: posted as a reply to the post (or the thread's last post) | | Stats | Not read: X posts count as published without numbers | X thread with a photo on the second post: ```json { "platformContent": { "x": "We rebuilt our editor from scratch. Here's what changed 🧵" }, "xThread": [ "1. It's 3x faster on big documents.", { "text": "2. Everything works offline now.", "mediaIds": [ "…" ] }, "Try it today: https://example.com/editor" ], "accountIds": [ "…" ], "scheduledAt": "2026-10-12T09:00:00Z" } ``` ## Instagram Business and Creator accounts. One photo is a feed post, one video is a Reel, and several items make a carousel. `instagramFormat: "story"` posts a Story instead: exactly one photo or video, and Instagram doesn't show captions on Stories. | | | | --- | --- | | Caption | 2,200 characters, at most 30 hashtags and 20 @mentions | | Media | Required: 1 to 10 photos or videos. Photos between 4:5 (portrait) and 1.91:1 (landscape) | | Videos | Reels 3 seconds to 15 minutes; videos in a carousel up to 60 seconds; up to 300 MB | | Stories | One photo, or a 3-60 second video up to 100 MB | | First comment | Yes, up to 2,200 characters (not on Stories) | | Daily limit | About 50 posts per account per day, set by Instagram | ## Threads Any Threads profile. Text-only posts are fine. Emoji count as their UTF-8 bytes (usually 4), as Threads counts them. | | | | --- | --- | | Text | 500 characters, up to 5 links | | Media | Optional: up to 20 items; photos up to 8 MB, videos up to 5 minutes and 1 GB | | First comment | Yes, up to 500 characters: posted as a reply | | Daily limit | 250 posts per profile per day, set by Threads | ## Facebook Facebook Pages (not personal profiles). Each Page you connect is its own account. | | | | --- | --- | | Text | 63,206 characters | | Media | Optional: up to 10 photos, or 1 video up to 1 GB; not both | | First comment | Yes, up to 8,000 characters | ## TikTok TikTok asks the person posting to choose who can see each post and to disclose commercial content. Set these in `tiktok`; agents should ask rather than guess. While our TikTok app is in TikTok's review, TikTok makes every post private (Only me), whatever `privacy` says. | | | | --- | --- | | Caption | 2,200 characters | | Media | Required: 1 video (3 seconds to 10 minutes) or up to 35 photos; not both | | Privacy | `tiktok.privacy`: `PUBLIC_TO_EVERYONE` (Everyone), `MUTUAL_FOLLOW_FRIENDS` (Friends), `FOLLOWER_OF_CREATOR` (Followers), `SELF_ONLY` (Only me). Left out: public if the account allows it | | Interactions | `allowComments`, `allowDuet`, `allowStitch` (Duet and Stitch for videos only); default true, and off when the account has them disabled | | Disclosure | `yourBrand: true` for your own business (labelled Promotional content); `brandedContent: true` for a paid partnership (labelled Paid partnership, can't be `SELF_ONLY`) | | Cover | `tiktok.coverTimestampMs`: the moment of the video used as its cover | | First comment | Not supported | TikTok settings: ```json { "content": "Our spring collection is here", "tiktok": { "privacy": "PUBLIC_TO_EVERYONE", "allowComments": true, "allowDuet": false, "allowStitch": false, "yourBrand": true, "coverTimestampMs": 2500 }, "mediaIds": [ "…" ], "accountIds": [ "…" ], "scheduledAt": "2026-10-12T18:00:00Z" } ``` Posting to TikTok means you agree to TikTok's Music Usage Confirmation, and to its Branded Content Policy for branded content. ## YouTube Posts are YouTube Shorts: one vertical or square video. The post's text becomes the description. | | | | --- | --- | | Title | `youtubeTitle`, up to 100 characters. Left out: the first line of the text | | Description | 5,000 characters (counted in bytes) | | Media | Required: exactly 1 video, vertical or square, up to 3 minutes | | Thumbnail | `youtubeThumbnailId`: an uploaded photo under 2 MB | | First comment | Yes, up to 10,000 characters: a comment on the video | ## Pinterest Pins go to the board picked for the account on the Connections page; there's no board parameter in the API. The Pin title is the first line of the text, up to 100 characters. | | | | --- | --- | | Description | 800 characters | | Media | Required: up to 5 photos, or 1 video (4 seconds to 15 minutes); not both | | First comment | Not supported | --- # Plans, credits and limits Source: https://postninja.app/docs/limits What each plan includes, what costs credits, and the limits the API and MCP server enforce. Every plan includes API and MCP access. ## Plans | | Personal | Pro | Agency | | --- | --- | --- | --- | | Monthly price | $15 | $50 | $100 | | Yearly price | $144 | $480 | $960 | | Connected accounts | 10 | 100 | Unlimited | | Team | Just you | Unlimited team members | Unlimited team members | | Credits a month | 1,500 (about 150 X posts) | 5,000 (about 500 X posts) | 10,000 (about 1,000 X posts) | | Profiles, approvals, review links | No | Yes | Yes | | API and MCP | Yes | Yes | Yes | Yearly billing is 20% off. A workspace's first plan can start with a 3-day free trial; its monthly credits arrive with the first payment. Without a plan, the API can save drafts but can't schedule posts. ## Credits X charges for every post, so X posts use credits (100 credits = $1). Posting to every other platform is free. Monthly credits refill each month and don't carry over; extra credits you buy never expire. Monthly credits are spent first. | What | Credits | | --- | --- | | An X post | 10 | | An X post with a link | 80 | | Each post of an X thread | 10, or 80 with a link | | An X first comment (it's a reply) | 10, or 80 with a link | | Instagram, Threads, Facebook, TikTok, YouTube, Pinterest | Free | | API calls, MCP calls, analytics, comments, webhooks | Free | - Every selected X account is charged separately: one post to three X accounts costs three times as much. - A post the workspace can't afford is refused when you schedule it, with a message saying how many credits it needs. - Credits are taken once, just before a post goes to X. If it fails, or you delete it before it goes out, they're refunded (monthly credits only within the same month). - Check your balance and buy more under **Settings → Billing**. ## API limits | Limit | Value | | --- | --- | | Requests | 120 a minute per API key (or per app and person), REST and MCP together | | Post text | 10,000 characters before platform limits | | Media per post | 10 | | Accounts per post | 200 | | X thread | 25 posts | | First comment | 2,200 characters before platform limits | | Photo upload | 30 MB (MCP `upload_image`: about 3 MB) | | Video upload | 512 MB | | List results | Posts and templates up to 100 per call; inbox up to 100 with offset paging | | Analytics range | Up to 366 days | | Webhook endpoints | 10 per workspace | | Kept media | Originals 7 days after a post went out everywhere, 30 days after a failure nobody retried | ## Platform limits Platforms also limit how often an account can post (for example about 50 API posts a day on Instagram and 250 on Threads). PostNinja waits and retries when a platform says to slow down. Per-platform text and media rules are on [Platform rules](https://postninja.app/docs/platforms).