Skip to content
On this page

Developers

Public API & MCP

Automate scheduling with the REST API, react to events with signed webhooks, and let AI agents work your calendar over MCP. New here? Start with the overview.

Everything you can do in the composer, from code or from an AI agent: list channels, schedule, publish now, save drafts, edit, reschedule, delete, find the next queue slot, upload media, manage tags and read analytics. Resellers can also onboard clients without the UI: create customers, assign channels to them and invite client reviewers to the branded portal (§3, Customers & client portal). The same API key works for the REST API (/api/v1) and the MCP server (/api/mcp), so Claude (or any MCP client) can schedule for you.

  • Base URL: $APP_URL/api/v1 (e.g. https://app.example.com/api/v1)
  • OpenAPI 3.1: GET $APP_URL/api/v1/openapi.json (public, no auth; import into Postman/Insomnia/Bruno or generate an SDK)
  • MCP endpoint: POST $APP_URL/api/mcp (Streamable HTTP, stateless, JSON responses)
  • Bodies are JSON (Content-Type: application/json) except multipart media uploads.
  • Times are ISO-8601 with a timezone (2026-11-02T09:30:00Z or 2026-11-02T11:30:00+02:00).

1. API keys#

Create keys in Settings → API & MCP (/app/settings/api, workspace admins and owners).

  • A key looks like orb_ + 43 base62 characters (~256 bits). It is shown once; only a SHA-256 fingerprint and a short display prefix (orb_AbC1dE2f) are stored. Lost it? Revoke and create a new one.
  • A key belongs to one workspace and acts with the current role of the member who created it: demote the creator to viewer and the key becomes read-only; remove them from the workspace and the key stops working.
  • Up to 50 active keys per workspace. Revocation takes effect immediately for REST and MCP.
  • "Last used" is updated at most once a minute per key.
  • Customer, channel-assignment and client-portal endpoints (reseller provisioning) need a key whose creator is currently an admin or owner; editor and viewer keys get 403 forbidden.
  • API access can be gated by plan (apiAccess entitlement). When billing is disabled (self-host / white-label), it is always on.

Authentication#

http
Authorization: Bearer orb_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx

Also accepted for compatibility: Authorization: orb_… (no scheme, as Postiz clients send it) and X-API-Key: orb_….

Rate limits#

120 requests per minute per key (fixed window). On the MCP endpoint every tools/call counts as one request, also inside a JSON-RPC batch (see §4). Every authenticated response carries:

HeaderMeaning
X-RateLimit-Limitrequests allowed per window
X-RateLimit-Remainingrequests left in this window
X-RateLimit-Resetunix seconds when the window resets
Retry-Afteron 429 only: seconds to wait

Provisioning writes have their own per-key buckets on top of that: 60 customer / channel-assignment / portal-access writes per minute, and 50 portal invites per hour (each one emails someone outside the workspace). Exceeding one returns 429 rate_limited with Retry-After and details.bucket (writes or invites).

Request ids#

Every response has X-Request-Id. Send your own X-Request-Id (8–128 chars of [A-Za-z0-9._-]) to correlate logs; otherwise one is generated (req_…). Quote it when contacting support.


2. Errors#

All errors use one envelope:

json
{
  "error": {
    "code": "validation_error",
    "message": "The request is invalid.",
    "details": { "issues": [{ "path": "posts.0.value", "message": "Too small: expected array to have >=1 items" }] },
    "requestId": "req_7hQ2…"
  }
}
StatuscodeWhen
400bad_requestmalformed JSON / multipart, bad Idempotency-Key
400validation_errorbody or query failed validation (details.issues[].path)
401unauthenticatedmissing, invalid or revoked key; creator left the workspace
402limit_reachedplan doesn't include API access, or a plan limit (posts/month, storage) is hit
403forbiddenkey's role too low (writes need editor)
404not_foundpost group, channel or media not in this workspace
405method_not_allowedwrong HTTP method (Allow header lists the right ones)
409conflict / idempotency_in_progressstate conflict / same Idempotency-Key still running
413payload_too_largebody or file too big
415unsupported_media_typefile type not allowed, or wrong Content-Type
422invalidsemantically invalid: past date, provider rule (length, media), unknown tag (details.fieldErrors)
422idempotency_key_reusedsame Idempotency-Key with a different body
429rate_limitedsee Retry-After
500internalour fault — retry, then contact support with the requestId
503unavailablea dependency isn't configured on this instance (e.g. media storage); the message says what to set

3. Endpoints#

MethodPathRoleDescription
GET/meviewerworkspace, plan, key info, role, rate limit
GET/channelsviewerconnected channels
GET/postsviewerlist posts (one row per channel), filters + cursor pagination
POST/postseditorcreate (schedule / now / draft), Postiz-compatible, Idempotency-Key
GET/posts/{groupId}viewerone post group with per-channel status
PATCH/posts/{groupId}editoredit content, thread, media, tags, channels, time, or unschedule
DELETE/posts/{groupId}editorcancel + delete on all channels
POST/posts/{groupId}/rescheduleeditormove unpublished posts to { date }
GET/slots/next?channelIds=a,b&after=ISOviewernext free queue slot shared by the channels
GET/media?limit=50viewermedia library, newest first
POST/mediaeditorupload (multipart file) or import from URL (JSON { url, name? })
GET/tagsviewerthe workspace's post tags
POST/tagseditorcreate a tag { name, color? } (returns the existing tag of that name)
GET/customersviewercustomers (agency mode: named groups of channels)
POST/customersadmincreate a customer { name, color? } (color: hex like #8b5cf6)
GET/customers/{customerId}viewerone customer with its channel count
PATCH/customers/{customerId}adminrename / recolor { name?, color? } (color: null clears it)
DELETE/customers/{customerId}admindelete; channels stay connected (unassigned), portal access is revoked
POST/customers/{customerId}/portal-invitesadmininvite a client reviewer { email } → { inviteUrl, expiresAt, … }
DELETE/customers/{customerId}/portal-invites/{inviteId}adminrevoke a pending invite
GET/customers/{customerId}/portal-membersadminreviewers with access + pending invites
DELETE/customers/{customerId}/portal-members/{userId}adminrevoke a reviewer's portal access
POST/channels/{channelId}/customeradminassign a channel { customerId } (null unassigns)
GET/analytics?from&to&days&customerId&channelIdviewertotals, per-channel rows and daily series (workspace, customer or channel)
GET/analytics/{channelId}?from&to&daysviewerdaily series of one channel (Postiz shape; ?date=<days> accepted)
GET/openapi.jsonpublicOpenAPI 3.1 document

Postiz-compatible aliases (to migrate existing scripts, n8n/Make flows): GET /integrations, GET /find-slot/{channelId} → { date }, POST /upload (same as /media; response includes path), GET /analytics/{channelId}?date=7 (Postiz series array).

A post group is one composition fanned out to several channels. Each channel gets its own post row (own status, error, live URL). Group status is the shared status or "mixed".

GET /me#

bash
curl $APP_URL/api/v1/me -H "Authorization: Bearer $API_KEY"
json
{
  "workspace": { "id": "…", "name": "Seraya", "slug": "seraya", "timezone": "Europe/Vilnius", "plan": { "id": "pro", "name": "Pro" } },
  "apiKey": { "id": "…", "name": "Claude", "prefix": "orb_AbC1dE2f" },
  "role": "admin",
  "rateLimit": { "limit": 120, "remaining": 119, "resetAt": "2026-10-25T10:01:00.000Z" }
}

GET /channels#

json
{
  "data": [
    {
      "id": "8f1c…", "name": "Seraya Studio", "provider": "linkedin-page", "username": "seraya",
      "avatarUrl": "https://…", "profileUrl": "https://…", "status": "active", "customerId": null,
      "postingTimes": [540, 780, 1080]
    }
  ]
}

status: active (can publish), reconnect_required, disabled. postingTimes are minutes of the day in the workspace timezone (540 = 09:00) and drive the queue.

POST /posts — create#

Postiz-compatible body:

json
{
  "type": "schedule",
  "date": "2026-11-02T09:00:00Z",
  "tags": ["Launch"],
  "posts": [
    {
      "integration": "CHANNEL_ID",
      "value": [
        { "content": "We just launched 🚀", "media": [{ "id": "MEDIA_ID" }] },
        { "content": "Here's what's new…" }
      ],
      "settings": { "title": "Launch" }
    },
    { "integration": "OTHER_CHANNEL_ID", "value": [{ "content": "A different caption for this network" }] }
  ]
}
  • type: schedule (default) — publish at date, or at the next free queue slot when date is omitted; now — publish immediately (the response returns first, publishing continues server-side; if the request itself ran long, e.g. importing large media URLs, the posts are left scheduled for now and the minute publish tick sends them instead — either way they go out once); draft — save without scheduling.
  • posts[].integration: channel id (string, or { "id": "…" }).
  • posts[].value[0] is the post; value[1..] are thread replies (X/Threads/Bluesky/Mastodon) or first comments where the network supports it.
  • Media: [{ "id": "…" }] from POST /media, or [{ "url": "https://…" }] (imported into the library on the fly; path is accepted as an alias). Optional alt. Media is only supported on value[0].
  • settings: provider-specific options (subreddit/title for Reddit, board for Pinterest, privacy for TikTok/YouTube…). __type is ignored.
  • tags: names or ids of existing workspace tags (Postiz { value, label } objects are accepted). Unknown tags → 422.
  • Provider rules (length, media count/types, required settings) are checked for scheduled posts → 422 with per-channel messages.
  • A date more than 5 minutes in the past → 422 (use type: "now").
  • shortLink: true shortens links when the instance has a shortener configured (Bitly or Dub; otherwise links are kept as written). utmPresetId tags links with one of the workspace's UTM presets. Both are also accepted by PATCH /posts/{groupId}.
  • campaign: a campaign id (campaigns are created on the calendar). The new post joins that campaign (its band on the calendar, its rollup and ?campaign= in Analytics). An unknown id, or another workspace's, is a 422 and nothing is created.
bash
curl -X POST $APP_URL/api/v1/posts \
  -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{"type":"schedule","date":"2026-11-02T09:00:00Z","posts":[{"integration":"CHANNEL_ID","value":[{"content":"Hello from the API"}]}]}'

Response 201 (with Location: /api/v1/posts/{groupId}):

json
{
  "type": "schedule",
  "groupId": "c0a1…",
  "status": "scheduled",
  "scheduledAt": "2026-11-02T09:00:00.000Z",
  "posts": [
    {
      "id": "…", "groupId": "c0a1…", "channelId": "CHANNEL_ID", "status": "scheduled",
      "content": "Hello from the API", "thread": [], "media": [], "settings": {},
      "scheduledAt": "2026-11-02T09:00:00.000Z", "publishedAt": null, "externalUrl": null,
      "error": null, "attempts": 0, "createdAt": "…", "updatedAt": "…"
    }
  ]
}

Idempotency#

Send Idempotency-Key: <unique string ≤255 visible ASCII chars> (a UUID is ideal) to make retries safe:

  • same key + same body within 24 h → the original response is replayed (Idempotent-Replayed: true), nothing is created twice;
  • same key + different body → 422 idempotency_key_reused;
  • same key while the first request is still running → 409 idempotency_in_progress (+ Retry-After). A create can legitimately take minutes (each media URL import may take up to 180 s), so this lasts as long as the request could possibly still be running: its whole 300 s time budget plus a minute (6 min). A claim older than that belongs to a request that was killed, and the next retry takes it over — the post is never created twice by a client that timed out and retried;
  • if the first request failed, the key is released — fix the problem and retry with the same key.

Keys are scoped per workspace.

GET /posts — list#

Query: from, to (ISO; aliases startDate, endDate), status (comma list of draft,scheduled,publishing,published,failed,cancelled), channelId (comma list), limit (1–100, default 50), cursor.

bash
curl "$APP_URL/api/v1/posts?status=scheduled,failed&from=2026-11-01T00:00:00Z&limit=20" -H "Authorization: Bearer $API_KEY"
json
{ "data": [ { "id": "…", "groupId": "…", "status": "scheduled", "…": "…" } ],
  "pagination": { "limit": 20, "hasMore": true, "nextCursor": "eyJ2IjoyLCJzIjoi…" } }

Pass nextCursor unchanged as cursor (with the same filters) for the next page; nextCursor is null on the last page. Rows are ordered by scheduled time, then id (a total order, so the rows of a fan-out group that share one time, and undated drafts, are never repeated or skipped across pages); undated drafts come last. Cursors are opaque keyset positions — no depth limit; a malformed cursor → 400.

GET / PATCH / DELETE /posts/{groupId}#

PATCH body (all optional, at least one field):

json
{
  "content": "New shared text",
  "thread": ["Reply 1"],
  "media": [{ "id": "MEDIA_ID" }],
  "tags": ["Launch"],
  "date": "2026-11-03T10:00:00Z",
  "type": "draft",
  "posts": [ { "integration": "CHANNEL_ID", "value": [{ "content": "…" }] } ]
}

type: "draft" unschedules (don't send date with it); type: "schedule" requires date; posts replaces the channel list and per-channel content. Published/publishing posts are locked.

Without posts, a PATCH edits the group's channels as they stand, except posts that were cancelled because their channel was removed or disabled: those stay cancelled and untouched (content, tag and date edits of the rest go through). To drop them from the group, send posts without them; a disabled channel named in posts is refused until it is enabled again. If every editable post of the group is on a removed or disabled channel, the PATCH is refused with 409 conflict.

Without type and date, a PATCH keeps each post's status: drafts stay drafts (a dated draft keeps its time — fixing a typo never queues it), and posts that are scheduled (or failed and waiting to be retried) stay scheduled and are re-validated. Channels added with posts follow the group: they are drafts in a draft group. When the key's member needs approval, editing a scheduled or failed group sends it back for approval at its current time (or at the date you send).

DELETE cancels and removes the group on all channels → { "groupId": "…", "deleted": true }. Already-published posts are not removed from the social networks.

POST /posts/{groupId}/reschedule#

json
{ "date": "2026-11-04T08:30:00+02:00" }

Moves every not-yet-published post of the group.

GET /slots/next#

bash
curl "$APP_URL/api/v1/slots/next?channelIds=CH1,CH2" -H "Authorization: Bearer $API_KEY"
# → { "date": "2026-11-02T07:00:00.000Z", "channelIds": ["CH1","CH2"], "timezone": "Europe/Vilnius" }

POST /media — upload#

Multipart (field file):

bash
curl -X POST $APP_URL/api/v1/media -H "Authorization: Bearer $API_KEY" -F "file=@launch.png"

Agency workspaces can stamp the file with a customer: multipart field customer=<customerId> (or "customer" in the JSON body below). The library, the composer's picker and GET /media?customer= then scope by it. An unknown or foreign customer is a 422.

Or import from a public URL (recommended for large videos — Vercel caps request bodies at ~4.5 MB):

bash
curl -X POST $APP_URL/api/v1/media -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: application/json" -d '{"url":"https://cdn.example.com/launch.mp4"}'

Allowed: JPEG, PNG, GIF, WebP, MP4, MOV, WebM. The type is detected from the file bytes (the filename and declared type are not trusted). URL imports are SSRF-guarded on every redirect and size-capped while downloading (images 20 MB, videos 512 MB; 413 above that, or as soon as a declared Content-Length exceeds it). Large files are streamed straight into storage, never buffered in memory; the whole download must finish within 180 s. Without Vercel Blob storage (local disk), URL imports are capped at 200 MB.

json
{ "id": "…", "url": "https://…/launch.png", "path": "https://…/launch.png", "name": "launch.png",
  "mime": "image/png", "size": 48213, "width": 1200, "height": 630, "duration": null, "alt": null,
  "customerId": null, "createdAt": "…" }

GET /media lists the newest files (limit, default 50, max 100). customer=<customerId> lists that customer's files plus unassigned (shared) ones; add includeUnassigned=false for that customer's files only; customer=unassigned lists only files without a customer.

GET / POST /tags#

bash
curl $APP_URL/api/v1/tags -H "Authorization: Bearer $API_KEY"
# → { "data": [ { "id": "…", "name": "Launch", "color": "#8b5cf6" } ] }

curl -X POST $APP_URL/api/v1/tags -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: application/json" -d '{"name":"Launch","color":"#22d3ee"}'
# → 201 { "id": "…", "name": "Launch", "color": "#22d3ee" }   (200 with the existing tag if the name is taken)

name is 1–40 characters; color is an optional hex color (a palette color is used otherwise). Up to 200 tags per workspace. Pass tag names (or ids) in tags when creating or editing posts.

Customers & client portal (reseller provisioning)#

A customer is one of your clients: a named group of channels. Their client reviewers sign in to the branded portal (your verified custom domain when you have one) and see only that customer's posts: they approve, request changes and comment. Reviewers don't use team seats. Everything here mirrors Channels → Customers and Settings → Branding, with the same rules.

GET /customers and GET /customers/{customerId} work with any key. Every other call needs an admin or owner key (403 otherwise). Ids from another workspace are 404. Creating customers needs the plan feature customers; portal invites need whiteLabel (402 limit_reached otherwise).

bash
# 1. Create the customer (201; 409 if the name is taken, case-insensitive)
curl -X POST $APP_URL/api/v1/customers -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: application/json" -d '{"name":"Bakery Co","color":"#f59e0b"}'
# → 201 { "id": "…", "name": "Bakery Co", "color": "#f59e0b", "channelCount": 0, "createdAt": "…" }

# 2. Put their channels under it (null unassigns)
curl -X POST $APP_URL/api/v1/channels/$CHANNEL_ID/customer -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: application/json" -d '{"customerId":"'$CUSTOMER_ID'"}'
# → 200 the channel, now with "customerId": "…"

# 3. Invite the client's reviewer to the portal
curl -X POST $APP_URL/api/v1/customers/$CUSTOMER_ID/portal-invites -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: application/json" -d '{"email":"owner@bakery.example"}'
json
{
  "id": "…", "email": "owner@bakery.example", "customerId": "…",
  "inviteUrl": "https://portal.youragency.com/invite/…",
  "expiresAt": "2026-11-01T10:00:00.000Z", "createdAt": "…", "emailed": true
}
  • The invite is emailed with your branding. inviteUrl is personal and single-use, valid for 7 days, and must be accepted with the invited email. When emailed is false (no email provider configured), send the link yourself. Treat it like a password reset link.
  • 409 conflict: the person is already on the team, already has access to this customer, or already has a pending invite (revoke it first).
  • GET /customers/{id}/portal-members → { "customerId", "members": [{ userId, name, email, joinedAt }], "pendingInvites": [{ id, email, invitedByName, createdAt, expiresAt, expired }] }.
  • DELETE /customers/{id}/portal-invites/{inviteId} → { customerId, inviteId, revoked: true }; DELETE /customers/{id}/portal-members/{userId} → { customerId, userId, removed: true } (their account stays).
  • PATCH /customers/{id} { name } renames. DELETE /customers/{id} → { id, deleted: true, unassignedChannels }: channels stay connected, reviewers lose access and pending invites stop working.
  • Every write is in the workspace audit log (customer.create / customer.rename / customer.delete, channel.assign_customer, portal_invite.created, plus the team service's client.invite, client.remove, invite.revoke) with meta.via = "api" and the key id.

Channels carry customerId; filter analytics with ?customerId=.

GET /analytics — rollup#

Query (all optional):

ParamMeaning
from, tofirst / last day (inclusive). YYYY-MM-DD is read in the workspace timezone; an ISO timestamp counts as its day there. to defaults to today
dayslast N days including today (1–366, default 30); ignored when from is given. Postiz alias: date
customerIdonly this customer's channels (404 if unknown)
channelIdonly this channel (404 if unknown)

Ranges are at most 366 days; from after to → 400.

bash
curl "$APP_URL/api/v1/analytics?from=2026-10-19&to=2026-10-25" -H "Authorization: Bearer $API_KEY"
json
{
  "range": { "from": "2026-10-19", "to": "2026-10-25", "timezone": "Europe/Vilnius", "days": 7 },
  "scope": { "channelId": null, "customerId": null },
  "totals": {
    "published": 12, "failed": 1, "successRate": 0.923, "previousPublished": 9, "queued": 4,
    "postsWithStats": 10, "impressions": 18400, "views": 5200, "likes": 640, "comments": 58, "shares": 31, "clicks": 212,
    "statsUpdatedAt": "2026-10-25T06:00:00.000Z"
  },
  "channels": [
    { "channelId": "…", "name": "Seraya Studio", "provider": "linkedin-page", "username": "seraya", "removed": false,
      "published": 7, "failed": 0, "successRate": 1, "postsWithStats": 7, "impressions": 12000, "views": 0,
      "likes": 410, "comments": 40, "shares": 22, "clicks": 180 }
  ],
  "series": [
    { "key": "likes", "label": "Likes", "total": 640, "percentageChange": 18.5,
      "data": [ { "date": "2026-10-19", "total": 80 }, { "date": "2026-10-20", "total": 132 }, "…" ] }
  ]
}
  • Totals and per-channel rows come from the same data as the in-app Analytics page.
  • series has one entry per metric — posts (published), impressions, views, likes, comments, shares, clicks — with one zero-filled point per day of the range (days in the workspace timezone). percentageChange compares the range with the equally long period just before it (0 when both are 0, 100 when the previous period had none).
  • Engagement is the latest snapshot per published post (refreshed by the stats job), counted on the day the post was published. Networks that don't report a metric contribute 0. Follower counts are not collected, so there is no followers series.
  • queued is what is waiting to publish right now (not bound to the range). failed counts posts that failed in the range.

GET /analytics/{channelId} — Postiz shape#

Returns just the daily series array for one channel (the Postiz GET /analytics/:integration shape: [{ label, data: [{ total, date }], percentageChange }], plus key and total). Same from / to / days parameters; Postiz's ?date=7 means the last 7 days.


4. MCP server — "Claude, schedule this for me"#

POST $APP_URL/api/mcp speaks the Model Context Protocol over Streamable HTTP (protocol versions 2025-11-25, 2025-06-18, 2025-03-26, 2024-11-05). It is stateless (no session id, no SSE stream) and authenticates with the same API key.

Claude Code#

bash
claude mcp add --transport http kairoset https://app.example.com/api/mcp \
  --header "Authorization: Bearer orb_YOUR_API_KEY"

(The server name is the brand name in lowercase; the Settings page shows the exact command, with the key filled in right after you create one.) Then ask: "Schedule a LinkedIn and X post for Tuesday 9am announcing our new pricing page, with a thread of 3 bullet points."

Claude Desktop / other clients (.mcp.json / claude_desktop_config.json)#

json
{
  "mcpServers": {
    "kairoset": {
      "type": "http",
      "url": "https://app.example.com/api/mcp",
      "headers": { "Authorization": "Bearer orb_YOUR_API_KEY" }
    }
  }
}

Clients that only support stdio can bridge with npx mcp-remote https://app.example.com/api/mcp --header "Authorization: Bearer orb_…".

Tools#

ToolWhat it doesKey inputs
list_channelsconnected channels + workspace timezone—
list_postsposts by time range / status / channels, paginatedfrom, to, status[], channelIds[], limit, cursor
get_postone post group with per-channel statusgroupId
create_postschedule / publish now / draftchannelIds[], content, thread[], media[] ({id} or {url}), type, date, overrides{channelId: {content, thread, settings}}, tags[], automations[], campaign, idempotencyKey
reschedule_postmove a group to a new timegroupId, date
delete_postcancel + delete a groupgroupId
find_next_slotnext free queue slot for channelschannelIds[], after
upload_media_from_urlimport an image/video URL → media idurl, name, customer
get_analyticstotals, per-channel rows and daily series ("how did last week go?")channelId, customerId, from, to (YYYY-MM-DD), days
list_tagsthe workspace's tags (use names in create_post tags)—
create_tagcreate a tag; returns the existing one if the name is taken (created: false)name, color
list_customerscustomers (named channel groups) for analytics filters—
create_customercreate a customer for a new client (admin key)name
assign_channel_to_customerput a channel under a customer, or unassign with null (admin key)channelId, customerId
invite_client_revieweremail a single-use client-portal invite; returns inviteUrl, expiresAt (admin key)customerId, email

Tool results contain the JSON both as structuredContent and as text. Failures (validation, not found, forbidden, provider rules) come back as tool results with isError: true and a readable message so the model can correct itself. Tools carry readOnlyHint/destructiveHint annotations so clients can ask for confirmation before delete_post. The initialize response includes instructions telling the model the workspace timezone and to confirm before type: "now".

Raw protocol (for testing)#

bash
curl -s -X POST $APP_URL/api/mcp \
  -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: application/json" -H "Accept: application/json, text/event-stream" \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"list_channels","arguments":{}}}'

Supported methods: initialize, notifications/* (→ 202), ping, tools/list, tools/call; JSON-RPC batches up to 50 messages. GET/DELETE return 405. Auth failures are plain HTTP 401 with the REST error envelope.

Rate limits on MCP: the HTTP request itself is charged once at auth (an exhausted key gets HTTP 429 before anything runs), and that hit covers the request's first tools/call. Each further tools/call in a batch charges the same per-key bucket; initialize, ping, tools/list and notifications are free. A call over the limit is not run and gets a JSON-RPC error in its slot of the batch response:

json
{ "jsonrpc": "2.0", "id": 7, "error": { "code": -32029, "message": "Rate limit exceeded (…). Retry in 41s.",
  "data": { "code": "rate_limited", "limit": 120, "retryAfterSec": 41 } } }

One request may also run at most 5 expensive calls (upload_media_from_url, create_post with type: "now"); the rest get the same error code with data.code: "batch_limit" and should be sent in another request. The X-RateLimit-* headers of a batch response reflect the bucket after the whole batch.


5. Migrating from Postiz#

  • Replace the base URL with $APP_URL/api/v1 and the key with an orb_… key; Authorization: <key> without Bearer keeps working.
  • POST /posts accepts the Postiz body (type, date, tags, shortLink, posts[].integration, value[], settings, image[]/media[] with id/path).
  • GET /integrations, GET /find-slot/{id}, POST /upload are provided as aliases; GET /analytics/{id} returns the Postiz series array (?date=<days> works).
  • Differences: list endpoint is GET /posts?from&to (Postiz startDate/endDate accepted), responses use the shapes above, errors use the envelope in §2.

6. Outbound webhook events#

Configure endpoints in Settings → Webhooks. Each delivery is a signed POST (HMAC-SHA256 of the raw body, see the settings page for the header names) with { "event": "<name>", "createdAt": "<ISO>", "data": { … } }.

EventWhen
post.scheduled / post.published / post.failed / post.deletedPost lifecycle
post.approval_requested / post.approved / post.rejectedApproval workflow
channel.connected / channel.disconnected / channel.reconnect_requiredChannel lifecycle
channel.expiringDaily (from the token-refresh cron): a channel login expires within 7 days and cannot renew itself

channel.expiring payload:

json
{
  "channel": { "id": "…", "provider": "facebook", "name": "…", "username": "…", "status": "active", "customerId": null },
  "expiresAt": "2026-11-01T09:00:00.000Z",
  "expiresInDays": 3,
  "reason": "This login can't be renewed automatically. Reconnect it before it expires."
}