Publish and schedule posts
Create drafts, threads and posts with media, then schedule them for a time or publish them now.
One endpoint creates every post. What you send decides what happens to it:
| You send | Postel |
|---|---|
Neither scheduledAt nor publishNow | Saves a draft. Works on every plan. |
scheduledAt | Saves the post and schedules it. Needs a paid plan. |
publishNow: true | Saves the post and publishes it within a minute. Needs a paid plan. |
You can also save a draft first and schedule or publish it later by its id.
Networks
| Network | platform | Characters per post | Threads | Media per post |
|---|---|---|---|---|
| X | x | 280 per part, or 25,000 if the connected X account has Premium | Up to 25 parts | Up to 4 images, or 1 video, or 1 GIF |
linkedin | 3,000 | No | Up to 4 images, or 1 video | |
| Bluesky | bluesky | 300 | Up to 25 parts | Up to 4 images |
| Mastodon | mastodon | 500 | Up to 25 parts | Up to 4 images, or 1 video, or 1 GIF |
GET /v1/channels shows which of these are connected in your workspace. It can also list networks the API doesn't post to yet, such as YouTube and TikTok, which need video settings the API can't set. Post to those from the Postel app.
Postel checks the length of every part before it saves anything. A part over the limit gets 400 text_too_long, and the message says which part and by how much. Bluesky counts characters the way people see them, so an emoji counts as one.
Create a post
| Field | Type | Description |
|---|---|---|
platform | string | Required. x, linkedin, bluesky or mastodon. |
text | string | The post. Send text or thread, not both. |
thread | string[] | A thread, one item per part, 1 to 25 items. |
mediaUrls | string[] | Up to 4 public image or video URLs. Postel downloads them and attaches them to the first part. |
firstComment | string | LinkedIn only. A comment Postel posts under the post right after it goes out. Up to 1,250 characters. |
scheduledAt | string | When to publish. ISO 8601 with a timezone, in the future. |
publishNow | boolean | Publish within a minute. Don't send it with scheduledAt. |
It answers 201 with the post. Send an Idempotency-Key so a retried request can't create the post twice.
curl https://www.postel.app/api/v1/posts \
-H "Authorization: Bearer $POSTEL_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: launch-post-2026-11-04" \
-d '{
"platform": "linkedin",
"text": "Our public API is live. Here is what we built and why.",
"firstComment": "Docs: https://www.postel.app/docs",
"scheduledAt": "2026-12-02T09:30:00+01:00"
}'Threads
On X, Bluesky and Mastodon you can write a thread in two ways.
Send thread with one item per part:
{
"platform": "x",
"thread": [
"Most launch posts fail for one reason.",
"They explain the product before the problem.",
"Start with the problem. The product is the second post."
]
}Or send text with a line that holds only --- between parts, the same way the Postel editor works:
{
"platform": "x",
"text": "Most launch posts fail for one reason.\n---\nThey explain the product before the problem."
}With thread, a --- line inside a part stays as text and doesn't split it. Each part has its own character limit. On X, that is 280 per part unless the connected X account has Premium.
LinkedIn has no threads. A thread with more than one part for LinkedIn gets 400 invalid_request, and a --- line in LinkedIn text stays as text.
The response shows the thread both ways: thread holds the parts, and text holds the whole post with the parts joined by a blank line.
Media
mediaUrls takes up to 4 public http or https URLs. Postel downloads each file before it saves the post and attaches them to the first part of a thread.
- Postel reads the file type from the file itself, not from the URL or the
Content-Typeheader. - The URL must answer within 30 seconds, without a login.
- One video or one GIF stands alone. You can't mix it with other files in one post.
| Network | File types | Largest file |
|---|---|---|
| X | JPEG, PNG, WebP, GIF, MP4, MOV, WebM | Images 5 MB, GIFs 15 MB, videos 100 MB |
| JPEG, PNG, GIF, MP4 | Images and GIFs 10 MB, videos 100 MB | |
| Bluesky | JPEG, PNG, WebP | Images 1 MB |
| Mastodon | JPEG, PNG, WebP, GIF, MP4, MOV, WebM | Images and GIFs 16 MB, videos 99 MB |
On LinkedIn a GIF posts as an image. A URL Postel can't use gets 400 invalid_request, and error starts with that URL and says why.
LinkedIn first comment
firstComment posts a comment under your LinkedIn post right after it goes out. Use it for a link you want to keep out of the post itself. It takes up to 1,250 characters. On any other network it gets 400 invalid_request.
Schedule a draft
| Field | Type | Description |
|---|---|---|
scheduledAt | string | Required. ISO 8601 with a timezone, in the future. |
firstComment | string | LinkedIn only. Replaces the saved first comment. An empty string removes it. |
It answers 200 with the post, now scheduled. Only drafts can be scheduled. Anything else gets 409 not_a_draft.
Times and timezones
scheduledAt needs a timezone, either Z or an offset: 2026-12-01T08:30:00Z and 2026-12-01T09:30:00+01:00 are the same moment. A time without one gets 400 invalid_request. Postel returns every time in UTC.
GET /v1/me returns the workspace's timezone if you want to schedule in the user's local time.
If the workspace has Vary posting times a little turned on in its posting schedule, a post goes out up to 4 minutes before or after scheduledAt.
One post per network per moment
Two posts on the same network can't be scheduled for the exact same time. The second one gets 409 time_taken. Posts on different networks can share a time.
Publish now
Send publishNow: true when you create a post, or publish an existing draft:
Publishing doesn't happen inside your request. Postel queues the post and answers right away with status scheduled and a message. POST /v1/posts/{id}/publish answers 202, and POST /v1/posts with publishNow answers 201. The post goes out within a minute.
This way a slow network never makes your request time out, and a retried request can't post twice: the second call gets 409 not_a_draft because the post is already on its way.
To learn how it went, poll post.published and post.failed events, or read the post with GET /v1/posts/{id} until its status is published or it is back to draft with a failure.
Publishing now is refused with 409 approval_required when the workspace needs posts approved. Schedule the post instead. See Approvals and plans.
When publishing fails
If the network refuses a post, Postel moves it back to draft and fills in failure:
{
"status": "draft",
"failure": {
"message": "Postel's connection to X has expired. Reconnect it in Settings → Channels.",
"type": "auth_expired",
"at": "2026-12-01T08:30:04.512Z",
"partlyPublished": false
}
}failure.type is one of auth_expired, invalid_content, rate_limited or platform_error, and new types can appear. Fix the cause, then schedule or publish the draft again.
A partly published thread
Sometimes the first parts of a thread go out and a later part is refused. Then the post stays published, url links to the first part, and failure.partlyPublished is true. It also fires a post.failed event.
Never resend a partly published thread. The first part is already live, and sending it again posts it twice. Finish the thread by hand on the network.
Read posts
Returns one post, or 404 not_found if there is no post with that id in the workspace.
Lists posts newest first. Filter with status and platform, and page with cursor. See Pagination.
The post object
| Field | Type | Description |
|---|---|---|
id | string | Postel's id for the post. Stable. |
platform | string | The network, like x or linkedin. |
status | string | draft, scheduled, published or pending_approval. |
text | string | The whole post as plain text. Thread parts are joined by a blank line. |
thread | string[] | Each part in order. One item for a single post. |
firstComment | string or null | LinkedIn first comment. |
mediaCount | integer | How many images or videos are attached. |
scheduledAt | string or null | When it is scheduled to go out, UTC ISO 8601. |
publishedAt | string or null | When it went out, UTC ISO 8601. |
url | string or null | Link to the published post on its network. |
failure | object or null | Why the last publish failed. See When publishing fails. |
createdAt | string | UTC ISO 8601. |
updatedAt | string | UTC ISO 8601. |
Responses from creating, scheduling and publishing can also include message, a sentence about what happens next, for example that the post waits for approval.