ShapelessAI← Back
Publishing

Posts

Create a post, schedule it, drop it in the queue, attach media, add a first comment, and read every refusal the rail can answer with.

Verified
View as Markdown

One route puts a post on a real account: POST /api/posts. It takes the post, checks it against the platform's rules before it accepts it, and either publishes it inline or puts it on the schedule.

The body

{
  "connectionId": "<id>",          // required - which connected account
  "postText": "...",               // required - the post body
  "mediaKeys": ["..."],            // optional - media this account owns
  "documentTitle": "...",          // the post's title - YouTube requires one, LinkedIn PDFs use it
  "scheduledAt": "2026-09-20T09:00:00Z",  // optional - omitted or past = publish now
  "queue": true,                   // optional - instead of scheduledAt: the next free slot
  "settings": { },                 // optional - per-platform, see below
  "firstComment": "..."            // optional - posted as the first comment/reply
}

Needs the publish scope. connectionId comes from GET /api/connections.

The three timings

Now. Omit scheduledAt, or send one in the past. The route publishes inline and answers with the live URL, so you can print it.

curl -s https://shapelessai.com/api/posts \
  -H "Authorization: Bearer $SHAPELESS_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"connectionId":"c_123","postText":"Shipped."}'
# {"id":"p_456","status":"published","publishedLocation":"https://www.linkedin.com/feed/update/..."}
shapeless posts create --to c_123 --text "Shipped."

At a time. Send scheduledAt as an ISO 8601 instant. Use an offset or Z; a bare local datetime is ambiguous and we will read it as UTC.

curl -s https://shapelessai.com/api/posts \
  -H "Authorization: Bearer $SHAPELESS_API_KEY" -H "Content-Type: application/json" \
  -d '{"connectionId":"c_123","postText":"Tuesday thought.","scheduledAt":"2026-09-22T07:00:00Z"}'
# {"id":"p_457","status":"scheduled","scheduledAt":"2026-09-22T07:00:00.000Z"}
shapeless posts create --to c_123 --text "Tuesday thought." --at 2026-09-22T07:00:00Z

In the queue. Send queue: true instead of scheduledAt and the post takes the next free slot on that connection's posting times. Nothing collides: a slot another post already holds is skipped. Sending both is a 400; a connection whose every slot is taken for the next two months is a 409.

shapeless posts create --to c_123 --text "Next one out." --queue

Read and edit the slots per connection:

curl -s https://shapelessai.com/api/connections/c_123/queue -H "Authorization: Bearer $SHAPELESS_API_KEY"
# {"slots":["09:00","13:00","17:00"],"timezone":"UTC","next":"2026-09-15T09:00:00.000Z"}

curl -s -X PATCH https://shapelessai.com/api/connections/c_123/queue \
  -H "Authorization: Bearer $SHAPELESS_API_KEY" -H "Content-Type: application/json" \
  -d '{"slots":["08:30","16:00"],"timezone":"Europe/Berlin"}'

GET needs read, PATCH needs write, and both answer the same shape. An account nobody has configured starts on ["09:00", "13:00", "17:00"] UTC, so the queue works before anyone touches it. Slots are 1 to 12 HH:MM times, resolved through the IANA zone on each day, so they keep meaning across daylight saving. next is null when every slot is taken for two months.

The answer

{
  "id": "p_456",
  "status": "scheduled" | "published" | "failed" | "handoff",
  "scheduledAt": "2026-09-22T07:00:00.000Z",
  "publishedLocation": "https://...",   // present when it went out
  "error": "..."                        // present when it did not
}

A row read back with GET /api/posts/[id] also carries firstComment, firstCommentUrl (the comment's own link once it landed) and firstCommentError (why it did not). The post going out and its first comment landing are two events, so they are two fields.

handoff means the platform has no sanctioned API path for this post, so it is waiting for you in the studio with everything prepared. It is not a failure and not a retry.

Poll one row with GET /api/posts/[id], or the queue with GET /api/posts?status=scheduled.

Media

mediaKeys are keys this account already owns - what an earlier job rendered, a brand asset under workspace-assets/, or something you uploaded. A key belonging to another account is a 400 naming it, never a post that publishes with no media.

shapeless assets upload ./carousel.pdf     # prints the media key
shapeless posts create --to c_linkedin --text "Ten lessons." \
  --media workspace-assets/<account>/carousel.pdf --title "Ten lessons from a year of shipping"

Kind comes from the extension, the same rule the engine uses: .pdf is a document, .mp4/.mov/.webm/.m4v a video, everything else an image. documentTitle names a LinkedIn PDF carousel. Counts and formats per platform are on Platforms; breaking one is a 422 before the row is ever created, never a post that dies at publish time.

First comment

firstComment posts a second message under the first, from the same account, as soon as the post lands. It is how a link gets shared without the link sitting in the body.

PlatformShapeLimit
linkedin, linkedin_pagea comment1,250 characters
xa reply in the thread280 characters
blueskya reply in the thread300 characters

Any other platform is a 422 at creation - never a post that publishes and then quietly drops the comment. PATCH /api/posts/[id] accepts firstComment too; null clears it. Whether it landed is on the row afterwards as firstCommentUrl or firstCommentError.

shapeless posts create --to c_linkedin --text "We wrote up what we learned." \
  --first-comment "Full write-up: https://shapelessai.com/notes"

Per-platform settings

settings is a free-form object passed through to the platform adapter. GET /api/platforms carries a JSON Schema per platform, so an agent can fill it without hard-coding.

TikTok requires an explicit choice before anything may queue - TikTok's own content-sharing rules, and we refuse rather than guess:

{
  "privacyLevel": "PUBLIC_TO_EVERYONE",  // or MUTUAL_FOLLOW_FRIENDS, FOLLOWER_OF_CREATOR, SELF_ONLY
  "disableComment": false,
  "disableDuet": false,
  "disableStitch": false,
  "commercialContent": false,            // the disclosure toggle
  "brandOrganic": false,                 // "your brand" - promotional content
  "brandContent": false,                 // a paid partnership
  "isAigc": true                         // AI-generated; our posts default this on
}

YouTube: {"privacyStatus": "public"} (or unlisted, private). The video's title is not a setting - it is documentTitle, and YouTube requires one. titleRequired in GET /api/platforms is the field that says so.

Reddit: {"subreddit": "startups", "title": "...", "url": "..."}. One post goes to exactly one community: whatever arrives is sanitized down to a single subreddit name, so a comma-separated list can never fan out. url set makes it a link post; without an explicit title a text post takes its first line.

Everything else takes no settings today, and settingsSchema says so with an empty object schema rather than an absent field.

Editing and cancelling

curl -s -X PATCH https://shapelessai.com/api/posts/p_457 \
  -H "Authorization: Bearer $SHAPELESS_API_KEY" -H "Content-Type: application/json" \
  -d '{"postText":"Tuesday thought, shorter.","scheduledAt":"2026-09-22T08:00:00Z"}'

curl -s -X DELETE https://shapelessai.com/api/posts/p_457 -H "Authorization: Bearer $SHAPELESS_API_KEY"

PATCH needs write and works while the post is still scheduled. It also accepts connectionId to move the post to another account on the same platform; a different platform is a 409, because the rules it was checked against no longer hold.

Errors

Every refusal is a status and a message that names the rule. None of them are worth retrying unchanged.

StatusCode / shapeWhat happened
400{error}Bad body: missing connectionId or postText, oversized text, a media key this account does not own, or scheduledAt and queue together.
401{error}Unknown or revoked key.
403{error}The key is missing the publish scope.
402{error, code: "free_daily_cap", limit: 10, day, resetsAt}A Free account already has 10 posts going out on that UTC day.
409{error}queue: true with every slot taken for two months, or a PATCH moving the post to a different platform.
422{error}A platform rule: too many images, images and a video together, a missing video, a first comment on a platform that has none, TikTok without its choices. The message names the rule.
429{error}Over the rate limit. See Authentication.
503{error}The service has no database.

Worked examples:

// 402
{"code":"free_daily_cap",
 "error":"The Free plan posts 10 a day and 2026-09-22 (UTC) is full. Pick another day, or upgrade for unlimited posts.",
 "limit":10,"day":"2026-09-22","resetsAt":"2026-09-23T00:00:00.000Z"}

// 422
{"error":"Takes at most 4 images (this post has 7)."}
{"error":"Needs a video before it can publish."}
{"error":"Takes images OR a video in one post, never both - drop one kind."}
{"error":"instagram has no first comment: it is supported on LinkedIn, X and Bluesky."}

The Free day

The cap counts the UTC day a post goes out on, not the day you created it. So a week planned in advance is five a day, not five in total, and the refusal names the day that is full rather than telling you to come back later. day is that date; resetsAt is its UTC midnight end.

Everything that puts a post on the rail is counted the same way - your own POST /api/posts and approving a proposal both land a post on a day - so an agent cannot route around the cap by proposing first. Paid plans have no cap.

The 402 is the one to build for. It is not an error in your code: it is the plan. Catch the code, move the post to a day with room, or tell the human the account is on Free.

The other half of the queue

The agent team also proposes posts. Those arrive as status: "proposed" and never go out on their own:

shapeless posts list --status proposed
shapeless posts approve <id>      # proposed -> scheduled   [publish]
shapeless posts dismiss <id>
shapeless posts publish <id>      # out, now                [publish]

Approving counts against the Free day the post lands on, exactly as creating one does. See Jobs for where proposals come from.