Everything your assistant can do through the MCP server, as plain HTTP for n8n, Zapier, Make and your own scripts. Same validators, same limits, same receipts: a post that would be refused in the Studio is refused here with the same code.
Base URL https://posteora.com/api/v1
Every request carries your account API key as a Bearer token. The key is on the Account page (sign in first). It is a password: regenerating it signs out every client that holds the old one, including assistants connected over MCP.
curl https://posteora.com/api/v1/accounts \
-H "Authorization: Bearer $POSTEORA_API_KEY"
A missing or wrong key answers 401 with a WWW-Authenticate header. OAuth
access tokens issued by the MCP sign-in work in the same header; they are limited to the scopes
they were granted (accounts:read, posts:read, posts:write).
The API accepts 120 requests a minute per key; over that it answers
429. The MCP server has the same bucket. Plan limits are the ones the Studio applies;
GET /api/v1/limits returns your own.
| Limit | Free | Pro | code |
|---|---|---|---|
| Schedule ahead | 14 days | 90 days | horizon |
| Posts queued at a time | 10 | 300 | queue_cap |
| Posts a month | 10 | no monthly cap, fair use | monthly_cap |
| Posts a day per network | counted in the monthly 10 | x 5, tiktok 3, youtube 3, linkedin 3, pinterest 10, bluesky 10, mastodon 10, discord 20, telegram 20, slack 20, devto 3, hashnode 3, others 5 | daily_cap |
| Same text to the same network | refused within 48 hours | duplicate | |
| Media storage | 25 files, 100 MB, video 50 MB | 500 files, 1024 MB, video 100 MB | storage_cap |
| X posts a day when scheduling | 2 on the same local day (a reply under your own post is exempt) | ||
Images up to 5 MB (JPEG, PNG, WebP, GIF), video up to 100 MB (MP4, MOV), within the plan's storage.
/api/v1/accountsPlatform ids you can target, with the token health from the daily check. needs_reconnect means a post to that network will fail until the owner reconnects on the website.
{
"ok": true,
"accounts": [
{"platform": "x", "display_name": "X / Twitter", "handle": "@example",
"status": "active", "health": "ok", "health_checked_at": "2026-10-18T06:00:00Z",
"max_chars": 280, "media_policy": "optional", "supports_video": true, "supports_threads": true},
{"platform": "bluesky", "display_name": "Bluesky", "handle": "example.bsky.social",
"status": "active", "health": "ok", "health_checked_at": "2026-10-18T06:00:00Z",
"max_chars": 300, "media_policy": "optional", "supports_video": true, "supports_threads": true}
],
"connect_url": "https://posteora.com/dashboard/studio/connections"
}
/api/v1/limits{"ok": true,
"plan": {"plan": "Pro", "schedule_ahead_days": 90, "queue_cap": 300, "monthly_posts": null,
"daily_per_network": {"bluesky": 10, "devto": 3, "discord": 20, "hashnode": 3, "linkedin": 3, "mastodon": 10, "pinterest": 10, "slack": 20, "telegram": 20, "tiktok": 3, "x": 5, "youtube": 3}, "daily_default": 5,
"media_bytes": 1073741824, "media_count": 500, "video_bytes": 104857600,
"ramble_per_day": 10, "duplicate_window_hours": 48},
"rate_limit": {"requests_per_minute": 120, "per": "API key"},
"x_allowance": {"meter": "...", "warning": null}}
/api/v1/mediaThree request shapes, one answer. Multipart with a file field, the raw bytes with their Content-Type, or JSON with a public https URL to fetch.
curl -X POST https://posteora.com/api/v1/media \
-H "Authorization: Bearer $POSTEORA_API_KEY" \
-F "file=@photo.jpg"
curl -X POST https://posteora.com/api/v1/media \
-H "Authorization: Bearer $POSTEORA_API_KEY" \
-H "Content-Type: application/json" \
-d '{"url": "https://example.com/photo.jpg"}'
201 {"ok": true, "media_id": 42, "kind": "image", "mime": "image/jpeg", "bytes": 183921}
Pass the id in media_ids when you create the post. Files are kept about a week after their last send.
/api/v1/posts| Field | Type | Notes |
|---|---|---|
text | string | The post. May be empty only for media-only posts on Pinterest or TikTok. |
targets | array | Platform ids from /accounts, for example ["x", "bluesky"]. Required. |
scheduled_for | string | ISO 8601 with an offset, for example 2026-10-20T09:00:00-07:00. Present: queue it. Absent: publish now. |
media_ids | array of int | From /media. |
media_url | string | One public https image or video; fetched for you. |
thread | array of string | 2 to 25 posts sent as one chained item (scheduling only, networks with supports_threads). Replaces text. |
options | object | Per network. Required for TikTok: {"tiktok": {"privacy_level": "SELF_ONLY", "allow_comment": true, ...}}. YouTube: {"youtube": {"privacy_status": "private"}}. |
reply_to | string | URL or id of one of your own X posts to reply under (X must be the only target). |
draft | boolean | Save as a draft in the Studio instead of queuing. |
Needs an Idempotency-Key header (see idempotency). Answers after every network has answered.
curl -X POST https://posteora.com/api/v1/posts \
-H "Authorization: Bearer $POSTEORA_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: 7c9e6679-7425-40de-944b-e07fc1f90ae7" \
-d '{"text": "Shipped the webhook docs today.", "targets": ["x", "bluesky"]}'
201 {
"ok": true,
"results": {
"x": {"ok": true, "url": "https://x.com/i/web/status/1845000000000000000",
"message": null, "status_label": "published", "record_id": 311},
"bluesky": {"ok": true, "url": "https://bsky.app/profile/example.bsky.social/post/3l5abc",
"message": null, "status_label": "published", "record_id": 312}
},
"x_allowance": {"meter": "19 posts with links or 259 plain posts left", "warning": null},
"receipt": "Published on X and Bluesky. X allowance: 19 posts with links or 259 plain posts left.",
"replayed": false
}
A network that refuses the post is reported in its own entry with "ok": false and a readable message; the others still go out. The HTTP status stays 201 because the request itself was valid.
curl -X POST https://posteora.com/api/v1/posts \
-H "Authorization: Bearer $POSTEORA_API_KEY" \
-H "Content-Type: application/json" \
-d '{"text": "Tuesday thread, part one.", "targets": ["x"],
"scheduled_for": "2026-10-20T09:00:00-07:00", "media_ids": [42]}'
201 {
"ok": true, "post_id": 918, "status": "pending", "status_label": "queued",
"scheduled_for": "2026-10-20T16:00:00Z",
"x_allowance": {"meter": "19 posts with links or 258 plain posts left", "warning": null},
"receipt": "Queued for Tue Oct 20, 09:00 (UTC-07:00) on X. X allowance with it queued: 19 posts with links or 258 plain posts left. It is in your Studio calendar: https://posteora.com/dashboard/studio"
}
/api/v1/posts?status=pending,draft&limit=50{"ok": true, "posts": [
{"post_id": 918, "status": "pending", "status_label": "queued", "scheduled_for": "2026-10-20T16:00:00Z",
"targets": ["x"], "text": "Tuesday thread, part one.", "thread_tweets": null, "has_media": true}
]}
Statuses: draft, pending, claimed (sending), done, partial, failed, cancelled.
/api/v1/posts/{post_id}{"ok": true, "post_id": 918, "status": "done", "status_label": "published",
"scheduled_for": "2026-10-20T16:00:00Z", "last_error": null,
"platforms": [
{"record_id": 320, "platform": "x", "status": "posted", "status_label": "published",
"url": "https://x.com/i/web/status/1845000000000000001", "error": null, "posted_at": "2026-10-20T16:00:04Z"}
]}
/api/v1/posts/{post_id}Cancels a pending, failed or partial post (a draft is deleted). A post already sent cannot be cancelled: 400.
{"ok": true, "post_id": 918, "deleted": false}
/api/v1/activity?status=posted,failed&limit=50One row per network per send, newest first, including posts published right away. This is the polling source when you cannot receive webhooks.
{"ok": true, "items": [
{"record_id": 320, "post_id": 918, "platform": "x", "status": "sent",
"platform_post_id": "1845000000000000001", "url": "https://x.com/i/web/status/1845000000000000001",
"text": "Tuesday thread, part one.", "error_code": null,
"posted_at": "2026-10-20T16:00:04Z", "created_at": "2026-10-20T16:00:04Z"}
]}
Send an Idempotency-Key header (any unique string up to 128 characters, a UUID is
fine) with POST /api/v1/posts. The first request runs; a retry with the same key and
the same account gets the stored answer back with Idempotency-Replayed: true and never
posts twice. A retry that arrives while the first request is still running answers
409 with code in_flight; wait a few seconds and send it again.
Publishing now requires the key (without it: 400, code
idempotency_key_required). Scheduling accepts it. The MCP tool
post.publish has had the same rule as its idempotency_key argument since it
shipped; the Studio's own composer routes honour the header too. Keys are held in server memory, so
the guarantee is exact within a running process and best effort across a restart.
Register up to 3 HTTPS endpoints on the Account page or with the API. Each gets its own secret. Events are queued the moment they happen and sent by the scheduler within about a minute, after the posts of that tick are settled, so a slow endpoint never delays a send.
| Event | When |
|---|---|
post.scheduled | A post entered the queue (schedule, or a draft approved). |
post.sent | One network published it. One event per network, so a post to three networks sends three. |
post.failed | One network refused it, or the scheduler refused it at send time (daily cap, missing TikTok choices). |
post.cancelled | A queued post was cancelled. |
account.needs_reconnect | The daily token check found a connection the network no longer accepts. |
webhook.test | The "Send test event" button or POST /api/v1/webhooks/{id}/test. |
/api/v1/webhookscurl -X POST https://posteora.com/api/v1/webhooks \
-H "Authorization: Bearer $POSTEORA_API_KEY" \
-H "Content-Type: application/json" \
-d '{"url": "https://example.com/hooks/posteora", "events": ["post.sent", "post.failed"]}'
201 {"ok": true, "webhook": {
"id": 7, "url": "https://example.com/hooks/posteora", "events": ["post.sent", "post.failed"],
"active": true, "source": "api", "secret": "whsec_4f1c...e9",
"last_status": null, "last_error": null, "last_attempt_at": null, "last_delivered_at": null,
"created_at": "2026-10-18T12:00:00Z"}}
Omit events to receive everything. GET /api/v1/webhooks lists them with their secrets, DELETE /api/v1/webhooks/{id} removes one, POST /api/v1/webhooks/{id}/test sends a test event and returns the HTTP status your endpoint gave.
A POST with Content-Type: application/json and three headers:
X-Posteora-Signature, X-Posteora-Event (the event name) and
X-Posteora-Delivery (a unique id you can use to drop duplicates). Answer any
2xx within 5 seconds. Anything else is retried: 4
attempts in all, waiting 30 s, then 120 s, then 600 s between them.
Never more than one attempt a second per account. The last status and error of each endpoint are
shown next to it on the Account page and in GET /api/v1/webhooks.
POST /hooks/posteora
Content-Type: application/json
X-Posteora-Signature: t=1760800000,v1=5f1d3c...a9
X-Posteora-Event: post.sent
X-Posteora-Delivery: 1041
{"event": "post.sent", "timestamp": "2026-10-20T16:00:05Z",
"post_id": 918, "record_id": 320, "platform": "x", "targets": null,
"platform_post_id": "1845000000000000001", "url": "https://x.com/i/web/status/1845000000000000001",
"text": "Tuesday thread, part one.", "error_code": null, "error": null, "scheduled_for": null}
{"event": "post.failed", "timestamp": "2026-10-20T16:00:05Z",
"post_id": 919, "record_id": 321, "platform": "bluesky", "targets": null,
"platform_post_id": null, "url": null, "text": "Second try.",
"error_code": "auth_expired", "error": "Connection expired. Reconnect your account.", "scheduled_for": null}
{"event": "post.scheduled", "timestamp": "2026-10-18T12:05:00Z",
"post_id": 918, "record_id": null, "platform": null, "targets": ["x"],
"platform_post_id": null, "url": null, "text": "Tuesday thread, part one.",
"error_code": null, "error": null, "scheduled_for": "2026-10-20T16:00:00Z"}
{"event": "account.needs_reconnect", "timestamp": "2026-10-19T06:00:00Z",
"platform": "linkedin", "handle": "Example Person", "account_id": 12,
"reason": "the network rejected the stored token"}
v1 is the hex HMAC-SHA256 of "<t>.<raw body>" with the webhook's secret.
Compare in constant time and reject a t older than 300 seconds.
Use the raw request body, not a re-serialised one.
# Python (Flask shown; any framework works the same way)
import hmac, hashlib, time
def verify(secret: str, header: str, body: bytes, tolerance: int = 300) -> bool:
parts = dict(p.split("=", 1) for p in header.split(","))
t, given = int(parts["t"]), parts["v1"]
if abs(int(time.time()) - t) > tolerance:
return False
expected = hmac.new(secret.encode(), f"{t}.".encode() + body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, given)
@app.post("/hooks/posteora")
def hook():
if not verify(SECRET, request.headers["X-Posteora-Signature"], request.get_data()):
return "bad signature", 400
event = request.get_json()
...
return "", 204
// Node.js (Express with express.raw so the body stays untouched)
const crypto = require("crypto");
function verify(secret, header, rawBody, tolerance = 300) {
const parts = Object.fromEntries(header.split(",").map((p) => p.split("=")));
const t = parseInt(parts.t, 10);
if (Math.abs(Math.floor(Date.now() / 1000) - t) > tolerance) return false;
const expected = crypto.createHmac("sha256", secret).update(`${t}.`).update(rawBody).digest("hex");
const a = Buffer.from(expected), b = Buffer.from(parts.v1 || "");
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
app.post("/hooks/posteora", express.raw({ type: "application/json" }), (req, res) => {
if (!verify(SECRET, req.get("X-Posteora-Signature") || "", req.body)) return res.status(400).end();
const event = JSON.parse(req.body);
res.status(204).end();
});
n8n-nodes-posteora (Settings, Community Nodes, Install). It has a Posteora node with Create Post, Schedule Post, Upload Media, List Accounts and Get Post Status, and a Posteora Trigger node that registers a webhook for you and checks the signature.Every error is JSON: {"ok": false, "error": "readable message", "code": "optional_code"}.
| Status | Meaning |
|---|---|
400 | Invalid field, or a network's own rule (text too long, media required, connect the network first). |
401 | Missing or revoked key. |
403 | Token lacks the scope, or an Origin header from a site that is not allowed. |
404 | Post or webhook not found on this account. |
409 | in_flight: the same Idempotency-Key is still being processed. |
413 | File too large or storage_cap. |
422 | A plan limit: horizon, queue_cap, daily_cap, monthly_cap, duplicate. |
429 | Over 120 requests a minute for this key. |
Prefer the assistant route? The same functions are exposed as MCP tools at https://posteora.com/mcp;
see the MCP server page.