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

  1. In Postacio, open Settings → API & agents and choose Create key.
  2. Name it after what will use it - "Newsletter bot" - because every draft and change it makes is signed with that name.
  3. 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.
  4. 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

  1. Open Claude and go to Settings → Connectors.
  2. Choose Add custom connector.
  3. Paste https://staging.postacio.com/mcp and choose Connect.
  4. Approve the connection in the browser window that opens.

ChatGPT

  1. Open ChatGPT and go to Settings → Security and login, then turn on Developer mode.
  2. Go to Settings → Connectors and choose Create.
  3. Paste https://staging.postacio.com/mcp and choose OAuth for authentication.
  4. Approve the connection in the browser window that opens.

Developer mode needs a paid ChatGPT plan.

Claude Code

  1. Run this command in your terminal.
    claude mcp add --transport http postacio https://staging.postacio.com/mcp
  2. In Claude Code, run /mcp and sign in to Postacio.

Any other MCP client

  1. Paste https://staging.postacio.com/mcp where your client adds an MCP server.
  2. 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 sendThe draft becomesNeeds publishing
neither dateA draftNo
"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 slotNo
"publish_at": "<datetime>"Scheduled to publish thenYes
"publish_at": "next-free-slot"Scheduled into the next open slotYes
"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-slot looks 60 days ahead. With no open slot it answers 422 validation with param: "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 publish or "publish_at": "now", read the draft until publish_state is finished; each target then carries its permalink or error.
  • An existing draft can be moved with POST …/plan, POST …/schedule and POST …/publish, and taken back with DELETE …/plan or DELETE …/schedule.

Errors

Every error has the same shape:

{
  "error": {
    "code": "validation",
    "message": "…",
    "param": "targets[ACCOUNT_ID].text",
    "detail": [{ "code": "…", "message": "…", "account_id": "…" }]
  }
}
StatusCodeMeaning
400, 422validationThe request is malformed or a target can't take it. param and detail say where.
401unauthorizedNo key or token, or one Postacio doesn't recognise.
401revokedThe key or the agent connection was revoked in Settings.
402account_pausedThe account's subscription is paused.
402plan_limitThe request is over the account's plan limits.
403publish_not_allowedThis key or connection may not schedule or publish.
404not_foundNot there, or not in a group this key may use.
409conflictThe draft is publishing or already finished and can't change.
409idempotency_conflictAn Idempotency-Key was reused with a different request.
429rate_limitedToo many requests. Wait for Retry-After.
500internalSomething 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.

EventWhen
draft.createdA draft was created.
draft.updatedA draft's content or settings changed.
draft.plannedA draft was given a date. It won't publish on its own.
draft.scheduledA draft was scheduled to publish.
draft.unscheduledA scheduled draft was taken off the schedule.
draft.publishedEvery destination published.
draft.partially_failedSome destinations published and some failed.
draft.failedEvery destination failed.
draft.deletedA 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.

Full reference

Every route, parameter and schema is in the OpenAPI document.