DevelopersBeta

Schedule posts from your code, or from your AI assistant.

The postyay REST API and MCP server do what the app does: upload media, run the same pre-flight checks, schedule to TikTok and Bluesky, and follow each post until it’s live. All you need is an API key.

BetaThe API and the MCP server are in beta and included in every plan while the beta lasts. The v1 contract only grows: fields are added, never renamed or removed.

Quickstart

Five steps from zero to a published post. Every example uses curl; set your key once with export POSTYAY_API_KEY=pyk_live_….

  1. 1

    Create an API key

    Go to Settings → API keys, give the key a name (e.g. “Zapier” or “Claude”) and copy it. You’ll only see it once; we store just a hash.

  2. 2

    List your connected accounts

    Connect TikTok or Bluesky in the app first. Each account’s id is what you post to; health tells you if it needs reconnecting.

    GET /api/v1/accounts
    curl https://postyay.com/api/v1/accounts \
      -H "Authorization: Bearer $POSTYAY_API_KEY"
  3. 3

    Upload a video or photo

    Send the file as multipart, or give us a public URL and we download it. Keep the returned id. We convert the format for each platform when it publishes.

    POST /api/v1/media
    curl https://postyay.com/api/v1/media \
      -H "Authorization: Bearer $POSTYAY_API_KEY" \
      -F [email protected]
    
    # or let postyay download it
    curl https://postyay.com/api/v1/media \
      -H "Authorization: Bearer $POSTYAY_API_KEY" \
      -H "Content-Type: application/json" \
      -d '{ "url": "https://example.com/clip.mp4" }'
  4. 4

    Schedule the post

    One post, several accounts. TikTok needs settings.privacyLevel. schedule is "now", an ISO 8601 time, or "draft". The same pre-flight checks as the app run first, and anything that would fail comes back as violations.

    POST /api/v1/posts
    curl https://postyay.com/api/v1/posts \
      -H "Authorization: Bearer $POSTYAY_API_KEY" \
      -H "Content-Type: application/json" \
      -H "Idempotency-Key: $(uuidgen)" \
      -d '{
        "text": "Behind the scenes of the shoot #bts",
        "mediaIds": ["MEDIA_ID"],
        "targets": [
          { "accountId": "TIKTOK_ACCOUNT_ID",
            "settings": { "privacyLevel": "PUBLIC_TO_EVERYONE", "allowComment": true } },
          { "accountId": "BLUESKY_ACCOUNT_ID",
            "settings": { "altText": { "MEDIA_ID": "A camera crew on set" } } }
        ],
        "schedule": "now"
      }'
    
    # "schedule": "2026-10-01T09:00:00+03:00" schedules it, "draft" only saves it.
  5. 5

    Follow it until it’s live

    Poll the post every few seconds. When status is published, each target has its live url. TikTok videos can take a few minutes to process.

    GET /api/v1/posts/{id}
    while :; do
      status=$(curl -s https://postyay.com/api/v1/posts/POST_ID \
        -H "Authorization: Bearer $POSTYAY_API_KEY" | jq -r .status)
      echo "$status"
      case "$status" in published|failed|needs_action|canceled) break ;; esac
      sleep 5
    done
    200 OK
    {
      "id": "5b0e…",
      "status": "published",
      "source": "api",
      "targets": [
        { "platform": "tiktok", "status": "published",
          "url": "https://www.tiktok.com/@you/video/7431…" },
        { "platform": "bluesky", "status": "published",
          "url": "https://bsky.app/profile/you.bsky.social/post/3l…" }
      ]
    }

Authentication

Send the key in the Authorization header of every request: Bearer pyk_live_….

  • A key acts as the member who created it, in that workspace. If they leave the workspace, the key stops working.
  • Revoke a key in Settings at any time; it stops working immediately. Last-used times help you spot keys you no longer need.
  • Treat keys like passwords: keep them on your server or in your assistant’s settings, never in a browser, a mobile app or a public repository.

Errors

Every error has the same shape. requestId is also in the x-trace-id header: quote it when you contact support.

400 Bad Request
{
  "code": "PREFLIGHT",
  "message": "TikTok allows 2,200 characters — you're 100 over.",
  "requestId": "3f0c9d2e-…",
  "violations": [
    { "accountId": "…", "platform": "tiktok", "code": "text_too_long",
      "message": "TikTok allows 2,200 characters — you're 100 over.",
      "params": { "max": 2200, "over": 100 },
      "fix": { "mode": "manual", "action": "shorten_text" } }
  ]
}

VALIDATION_ERROR adds details (the fields that are wrong). PREFLIGHT adds violations: one per problem, with the account, the platform and a stable code such as text_too_long or media_required.

401
UNAUTHORIZED
No API key was sent.
401
INVALID_API_KEY
The key is malformed or doesn’t exist.
401
API_KEY_REVOKED
The key was revoked, or its creator left the workspace.
403
PLAN_REQUIRED
The workspace’s plan doesn’t include API access.
400
VALIDATION_ERROR
The request body or query is invalid; see details.
400
PREFLIGHT
The post would fail on a platform; see violations.
400
INVALID_SETTINGS
A target’s platform settings are missing or invalid (e.g. TikTok privacy).
400
PAST_TIME
The scheduled time has already passed.
400
ACCOUNT_NOT_FOUND
An account isn’t connected to this workspace.
400
MEDIA_NOT_FOUND
A media id isn’t in this workspace, or isn’t ready.
400
RECONNECT_REQUIRED
The account needs to be reconnected in the app.
400
URL_NOT_ALLOWED
The media URL points to a private or reserved address.
402
PLAN_LIMIT_POSTS
The plan’s monthly post limit is reached. Drafts still work.
409
NOT_EDITABLE
The post is publishing or published, so it can’t change.
409
IN_FLIGHT
The post is being published right now; try again in a minute.
409
IDEMPOTENCY_KEY_REUSED
This Idempotency-Key was used with a different body.
409
IDEMPOTENCY_IN_PROGRESS
The first request with this Idempotency-Key is still running.
413
TOO_LARGE
The file is over the upload size limit.
415
UNSUPPORTED_MEDIA
Not a video, photo or PDF we accept.
429
RATE_LIMITED
Too many requests; wait Retry-After seconds.

Rate limits

Limits are per key, in one-minute windows: 60 requests a minute, of which at most 10 may create posts. MCP tool calls count against the same budget.

Every response says where you stand with RateLimit-Limit, RateLimit-Remaining, RateLimit-Reset (seconds) and RateLimit-Policy. Over the limit you get 429 RATE_LIMITED with Retry-After.

429 Too Many Requests
RateLimit-Policy: 60;w=60, 10;w=60;comment="posts"
RateLimit-Limit: 60
RateLimit-Remaining: 0
RateLimit-Reset: 23
Retry-After: 23

Posts also count toward your plan’s monthly post limit, exactly like posts made in the app. GET /api/v1/me shows your usage.

Pagination

Lists return { data, nextCursor }, newest first. Pass limit (1–100, default 25) and, for the next page, cursor=nextCursor. nextCursor is null on the last page. GET /api/v1/posts also filters by status, accountId, from and to.

GET /api/v1/posts
curl "https://postyay.com/api/v1/posts?status=scheduled&limit=50" \
  -H "Authorization: Bearer $POSTYAY_API_KEY"

# next page
curl "https://postyay.com/api/v1/posts?status=scheduled&limit=50&cursor=NEXT_CURSOR" \
  -H "Authorization: Bearer $POSTYAY_API_KEY"

Idempotency

Network hiccups happen. Send an Idempotency-Key header (any unique string, e.g. a UUID) with POST /api/v1/posts and retry freely: for 24 hours, the same key with the same body returns the first result with Idempotent-Replayed: true instead of creating a second post. The same key with a different body is refused.

Post lifecycle

A post goes to one or more accounts; each is a target with its own status. The post’s status is the most urgent of its targets’.

draft
Saved, not scheduled.
scheduled
Waiting for its time.
queued
Due; picked up for publishing.
preparing
Media is being converted for the platform.
publishing
Being sent to the platform.
processing
The platform accepted it and is processing it.
published
Live; the target has its url.
needs_action
Someone has to act (e.g. reconnect the account); see error.
failed
Gave up after retries; see error.
canceled
The post was deleted before it published.

MCP server

postyay speaks the Model Context Protocol, so AI assistants can schedule posts for you: “post this clip to TikTok and Bluesky tomorrow at 9”. It uses the same API key, limits and checks as the REST API.

Endpoint

https://postyay.com/mcp

Streamable HTTP (stateless, JSON responses). Authentication: Authorization: Bearer pyk_live_….

Tools

  • list_accountsConnected accounts with their ids and health.
  • upload_media_from_urlDownloads a video, photo or PDF from a public URL.
  • create_postCreates and schedules a post (now, a time, or draft), with pre-flight feedback.
  • schedule_postSchedules an existing draft.
  • list_postsPosts with their status, filtered and paginated.
  • get_post_statusOne post’s delivery timeline and live URLs.
  • reschedule_postMoves a post to a new time.
  • delete_postDeletes a post and cancels what hasn’t published.
  • get_best_timeSuggests a time to post. For now a fixed heuristic (tomorrow 09:00 in your time zone), not audience data, and it says so.

Connect your assistant

Claude Code

One command adds the server with your key:

Terminal
claude mcp add --transport http postyay https://postyay.com/mcp \
  --header "Authorization: Bearer pyk_live_…"

Claude Desktop

Add this to claude_desktop_config.json (Settings → Developer → Edit config) and restart Claude. mcp-remote bridges the remote server and sends your key as a header.

claude_desktop_config.json
{
  "mcpServers": {
    "postyay": {
      "command": "npx",
      "args": ["-y", "mcp-remote", "https://postyay.com/mcp",
               "--header", "Authorization:${POSTYAY_API_KEY}"],
      "env": { "POSTYAY_API_KEY": "Bearer pyk_live_…" }
    }
  }
}

Cursor, VS Code and other clients

Any client that lets you set HTTP headers on a remote MCP server works with the URL and the header:

mcp.json
{
  "mcpServers": {
    "postyay": {
      "url": "https://postyay.com/mcp",
      "headers": { "Authorization": "Bearer pyk_live_…" }
    }
  }
}

ChatGPT and claude.ai (web connectors)

Their custom connectors sign in with OAuth (or connect without any authentication), and can’t send an API key. postyay’s MCP server uses API keys only for now, so these connectors aren’t supported yet. OAuth sign-in is planned. Until then, use Claude Desktop, Claude Code or another client that sends headers.

API reference

Generated from the same schemas the API validates with. Base URL: https://postyay.com/api/v1. Import the OpenAPI document into Postman, Insomnia or your code generator.

get/api/v1/me

Current key, workspace and limits

Responses

  • 200Me · The key and its workspace.
  • 401Missing, unknown or revoked API key.
  • 429Too many requests; wait Retry-After seconds.
get/api/v1/accounts

List connected accounts

Every connected account with its health. Use the id as a post target.

Responses

  • 200AccountList · Connected accounts, oldest first.
  • 401Missing, unknown or revoked API key.
  • 429Too many requests; wait Retry-After seconds.
post/api/v1/media

Upload media

Send the file as multipart/form-data (one file part), or JSON { "url": "https://…" } and we download it. Videos (MP4, MOV, WebM), photos (JPEG, PNG, WebP, GIF, HEIC, AVIF) and PDFs. Conversion for each platform happens at publish time.

Request body

Responses

  • 201Media · The stored media.
  • 400VALIDATION_ERROR (see details), PREFLIGHT (see violations), or another request problem.
  • 401Missing, unknown or revoked API key.
  • 413The file is over the size limit.
  • 415Not a supported file type.
  • 429Too many requests; wait Retry-After seconds.
post/api/v1/media/uploads

Start a direct upload (large files)

For big videos: returns a presigned URL; PUT the file there with the given headers, then call POST /media/{id}/complete. Available when postyay stores media in object storage; otherwise 409 DIRECT_UPLOAD_UNAVAILABLE (use POST /media).

Request body

Responses

  • 201MediaUploadTicket · Where to upload the file.
  • 400VALIDATION_ERROR (see details), PREFLIGHT (see violations), or another request problem.
  • 401Missing, unknown or revoked API key.
  • 409The post’s state doesn’t allow this now, or an idempotent request is still running.
  • 413The file is over the size limit.
  • 415Not a supported file type.
  • 429Too many requests; wait Retry-After seconds.
post/api/v1/media/{id}/complete

Finish a direct upload

Checks what arrived, reads the file’s details and marks it ready. Safe to call again.

Parameters

id *
path
string (uuid)
The media id.

Responses

  • 200Media · The ready media.
  • 400VALIDATION_ERROR (see details), PREFLIGHT (see violations), or another request problem.
  • 401Missing, unknown or revoked API key.
  • 404Not found in this workspace.
  • 409The post’s state doesn’t allow this now, or an idempotent request is still running.
  • 413The file is over the size limit.
  • 429Too many requests; wait Retry-After seconds.
get/api/v1/media/{id}

Get media

Parameters

id *
path
string (uuid)
The media id.

Responses

  • 200Media · The media.
  • 401Missing, unknown or revoked API key.
  • 404Not found in this workspace.
  • 429Too many requests; wait Retry-After seconds.
get/api/v1/posts

List posts

Newest first, paginated with cursor.

Parameters

status
query
"draft" | "scheduled" | "queued" | "preparing" | "publishing" | "processing" | "published" | "needs_action" | "failed" | "canceled"
Posts with at least one target in this status.
accountId
query
string
Posts that go to this account.
from
query
string (date-time)
Scheduled at or after (ISO 8601).
to
query
string (date-time)
Scheduled at or before (ISO 8601).
limit
query
integer
Default: 25.
cursor
query
string
nextCursor from the previous page.

Responses

  • 200PostList · A page of posts.
  • 400VALIDATION_ERROR (see details), PREFLIGHT (see violations), or another request problem.
  • 401Missing, unknown or revoked API key.
  • 429Too many requests; wait Retry-After seconds.
post/api/v1/posts

Create or schedule a post

Validates accounts, media and per-platform settings, runs pre-flight (text length, media type, duration, aspect…) for every target, and schedules it. Problems come back as 400 PREFLIGHT with violations. Send an Idempotency-Key header to retry safely: the same key and body within 24 hours returns the first result (Idempotent-Replayed: true).

Parameters

Idempotency-Key
header
string
Any unique string, e.g. a UUID.

Request body

Responses

  • 201Post · The created post, with its timeline.
  • 400VALIDATION_ERROR (see details), PREFLIGHT (see violations), or another request problem.
  • 401Missing, unknown or revoked API key.
  • 402The plan’s monthly post limit is reached (PLAN_LIMIT_POSTS).
  • 403The key’s role or plan doesn’t allow this.
  • 409The post’s state doesn’t allow this now, or an idempotent request is still running.
  • 429Too many requests; wait Retry-After seconds.
get/api/v1/posts/{id}

Get a post and its status

Each target’s status, timeline and, once published, its live url.

Parameters

id *
path
string (uuid)
The post id.

Responses

  • 200Post · The post.
  • 401Missing, unknown or revoked API key.
  • 404Not found in this workspace.
  • 429Too many requests; wait Retry-After seconds.
patch/api/v1/posts/{id}

Edit or reschedule a post

Send only what changes, e.g. { "schedule": "2026-10-01T09:00:00Z" }. Pre-flight runs again. 409 NOT_EDITABLE once a target is publishing or published.

Parameters

id *
path
string (uuid)
The post id.

Request body

Responses

  • 200Post · The updated post.
  • 400VALIDATION_ERROR (see details), PREFLIGHT (see violations), or another request problem.
  • 401Missing, unknown or revoked API key.
  • 402The plan’s monthly post limit is reached (PLAN_LIMIT_POSTS).
  • 403The key’s role or plan doesn’t allow this.
  • 404Not found in this workspace.
  • 409The post’s state doesn’t allow this now, or an idempotent request is still running.
  • 429Too many requests; wait Retry-After seconds.
delete/api/v1/posts/{id}

Delete a post

Cancels every target that hasn’t published. Already-published posts stay on the platform. 409 IN_FLIGHT while it is publishing.

Parameters

id *
path
string (uuid)
The post id.

Responses

  • 204Deleted.
  • 401Missing, unknown or revoked API key.
  • 403The key’s role or plan doesn’t allow this.
  • 404Not found in this workspace.
  • 409The post’s state doesn’t allow this now, or an idempotent request is still running.
  • 429Too many requests; wait Retry-After seconds.

Objects

MediaFromUrl

url *
string (uri)
A public http(s) URL of a video, photo or PDF. Redirects are followed; private addresses are refused.

MediaUploadInit

width
integer
height
integer
durationSec
number
name
string
Original file name.
mime *
string
The file’s type, e.g. video/mp4.
bytes *
integer
Exact size in bytes; the upload URL only accepts this size.

TikTokSettings

privacyLevel *
"PUBLIC_TO_EVERYONE" | "MUTUAL_FOLLOW_FRIENDS" | "FOLLOWER_OF_CREATOR" | "SELF_ONLY"
Required: TikTok has no default. Must be one the creator allows.
allowComment
boolean
Default: false.
allowDuet
boolean
Default: false.
allowStitch
boolean
Default: false.
disclose
boolean
Commercial content disclosure; needs yourBrand and/or brandedContent. Default: false.
yourBrand
boolean
Default: false.
brandedContent
boolean
Paid partnership. Can’t be combined with SELF_ONLY. Default: false.
isAigc
boolean
The content is AI-generated. Default: false.
coverTimestampMs
integer

BlueskySettings

altText
object
Alt text per attached media id. Default: {}.

TargetInput

accountId *
string
From GET /v1/accounts.
settings
object
Platform options: see TikTokSettings (TikTok needs privacyLevel) and BlueskySettings. Default: {}.
override
object
Different text or title for this account only.
override.text
string
override.title
string

CreatePost

text
string
The caption. Default: "".
title
string
mediaIds
string[]
From POST /v1/media, in order. Default: [].
targets *
schedule *
"now" | "draft" | string (date-time)
"now" publishes within seconds, an ISO 8601 time schedules it, "draft" saves it without scheduling (drafts may be incomplete).

UpdatePost

text
string
title
string | null
null removes it.
mediaIds
string[]
targets
Replaces the whole list.
schedule
"now" | "draft" | string (date-time)
"now" publishes within seconds, an ISO 8601 time schedules it, "draft" saves it without scheduling (drafts may be incomplete).

Violation

accountId *
string
The target account the problem is on.
platform *
"x" | "bluesky" | "threads" | "linkedin" | "facebook" | "instagram" | "youtube" | "tiktok" | "pinterest"
code *
string
Stable machine code, e.g. text_too_long, video_too_long, media_required.
message *
string
English explanation.
mediaId
string
The attached media item the problem is about, when there is one.
params
object
Raw values behind the message (limits, sizes, seconds).
fix
object
How it can be resolved. manual: change the content. confirm: we can fix it in the app (crop, trim) once you agree.
fix.mode *
"confirm" | "manual"
fix.action *
string

Error

code *
string
Stable machine code, e.g. VALIDATION_ERROR, PREFLIGHT, RATE_LIMITED, NOT_FOUND.
message *
string
Human-readable explanation (English).
requestId *
string
Quote this when contacting support; it is also sent as the x-trace-id header.
details
object[]
Field problems, on VALIDATION_ERROR.
violations
Pre-flight problems, on PREFLIGHT.

Account

id *
string (uuid)
platform *
"x" | "bluesky" | "threads" | "linkedin" | "facebook" | "instagram" | "youtube" | "tiktok" | "pinterest"
handle *
string
@username on TikTok and Bluesky.
displayName *
string | null
avatarUrl *
string | null
health *
"healthy" | "expiring" | "reconnect_required" | "limited"
reconnect_required: posts to it can’t publish until it is reconnected in the app.
healthMessage *
string | null
sandbox *
boolean
Connected through the sandbox provider: nothing reaches the real platform.
connectedAt *
string (date-time)

Media

id *
string (uuid)
kind *
"image" | "video" | "document"
mime *
string
bytes *
integer
width *
number | null
height *
number | null
durationSec *
number | null
originalName *
string | null
status *
"uploading" | "processing" | "ready" | "failed"
Only ready media can be attached to a post.

MediaUploadTicket

media *
The pending media (status: "uploading") until you call complete.
upload *
object
upload.url *
string
PUT the raw file bytes here.
upload.method *
"PUT"
upload.headers *
object
Send exactly these headers with the PUT.
upload.expiresAt *
string (date-time)

TimelineEvent

status *
"draft" | "scheduled" | "queued" | "preparing" | "publishing" | "processing" | "published" | "needs_action" | "failed" | "canceled"
message *
string
code *
string | null
at *
string (date-time)

Target

id *
string (uuid)
accountId *
string
platform *
"x" | "bluesky" | "threads" | "linkedin" | "facebook" | "instagram" | "youtube" | "tiktok" | "pinterest"
status *
"draft" | "scheduled" | "queued" | "preparing" | "publishing" | "processing" | "published" | "needs_action" | "failed" | "canceled"
draft → scheduled → queued → preparing → publishing → processing → published; or needs_action / failed / canceled.
scheduledAt *
string (date-time) | null
publishedAt *
string (date-time) | null
url *
string | null
The live post, once published.
error *
object | null
error.code *
string | null
error.message *
string
settings *
object
override *
object | null
override.text
string
override.title
string
timeline
Oldest first. On single-post responses only.

Post

id *
string (uuid)
status *
"draft" | "scheduled" | "queued" | "preparing" | "publishing" | "processing" | "published" | "needs_action" | "failed" | "canceled"
The most urgent of its targets’ statuses (e.g. needs_action beats published).
source *
"web" | "api" | "mcp"
Where it was created.
text *
string
title *
string | null
media *
scheduledAt *
string (date-time) | null
createdAt *
string (date-time)
updatedAt *
string (date-time)
targets *

AccountList

data *
nextCursor *
string | null
Pass as cursor for the next page; null on the last one.

PostList

data *
nextCursor *
string | null
Pass as cursor for the next page; null on the last one.

Me

workspace *
object
workspace.id *
string (uuid)
workspace.name *
string
workspace.plan *
"free" | "creator" | "pro" | "agency"
workspace.timezone *
string
key *
object
key.id *
string (uuid)
key.name *
string
key.prefix *
string
usage *
object
usage.postsThisMonth *
integer
Scheduled or published posts created this UTC month.
usage.postsLimit *
integer | null
null: unlimited.
usage.accounts *
integer
usage.accountsLimit *
integer
rateLimits *
object
rateLimits.requestsPerMinute *
integer
rateLimits.postsPerMinute *
integer
apiAccess *
"beta" | "ga"

BestTime

at *
string (date-time)
timezone *
string
basis *
"heuristic"
explanation *
string