docs / api

The JSON API and webhooks

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

Authentication

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).

Rate limits and plan limits

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.

LimitFreeProcode
Schedule ahead14 days90 dayshorizon
Posts queued at a time10300queue_cap
Posts a month10no monthly cap, fair usemonthly_cap
Posts a day per networkcounted 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 5daily_cap
Same text to the same networkrefused within 48 hoursduplicate
Media storage25 files, 100 MB, video 50 MB 500 files, 1024 MB, video 100 MBstorage_cap
X posts a day when scheduling2 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.

Connected networks

GET/api/v1/accounts

Platform 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"
}
GET/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}}

Upload media

POST/api/v1/media

Three 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.

Publish now or schedule

POST/api/v1/posts
FieldTypeNotes
textstringThe post. May be empty only for media-only posts on Pinterest or TikTok.
targetsarrayPlatform ids from /accounts, for example ["x", "bluesky"]. Required.
scheduled_forstringISO 8601 with an offset, for example 2026-10-20T09:00:00-07:00. Present: queue it. Absent: publish now.
media_idsarray of intFrom /media.
media_urlstringOne public https image or video; fetched for you.
threadarray of string2 to 25 posts sent as one chained item (scheduling only, networks with supports_threads). Replaces text.
optionsobjectPer network. Required for TikTok: {"tiktok": {"privacy_level": "SELF_ONLY", "allow_comment": true, ...}}. YouTube: {"youtube": {"privacy_status": "private"}}.
reply_tostringURL or id of one of your own X posts to reply under (X must be the only target).
draftbooleanSave as a draft in the Studio instead of queuing.

Publish now

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.

Schedule

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"
}

Queue, status, cancel, activity

GET/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.

GET/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"}
 ]}
DELETE/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}
GET/api/v1/activity?status=posted,failed&limit=50

One 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"}
]}

Idempotency

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.

Webhooks

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.

EventWhen
post.scheduledA post entered the queue (schedule, or a draft approved).
post.sentOne network published it. One event per network, so a post to three networks sends three.
post.failedOne network refused it, or the scheduler refused it at send time (daily cap, missing TikTok choices).
post.cancelledA queued post was cancelled.
account.needs_reconnectThe daily token check found a connection the network no longer accepts.
webhook.testThe "Send test event" button or POST /api/v1/webhooks/{id}/test.
POST/api/v1/webhooks
curl -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.

Delivery

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"}

Verify the signature

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, Zapier, Make

Errors

Every error is JSON: {"ok": false, "error": "readable message", "code": "optional_code"}.

StatusMeaning
400Invalid field, or a network's own rule (text too long, media required, connect the network first).
401Missing or revoked key.
403Token lacks the scope, or an Origin header from a site that is not allowed.
404Post or webhook not found on this account.
409in_flight: the same Idempotency-Key is still being processed.
413File too large or storage_cap.
422A plan limit: horizon, queue_cap, daily_cap, monthly_cap, duplicate.
429Over 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.