calmposts

REST API and webhooks

Scripts and AI agents act in one workspace with the scopes their key was given. Every post they create gets source “api”.

REST API v1

At /api/v1, authenticated with an API key.

Hosted MCP server

At /mcp (Streamable HTTP), with an API key or an OAuth token from claude.ai or ChatGPT.

MCP docs

Local MCP binary

calmposts-mcp runs the same tools over stdio, on top of the REST API.

MCP docs

The machine-readable reference is https://calmposts.com/api/v1/openapi.json (OpenAPI 3.1, public).

API keys

The workspace's owner creates keys in API & MCP in the app and picks a name and scopes. The key (cp_live_…) is shown once; calmposts keeps only its SHA-256 hash and its first 12 characters.

A revoked key is refused on its next request. A key also stops working when its creator is blocked, deleted or leaves the workspace. Your plan sets how many keys you can hold; the free plan has none.

Every request sends Authorization: Bearer cp_live_…. Each key gets 60 requests a minute, REST and MCP together; past that the answer is 429 rate_limited with Retry-After in seconds.

ScopeAllows
accounts:readGET /accounts, GET /limits
posts:readGET /posts, GET /posts/:id, GET /queue/next
posts:writePOST /posts, PATCH /posts/:id, DELETE /posts/:id, POST /posts/:id/retry
media:writePOST /media

Errors

Errors share one envelope: {"error": "…", "code": "…", "field": "…"}.

A post that fails validation is a 422: field and code name the first problem (targets[0].parts[0].text, too_long) and the errors array lists them all. A quota is 402 quota_exceeded with the metric as field; a missing scope is 403 insufficient_scope.

Calls

Set up

Keep the key in an environment variable; the examples below reuse $auth.

export CALMPOSTS_API_KEY=cp_live_…
export CALMPOSTS=https://calmposts.com
auth=(-H "Authorization: Bearer $CALMPOSTS_API_KEY")

Accounts

Each connected account with its network's rules and its character limit.

curl "${auth[@]}" $CALMPOSTS/api/v1/accounts
# {"items":[{"id":"…","provider":"x","handle":"@carol","status":"active","char_limit":280,"capabilities":{…}}]}

Limits

The plan's usage this period, the workspace's time zone and each network's rules.

curl "${auth[@]}" $CALMPOSTS/api/v1/limits
# {"timezone":"Europe/Berlin","billing":{"plan":{…},"usage":{"posts_per_day":{"used":3,"limit":30,…}}},"networks":[…]}

Create a post

schedule.mode is draft, queue (the accounts' next free slot), at (with an RFC 3339 time) or now. Several parts make a thread; a target's own parts give that channel its own wording.

curl -X POST $CALMPOSTS/api/v1/posts "${auth[@]}" -H "Content-Type: application/json" -d '{
  "parts":    [{"text": "Calm posts, scheduled."}],
  "targets":  [{"account_id": "ACCOUNT_ID"}],
  "schedule": {"mode": "queue"}
}'
# 201 {"id":"…","status":"scheduled","scheduled_at":"…","version":1,"source":"api",…}

Attach an image

Upload first, then pass the media id in a part.

curl -X POST $CALMPOSTS/api/v1/media "${auth[@]}" -F file=@cat.png -F alt="A calm cat"
# 201 {"id":"MEDIA_ID","kind":"image",…}  →  "parts": [{"text": "…", "media_ids": ["MEDIA_ID"]}]

List and read

Filter by status, account_id, from and to (RFC 3339), q, limit and offset.

curl "${auth[@]}" "$CALMPOSTS/api/v1/posts?status=scheduled,failed&limit=20"   # {"items":[…],"total":n}
curl "${auth[@]}" $CALMPOSTS/api/v1/posts/POST_ID

Edit

PATCH changes what the body names, from the version you read; a stale version is 409 version_conflict. Only drafts and scheduled posts can be edited.

curl -X PATCH $CALMPOSTS/api/v1/posts/POST_ID "${auth[@]}" -H "Content-Type: application/json" \
  -d '{"version": 1, "schedule": {"mode": "at", "at": "2026-10-21T09:30:00+02:00"}}'

Delete, retry, next slot

DELETE cancels a scheduled post (a published one stays on the network). retry sends the failed channels again now. queue/next is where “queue” would put a post.

curl -X DELETE "${auth[@]}" $CALMPOSTS/api/v1/posts/POST_ID          # 204
curl -X POST "${auth[@]}" $CALMPOSTS/api/v1/posts/POST_ID/retry      # the post
curl "${auth[@]}" "$CALMPOSTS/api/v1/queue/next?account_ids=A,B"     # {"at":"2026-10-21T09:00:00Z"}

Webhooks

Instead of polling, let calmposts tell your system what happened. Each delivery is a POST with a JSON body; the id is unique, so a receiver can drop a repeat.

EventSent when
post.publishedevery channel of a post went out (data: post_id and each target's url)
post.faileda channel of a post failed for good (data: post_id, target_id, error_code, error)
post.pending_approvalan agent's post waits for a person to approve it
account.needs_reconnecta network revoked the access and the account needs reconnecting
{
  "id": "…",
  "type": "post.published",
  "workspace_id": "…",
  "created_at": "2026-10-21T09:00:03Z",
  "data": {"post_id": "…", "targets": [{"id": "…", "account_id": "…", "provider": "x", "url": "https://x.com/…"}]}
}

The body carries no token or secret.

Connect an agent over MCP