Postacio for developers
A REST API and an MCP server, both working on the same groups, drafts and schedules you see in the app. Scripts use an API key; AI agents like Claude and ChatGPT sign in with your account instead.
- REST APIBearer keys for your scripts. Every endpoint, field and example in the API reference.
https://postacio.com/api/v1 - MCP serverClaude, ChatGPT and Claude Code sign in with your account. No key to copy.
https://postacio.com/mcp - WebhooksSigned JSON for 9 draft events, whoever made the change.
Postacio-Signature: t=…,v1=…
The reference.
From your first key to a verified webhook.
Get an API key
- 1In Postacio, open Settings → API & AI agents and choose Create key.
- 2Name it after what will use it - "Newsletter bot" - because every draft and change it makes is signed with that name.
- 3Choose the groups it may use (all of them by default) and whether it may schedule and publish. Publishing is off unless you turn it on: the key then only creates and edits drafts, and you schedule or publish them in Postacio.
- 4Copy the key. It starts with
pk_live_and is shown once; Postacio keeps only a hash of it.
Send it as a bearer token on every request:
curl https://postacio.com/api/v1/me \
-H "Authorization: Bearer $POSTACIO_API_KEY"You can have up to 20 active keys. Revoking one in Settings takes effect on its next request, which then answers 401 revoked.
Connect an AI agent
Agents connect over MCP at https://postacio.com/mcp. They sign in with OAuth: Postacio asks you which groups the agent may use and whether it may publish (off unless you turn it on, so a new agent writes drafts only), and you can change or revoke that later in Settings → API & agents.
Claude
- 1Open Claude and go to Settings → Connectors.
- 2Choose Add custom connector.
- 3Paste https://postacio.com/mcp and choose Connect.
- 4Approve the connection in the browser window that opens.
ChatGPT
- 1Open ChatGPT and go to Settings → Security and login, then turn on Developer mode.
- 2Go to Settings → Connectors and choose Create.
- 3Paste https://postacio.com/mcp and choose OAuth for authentication.
- 4Approve the connection in the browser window that opens.
Developer mode needs a paid ChatGPT plan.
Claude Code
- 1Run this command in your terminal.
Terminal claude mcp add --transport http postacio https://postacio.com/mcp - 2In Claude Code, run /mcp and sign in to Postacio.
Any other MCP client
- 1Paste https://postacio.com/mcp where your client adds an MCP server.
- 2It signs in with OAuth. No API key needed.
The MCP tools mirror the API one to one: listing groups and accounts, creating, editing, planning, scheduling and publishing drafts of every kind, approvals, review comments, media from a file or a link, tags, posting slots, posting goals and analytics. Only webhooks have no tool, because an agent has no address to receive them. An agent is told to confirm with you before it schedules, publishes or deletes, to write a reply or quote only when you ask, and never to post the same text to two accounts of one network.
The server also offers three ready-made prompts your client can show as actions: plan_week, review_scheduled and repurpose_top_post.
Your first draft
A worked example with a key that may publish. Set POSTACIO_API_KEY first.
1. Find your group and an account
curl https://postacio.com/api/v1/groups \
-H "Authorization: Bearer $POSTACIO_API_KEY"
curl https://postacio.com/api/v1/groups/$GROUP_ID/accounts \
-H "Authorization: Bearer $POSTACIO_API_KEY"2. Create a draft in the next free slot
next-free-slot picks the group's next open posting slot for those accounts. Idempotency-Key makes a retried request safe: the same key returns the same draft instead of a second one.
curl -X POST https://postacio.com/api/v1/groups/$GROUP_ID/drafts \
-H "Authorization: Bearer $POSTACIO_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: first-draft-001" \
-d '{
"text": "Hello from the Postacio API",
"targets": [{ "account_id": "ACCOUNT_ID" }],
"publish_at": "next-free-slot"
}'3. Read it back
curl https://postacio.com/api/v1/groups/$GROUP_ID/drafts/$DRAFT_ID \
-H "Authorization: Bearer $POSTACIO_API_KEY"{
"id": "…",
"status": "scheduled",
"publish_state": "idle",
"scheduled_at": "2026-09-14T09:00:00.000Z",
"origin": { "kind": "api", "name": "Newsletter bot" },
"targets": [{ "account_id": "…", "platform": "x", "status": "pending", "permalink": null, "error": null }]
}The draft is on the calendar in the app, marked API, and its activity log says "Newsletter bot (API)".
Scheduling choices
| You send | The draft becomes | Needs publishing |
|---|---|---|
| neither date | A draft | No |
"plan_at": "<datetime>" | Planned for that date. It won't publish on its own. | No |
"plan_at": "next-free-slot" | Planned into the next open slot | No |
"publish_at": "<datetime>" | Scheduled to publish then | Yes |
"publish_at": "next-free-slot" | Scheduled into the next open slot | Yes |
"publish_at": "now" | Publishing immediately (202) | Yes |
- Datetimes are ISO 8601 with an offset, for example
2026-09-14T09:00:00+02:00. next-free-slotlooks 60 days ahead. With no open slot it answers422 validationwithparam: "publish_at".jitter_minutes(0 to 15) moves the publish time by a small random amount so posts don't all land on the minute.- Publishing is asynchronous. After
publishor"publish_at": "now", read the draft untilpublish_stateisfinished; each target then carries itspermalinkorerror. - An existing draft can be moved with
POST …/plan,POST …/scheduleandPOST …/publish, and taken back withDELETE …/planorDELETE …/schedule.
Kinds and options
kind says what a draft is. It defaults to post and can't change after the draft is created.
| Kind | Networks | You also send |
|---|---|---|
post | All | - |
reel | YouTube | A video in media |
poll | X, LinkedIn, Telegram | poll. The text is the question. |
article | One X account with X Premium | - |
reply | One X account | target_url, a link to the X post |
quote | One X account | target_url, a link to the X post |
repost | One LinkedIn account | target_url, a link to the LinkedIn post |
A reel
Upload the video with POST …/media, PUT the bytes, then verify it. Put the answer from verify in media as it is.
curl -X POST https://postacio.com/api/v1/groups/$GROUP_ID/drafts \
-H "Authorization: Bearer $POSTACIO_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"kind": "reel",
"text": "Behind the scenes",
"media": [{ "key": "…", "kind": "video", "content_type": "video/mp4", "name": "bts.mp4" }],
"targets": [{ "account_id": "YOUTUBE_ACCOUNT_ID", "options": { "title": "Behind the scenes", "madeForKids": "no" } }]
}'A poll
curl -X POST https://postacio.com/api/v1/groups/$GROUP_ID/drafts \
-H "Authorization: Bearer $POSTACIO_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"kind": "poll",
"text": "Which topic next week?",
"poll": { "options": ["Pricing", "Hiring", "Tooling"], "duration_days": 1 },
"targets": [{ "account_id": "X_ACCOUNT_ID" }]
}'X and LinkedIn take 2 to 4 answers; Telegram takes up to 12. X polls run 1, 3 or 7 days, LinkedIn polls also 14. A Telegram poll stays open unless it sets close_after_days, and only a Telegram poll can be a quiz or take multiple_answers.
Per-platform options
targets[].options holds the same settings the composer shows for that account's network, such as X reply settings, LinkedIn visibility or the Telegram link preview. The OpenAPI document lists every option with its type, allowed values and default, one shape per network:
curl -s https://postacio.com/api/v1/openapi.json \
| jq '.components.schemas.CreateDraft.properties.targets.items.properties.options.anyOf[]
| { network: .title, options: (.properties | keys) }'An unknown option or a value outside its choices is a 400 validation whose param names it, for example targets[0].options.replySettings.
Posting slots
Slots are the group's recurring posting times, the ones next-free-slot fills. Change them one at a time:
curl -X POST https://postacio.com/api/v1/groups/$GROUP_ID/queue/slots \
-H "Authorization: Bearer $POSTACIO_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "label": "Morning", "days": [0, 2, 4], "minutes": 540, "account_ids": ["ACCOUNT_ID"] }'
curl -X PATCH https://postacio.com/api/v1/groups/$GROUP_ID/queue/slots/$SLOT_ID \
-H "Authorization: Bearer $POSTACIO_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "minutes": 600 }'daysrun 0 (Monday) to 6 (Sunday).minutesis the time after midnight in the group's timezone, so 540 is 09:00.PATCHchanges only the fields you send.starts_on,ends_onandskip_dateslimit when a slot runs.- Changing or deleting a slot doesn't move posts that are already scheduled.
Media from a link
Have a public link rather than the file? Postacio downloads it and stores it like an upload. The answer goes straight into a draft's media list:
curl -X POST https://postacio.com/api/v1/groups/$GROUP_ID/media/import \
-H "Authorization: Bearer $POSTACIO_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "url": "https://example.com/chart.png", "alt_text": "Signups by month" }'Only public https links are fetched, up to 50 MiB, following at most three redirects. A link that needs a sign-in, or points at a private address, is a 400.
Approvals
In a group that requires approval, a post can't be scheduled or published until it is approved at its current version. Every draft reports where it stands in approval: required, status and current.
curl -X POST https://postacio.com/api/v1/groups/$GROUP_ID/drafts/$DRAFT_ID/approval \
-H "Authorization: Bearer $POSTACIO_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "action": "approve", "expected_version": 7 }'requestasks the group's approvers and needs write access.approveandrequest_changesneed publishing permission; changes need acomment.- An approval names the
versionyou reviewed. If the draft changed since, the answer is 409: read it again. - A key or agent can't approve a draft a key or agent wrote (
403 forbidden): a person approves it in Postacio. - A reply to, or quote of, someone else's X post that a key or agent wrote doesn't publish until a person approves it in Postacio. At its time it is held with
hold_reasonneeds_person, and the draft says so beforehand inwarnings. - Filter the list with
?approval=pending, and byaccount_idor afrom/towindow withsort=time.
Group analytics
GET /groups/$GROUP_ID/analytics/summary answers a group's numbers in one call: posts published, each metric summed, and the change in followers, per account and in total.
Errors
Every error has the same shape:
{
"error": {
"code": "validation",
"message": "…",
"param": "targets[ACCOUNT_ID].text",
"detail": [{ "code": "…", "message": "…", "account_id": "…" }]
}
}| Status | Code | Meaning |
|---|---|---|
| 400, 422 | validation | The request is malformed or a target can't take it. param and detail say where. |
| 401 | unauthorized | No key or token, or one Postacio doesn't recognise. |
| 401 | revoked | The key or the agent connection was revoked in Settings. |
| 402 | account_paused | The account's subscription is paused. |
| 402 | plan_limit | The request is over the account's plan limits. |
| 403 | publish_not_allowed | This key or connection may not schedule or publish. |
| 404 | not_found | Not there, or not in a group this key may use. |
| 409 | conflict | The draft is publishing or already finished and can't change. |
| 409 | idempotency_conflict | An Idempotency-Key was reused with a different request. |
| 429 | rate_limited | Too many requests. Wait for Retry-After. |
| 500 | internal | Something failed on our side. Retrying with the same Idempotency-Key is safe. |
| 503 | maintenance | Postacio is closed for maintenance. Wait for Retry-After. |
Rate limits
- 120 / 30
- reads / writes a minute, per key or agent
- 300
- requests a minute, per account across all keys and agents
- 1 : 1
- an MCP tool call counts like the API request it maps to
Every response carries X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset (unix seconds) for the key, and the same three with -User- in the name for the account. A 429 adds Retry-After.
Webhooks
Add an address in Settings → API & AI agents → Webhooks and choose the events you want. Postacio sends a JSON POST for each one, whoever made the change - you in the app, a key or an agent.
| Event | When |
|---|---|
draft.created | A draft was created. |
draft.updated | A draft's content or settings changed. |
draft.planned | A draft was given a date. It won't publish on its own. |
draft.scheduled | A draft was scheduled to publish. |
draft.unscheduled | A scheduled draft was taken off the schedule. |
draft.published | Every destination published. |
draft.partially_failed | Some destinations published and some failed. |
draft.failed | Every destination failed. |
draft.deleted | A draft was deleted. The payload carries only its id and group. |
What arrives
POST /your/endpoint
Content-Type: application/json
User-Agent: Postacio-Webhooks/1
Postacio-Event: draft.published
Postacio-Delivery-Id: 5f0c…
Postacio-Signature: t=1789466400,v1=3b1f…
{
"id": "5f0c…",
"event": "draft.published",
"created_at": "2026-09-13T10:00:00Z",
"origin": { "kind": "agent", "name": "Claude" },
"data": { "draft": { "id": "…", "status": "published", "targets": [ … ] } }
}id is the delivery id, the same as the header and the same on every retry, so you can ignore one you have already handled. Bodies are at most 32 KiB; a draft too large to fit arrives as { "draft": { "id", "group_id" }, "truncated": true } and you can read it through the API.
Verify the signature
v1 is an HMAC-SHA256 of <t>.<raw body> keyed with your webhook's signing secret (whsec_…, shown once when you add it or rotate it). Verify the raw bytes before parsing the JSON, and reject a timestamp more than 5 minutes old.
import { createHmac, timingSafeEqual } from "node:crypto";
// rawBody is the request body exactly as received - verify it before JSON.parse.
// In Express: app.post("/hooks/postacio", express.raw({ type: "application/json" }), handler)
export function verifyPostacioSignature(secret, header, rawBody, toleranceSec = 300) {
let timestamp = null;
const signatures = [];
for (const part of String(header ?? "").split(",")) {
const [key, value] = part.trim().split("=", 2);
if (key === "t" && /^\d+$/.test(value ?? "")) timestamp = Number(value);
if (key === "v1" && value) signatures.push(value);
}
if (timestamp === null || signatures.length === 0) return false;
if (Math.abs(Math.floor(Date.now() / 1000) - timestamp) > toleranceSec) return false;
const expected = Buffer.from(
createHmac("sha256", secret).update(`${timestamp}.${rawBody}`, "utf8").digest("hex"),
);
return signatures.some((signature) => {
const given = Buffer.from(signature);
return given.length === expected.length && timingSafeEqual(given, expected);
});
}Retries and pausing
- Any 2xx within 30 seconds counts as delivered. Anything else is a failed attempt.
- A failed delivery is retried up to 7 times with growing gaps: about 30 seconds, then 1, 2, 4, 8, 16, 32 and 64 minutes.
- After 25 failed attempts in a row spanning at least 24 hours, the webhook is paused and you get a notification. Fix the endpoint, then choose Resume in Settings.
- Settings keeps each webhook's recent deliveries with the status your endpoint returned, and can resend any of them.
- Addresses must be public
https://URLs without a username or password.
Manage webhooks through the API
Tools that subscribe with REST hooks, such as Zapier, can add a webhook when they turn on and remove it when they turn off. A webhook receives events from every group, so this needs a key with access to all groups. Other keys get 403 forbidden.
curl -X POST https://postacio.com/api/v1/webhooks \
-H "Authorization: Bearer $POSTACIO_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "url": "https://hooks.example.com/postacio", "events": ["draft.published"] }'
curl -X DELETE https://postacio.com/api/v1/webhooks/$WEBHOOK_ID \
-H "Authorization: Bearer $POSTACIO_API_KEY"The create answer carries the whsec_ signing secret, once. A retry with the same Idempotency-Key returns the webhook without it. Up to 10 webhooks; PATCH /webhooks/{id} changes one or resumes it with "status": "active". Webhooks added this way show in Settings like any other.
Agent Skill
For a coding agent that runs on your machine and would rather call the API than connect over MCP, Postacio has an Agent Skill. It is a SKILL.md with instructions and a small Node helper, scripts/postacio.mjs, that needs no install beyond Node.
- 1Copy the
agent-skills/postaciofolder into your agent's skills folder. For Claude Code, that is~/.claude/skills/postacio. - 2Create an API key (see Get an API key) and set it as
POSTACIO_API_KEYin the environment the agent runs in. - 3Ask the agent to draft or schedule a post. It reads the skill when the task calls for it.
Everything the skill does goes through the API with that key, with the key's groups and publishing permission.
Loading it into a tool? The same reference is the OpenAPI document.
Questions before you start.
Do AI agents need an API key?
No. Claude, ChatGPT and other MCP clients sign in with your Postacio account over OAuth. Postacio asks which groups the agent may use and whether it may publish (off unless you turn it on), and you can change or revoke that in Settings → API & AI agents. API keys are for your own scripts.
Which plans include the API?
The REST API, webhooks and the MCP server are on every plan, and on the free trial.
Is there an OpenAPI document?
Yes, at /api/v1/openapi.json. It lists every route, parameter and schema, including the options each network takes, and loads in any OpenAPI tool. The API reference at /developers/api is drawn from it.
What are the rate limits?
120 reads and 30 writes a minute for each key or agent, and 300 requests a minute for the account across all of them. A 429 answer says when to try again in Retry-After.
How do I know when a post has gone out?
Add a webhook for draft.published, draft.partially_failed and draft.failed, or read the draft until its publish_state is finished. Each target then carries its permalink or its error.
Build on your own schedule.
Start your free trial, create a key in Settings, and send your first draft.
Review comments#
Read and write the same review comments as the draft's comment panel. A comment from a key or agent is signed with its name, and
@Namenotifies that teammate or reviewer. Comments are flat, so to reply you add another comment.thread_post_indexpins a comment to one post of a thread (0 is the first). A key may resolve, reopen ("resolved": false) or delete any comment on a draft it can reach, whoever wrote it.