Postacio for developers
Postacio has a REST API and an MCP server. Both work 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, so they never need one.
The API lives at https://staging.postacio.com/api/v1. The full reference is the OpenAPI document, which any OpenAPI tool can load.
Get an API key
- In Postacio, open Settings → API & agents and choose Create key.
- Name it after what will use it - "Newsletter bot" - because every draft and change it makes is signed with that name.
- Choose the groups it may use (all of them by default) and whether it may schedule and publish. With publishing off it can only create and edit drafts.
- Copy 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://staging.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://staging.postacio.com/mcp. They sign in with OAuth: Postacio asks you which groups the agent may use and whether it may publish, and you can change or revoke that later in Settings → API & agents.
Claude
- Open Claude and go to Settings → Connectors.
- Choose Add custom connector.
- Paste https://staging.postacio.com/mcp and choose Connect.
- Approve the connection in the browser window that opens.
ChatGPT
- Open ChatGPT and go to Settings → Security and login, then turn on Developer mode.
- Go to Settings → Connectors and choose Create.
- Paste https://staging.postacio.com/mcp and choose OAuth for authentication.
- Approve the connection in the browser window that opens.
Developer mode needs a paid ChatGPT plan.
Claude Code
- Run this command in your terminal.
claude mcp add --transport http postacio https://staging.postacio.com/mcp - In Claude Code, run /mcp and sign in to Postacio.
Any other MCP client
- Paste https://staging.postacio.com/mcp where your client adds an MCP server.
- It 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, uploading media, tags, free posting slots and analytics. An agent is told to confirm with you before it publishes or deletes.
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://staging.postacio.com/api/v1/groups \
-H "Authorization: Bearer $POSTACIO_API_KEY"
curl https://staging.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-slotpicks 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://staging.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://staging.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.
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. |
Rate limits
- Per key or agent: 120 reads and 30 writes a minute.
- Per account: 300 requests a minute across all keys and agents.
- MCP tool calls count exactly like the API requests they map 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 & 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.