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:00Zor2026-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 (
apiAccessentitlement). When billing is disabled (self-host / white-label), it is always on.
Authentication#
Authorization: Bearer orb_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxAlso 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:
| Header | Meaning |
|---|---|
X-RateLimit-Limit | requests allowed per window |
X-RateLimit-Remaining | requests left in this window |
X-RateLimit-Reset | unix seconds when the window resets |
Retry-After | on 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:
{
"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…"
}
}| Status | code | When |
|---|---|---|
| 400 | bad_request | malformed JSON / multipart, bad Idempotency-Key |
| 400 | validation_error | body or query failed validation (details.issues[].path) |
| 401 | unauthenticated | missing, invalid or revoked key; creator left the workspace |
| 402 | limit_reached | plan doesn't include API access, or a plan limit (posts/month, storage) is hit |
| 403 | forbidden | key's role too low (writes need editor) |
| 404 | not_found | post group, channel or media not in this workspace |
| 405 | method_not_allowed | wrong HTTP method (Allow header lists the right ones) |
| 409 | conflict / idempotency_in_progress | state conflict / same Idempotency-Key still running |
| 413 | payload_too_large | body or file too big |
| 415 | unsupported_media_type | file type not allowed, or wrong Content-Type |
| 422 | invalid | semantically invalid: past date, provider rule (length, media), unknown tag (details.fieldErrors) |
| 422 | idempotency_key_reused | same Idempotency-Key with a different body |
| 429 | rate_limited | see Retry-After |
| 500 | internal | our fault — retry, then contact support with the requestId |
| 503 | unavailable | a dependency isn't configured on this instance (e.g. media storage); the message says what to set |
3. Endpoints#
| Method | Path | Role | Description |
|---|---|---|---|
| GET | /me | viewer | workspace, plan, key info, role, rate limit |
| GET | /channels | viewer | connected channels |
| GET | /posts | viewer | list posts (one row per channel), filters + cursor pagination |
| POST | /posts | editor | create (schedule / now / draft), Postiz-compatible, Idempotency-Key |
| GET | /posts/{groupId} | viewer | one post group with per-channel status |
| PATCH | /posts/{groupId} | editor | edit content, thread, media, tags, channels, time, or unschedule |
| DELETE | /posts/{groupId} | editor | cancel + delete on all channels |
| POST | /posts/{groupId}/reschedule | editor | move unpublished posts to { date } |
| GET | /slots/next?channelIds=a,b&after=ISO | viewer | next free queue slot shared by the channels |
| GET | /media?limit=50 | viewer | media library, newest first |
| POST | /media | editor | upload (multipart file) or import from URL (JSON { url, name? }) |
| GET | /tags | viewer | the workspace's post tags |
| POST | /tags | editor | create a tag { name, color? } (returns the existing tag of that name) |
| GET | /customers | viewer | customers (agency mode: named groups of channels) |
| POST | /customers | admin | create a customer { name, color? } (color: hex like #8b5cf6) |
| GET | /customers/{customerId} | viewer | one customer with its channel count |
| PATCH | /customers/{customerId} | admin | rename / recolor { name?, color? } (color: null clears it) |
| DELETE | /customers/{customerId} | admin | delete; channels stay connected (unassigned), portal access is revoked |
| POST | /customers/{customerId}/portal-invites | admin | invite a client reviewer { email } → { inviteUrl, expiresAt, … } |
| DELETE | /customers/{customerId}/portal-invites/{inviteId} | admin | revoke a pending invite |
| GET | /customers/{customerId}/portal-members | admin | reviewers with access + pending invites |
| DELETE | /customers/{customerId}/portal-members/{userId} | admin | revoke a reviewer's portal access |
| POST | /channels/{channelId}/customer | admin | assign a channel { customerId } (null unassigns) |
| GET | /analytics?from&to&days&customerId&channelId | viewer | totals, per-channel rows and daily series (workspace, customer or channel) |
| GET | /analytics/{channelId}?from&to&days | viewer | daily series of one channel (Postiz shape; ?date=<days> accepted) |
| GET | /openapi.json | public | OpenAPI 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#
curl $APP_URL/api/v1/me -H "Authorization: Bearer $API_KEY"{
"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#
{
"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:
{
"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 atdate, or at the next free queue slot whendateis 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 leftscheduledfor 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": "…" }]fromPOST /media, or[{ "url": "https://…" }](imported into the library on the fly;pathis accepted as an alias). Optionalalt. Media is only supported onvalue[0]. settings: provider-specific options (subreddit/title for Reddit, board for Pinterest, privacy for TikTok/YouTube…).__typeis 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
datemore than 5 minutes in the past → 422 (usetype: "now"). shortLink: trueshortens links when the instance has a shortener configured (Bitly or Dub; otherwise links are kept as written).utmPresetIdtags links with one of the workspace's UTM presets. Both are also accepted byPATCH /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 a422and nothing is created.
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}):
{
"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.
curl "$APP_URL/api/v1/posts?status=scheduled,failed&from=2026-11-01T00:00:00Z&limit=20" -H "Authorization: Bearer $API_KEY"{ "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):
{
"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#
{ "date": "2026-11-04T08:30:00+02:00" }Moves every not-yet-published post of the group.
GET /slots/next#
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):
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):
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.
{ "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#
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).
# 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"}'{
"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.
inviteUrlis personal and single-use, valid for 7 days, and must be accepted with the invited email. Whenemailedisfalse(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'sclient.invite,client.remove,invite.revoke) withmeta.via = "api"and the key id.
Channels carry customerId; filter analytics with ?customerId=.
GET /analytics — rollup#
Query (all optional):
| Param | Meaning |
|---|---|
from, to | first / last day (inclusive). YYYY-MM-DD is read in the workspace timezone; an ISO timestamp counts as its day there. to defaults to today |
days | last N days including today (1–366, default 30); ignored when from is given. Postiz alias: date |
customerId | only this customer's channels (404 if unknown) |
channelId | only this channel (404 if unknown) |
Ranges are at most 366 days; from after to → 400.
curl "$APP_URL/api/v1/analytics?from=2026-10-19&to=2026-10-25" -H "Authorization: Bearer $API_KEY"{
"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.
serieshas 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).percentageChangecompares 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.
queuedis what is waiting to publish right now (not bound to the range).failedcounts 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#
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)#
{
"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#
| Tool | What it does | Key inputs |
|---|---|---|
list_channels | connected channels + workspace timezone | — |
list_posts | posts by time range / status / channels, paginated | from, to, status[], channelIds[], limit, cursor |
get_post | one post group with per-channel status | groupId |
create_post | schedule / publish now / draft | channelIds[], content, thread[], media[] ({id} or {url}), type, date, overrides{channelId: {content, thread, settings}}, tags[], automations[], campaign, idempotencyKey |
reschedule_post | move a group to a new time | groupId, date |
delete_post | cancel + delete a group | groupId |
find_next_slot | next free queue slot for channels | channelIds[], after |
upload_media_from_url | import an image/video URL → media id | url, name, customer |
get_analytics | totals, per-channel rows and daily series ("how did last week go?") | channelId, customerId, from, to (YYYY-MM-DD), days |
list_tags | the workspace's tags (use names in create_post tags) | — |
create_tag | create a tag; returns the existing one if the name is taken (created: false) | name, color |
list_customers | customers (named channel groups) for analytics filters | — |
create_customer | create a customer for a new client (admin key) | name |
assign_channel_to_customer | put a channel under a customer, or unassign with null (admin key) | channelId, customerId |
invite_client_reviewer | email 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)#
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:
{ "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/v1and the key with anorb_…key;Authorization: <key>withoutBearerkeeps working. POST /postsaccepts the Postiz body (type,date,tags,shortLink,posts[].integration,value[],settings,image[]/media[]withid/path).GET /integrations,GET /find-slot/{id},POST /uploadare provided as aliases;GET /analytics/{id}returns the Postiz series array (?date=<days>works).- Differences: list endpoint is
GET /posts?from&to(PostizstartDate/endDateaccepted), 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": { … } }.
| Event | When |
|---|---|
post.scheduled / post.published / post.failed / post.deleted | Post lifecycle |
post.approval_requested / post.approved / post.rejected | Approval workflow |
channel.connected / channel.disconnected / channel.reconnect_required | Channel lifecycle |
channel.expiring | Daily (from the token-refresh cron): a channel login expires within 7 days and cannot renew itself |
channel.expiring payload:
{
"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."
}