Threads
Create multi-post threads that publish as connected replies on X, Bluesky, Threads, Mastodon, and Telegram, or as standalone posts on LinkedIn, Instagram, and Facebook.
Create a Thread
Threads are arrays of 2-25 posts published in order as connected replies. Each post in the thread replies to the previous one. Threads work on X, Bluesky, Threads, Mastodon, and Telegram (native reply threading). On LinkedIn, Instagram, and Facebook, each post in the thread is published as a standalone post (these platforms do not support native threading). More platforms are coming soon.
Platform threading behavior:
- X/Twitter: Native threading via reply chains
- Bluesky: Native threading via reply chains
- Threads (Meta): Native threading via
reply_to_id - Mastodon: Native threading via
in_reply_to_id - Telegram: Native threading via
reply_to_message_id - LinkedIn: Each post is published as a standalone post (no native threading support)
- Instagram: Each post is published as a standalone post (no native threading support)
- Facebook: Each post is published as a standalone post (no native threading support)
A thread is all or nothing
If any part of a thread fails to publish, every part that had already published is deleted from the platform and the whole thread's quota is refunded. That holds on every path a thread publishes by: immediately, through the scheduler, when a draft is promoted, and for each account of a multi-account publish. Where a thread degrades to standalone posts, the same rule covers the standalone posts made so far, on LinkedIn and Facebook. Instagram depends on how the account was connected: an account connected via Facebook rolls back like any other, and an account connected through Instagram cannot, so a failed thread there leaves every part it had already published live, and names them.
The error says what it removed, in error.thread_rollback, and the code tells
you whether a retry is safe:
502 upstream_error:still_liveis empty. Nothing of the thread is on the platform, so repeating the same request is safe.502 thread_rollback_incomplete: Chirpie could not remove everything. The posts instill_liveare really still up, with their ids and public URLs. Repeating the request would publish them a second time, so remove them first, or post only the remaining parts.
rollback_supported: false means Chirpie cannot delete a published post for
that account, so nothing could be removed. On Instagram that is the case
for an account connected through Instagram: such a thread leaves every part it
had already published live, and says which. An Instagram account connected via
Facebook rolls back. See the delete table.
A scheduled thread that fails is retried three times before Chirpie gives up. The one exception is a rollback that left posts live: that thread fails at once, because a retry would publish them twice, and the reason names them.
The full response shape, with examples: A thread that failed part-way.
POST /api/v1/threads
Authorization: Bearer chirpie_sk_YOUR_KEY
Idempotency-Key: 8f1c0e0c-6d51-4d73-9f4e-6b2a0a2d3f11
Content-Type: application/jsonHeaders
| Header | Required | Description |
|---|---|---|
Authorization | Yes | Bearer chirpie_sk_.... The key needs the posts:write scope |
Content-Type | Yes | application/json |
Idempotency-Key | No | Makes a retry of this exact request safe for 24 hours. The same key with the same body replays the original response with Idempotent-Replay: true instead of publishing the thread again. Max 255 characters. See Idempotency |
A thread is the request most worth sending a key with: it is the longest-running call in the API, so it is the one most likely to time out on your side after Chirpie has already started publishing.
Request Body
| Field | Type | Required | Description |
|---|---|---|---|
account_id | string (UUID) | Yes, unless account_ids is given | ID of the connected account. Note: LinkedIn, Instagram, and Facebook degrade to standalone posts. |
account_ids | string[] (UUID) | Yes, unless account_id is given | 1 to 25 connected accounts to publish the thread to in one request, given instead of account_id. Duplicates are refused. Naming it selects the fan-out response shape, even for a single account. See Post to several accounts at once |
account_configurations | object | No | Per-account overrides, keyed by account id. Only with account_ids, and every key must be in it. Each value takes posts, which replaces the whole array for that account (still 2-25 parts, or 1-25 when draft is true), first_comment, which replaces the shared one for that account ("" publishes that account with none), and configuration, which replaces the whole publishing-options block for that account. See Post to several accounts at once |
posts | array | Yes | Array of 2-25 post objects, or 1-25 when draft is true |
posts[].text | string | Yes, unless draft is true | Post content. Max length depends on platform: 280 (X standard), 25,000 (X Premium), 300 (Bluesky), 3,000 (LinkedIn), 500 (Threads), 500 (Mastodon), 2,200 (Instagram), 63,206 (Facebook), or 4,096 (Telegram, 1,024 when the post carries media) |
posts[].media | object[] | No | Uploaded files and public links for this post, each with optional alt text. See Media |
posts[].media_ids | string[] | No | Ids from an upload, when no alt text is needed |
posts[].media_urls | string[] | No | Public image/video URLs per post. X: up to 4 images or 1 video. Bluesky: up to 4 images (977 KB each), no video. LinkedIn: up to 4 images (JPEG/PNG/GIF, 8 MB max), no video. Threads: 1 image, no video. Mastodon: up to 4 images or 1 video. Instagram: requires images, up to 10 (carousel), no video. Facebook: up to 10 images, no video. Telegram: up to 10 images, or 1 video. Anything the platform cannot take is refused with 400 unsupported_media, never dropped. |
first_comment | string | No | One comment to publish under the thread the moment it goes out, under the last part. X, Threads, Instagram and Facebook only. It counts as one post against your monthly quota. See First comment on a thread |
configuration | object | No | Per-platform publishing options for every part of the thread, keyed by platform. A thread publishes to the feed, so an Instagram story or reel, and a Facebook Page story, are refused: each is a single post. See Publishing options on a thread |
schedule_at | string (ISO 8601) | No | Schedule the entire thread. Either an absolute instant carrying a timezone (...Z, +02:00, -05:00), normalized to UTC, or a local time with no offset (2026-11-01T09:30:00), read in timezone or the timezone saved on your account. A local time with neither, or a relative offset like +30m, is rejected. On a draft thread it is only the time you have in mind. |
timezone | string (IANA) | No | The timezone a schedule_at with no offset is read in, such as America/New_York. Daylight saving is worked out for the date you named. Ignored when schedule_at already carries an offset, but still validated. A fixed offset like +02:00 is not accepted here. See Timezones |
draft | boolean | No | Save the thread without sending it. Every part is stored as a draft, nothing reaches the platform, and nothing counts against your quota. See Draft threads |
Only the fields above are accepted. Any other top-level field returns
400 bad_request rather than being ignored. In particular the request field is
schedule_at. Sending scheduled_at (the field name in the response) is
rejected with Unknown field 'scheduled_at'. Did you mean 'schedule_at'?. If
you are round-tripping a post object you read back from the API, send only the
fields listed above. group_id, which every returned thread and post carries,
is one of the fields that cannot be sent back: it is set by naming
account_ids, never by hand.
First comment on a thread
first_comment on a thread is one comment for the whole thread, published
under the last part, and reported on that part's first_comment. It is the
same field, and the same rules, as a first comment on a post:
X, Threads, Instagram and Facebook only, refused with
400 first_comment_unsupported anywhere else, and counted as one post against
your monthly quota.
curl -X POST https://chirpie.ai/api/v1/threads \
-H "Authorization: Bearer chirpie_sk_YOUR_KEY" \
-H "Content-Type: application/json" \
-d '{
"account_id": "550e8400-e29b-41d4-a716-446655440000",
"posts": [
{ "text": "We rebuilt scheduling this week." },
{ "text": "Queued posts now publish within five minutes of their time." }
],
"first_comment": "Full write-up: https://example.com/blog/scheduling"
}'Every other part comes back with first_comment: null. A failed first comment
never fails its thread: the thread publishes, the last part carries
first_comment.status: "failed" with a reason, and
POST /api/v1/posts/:id/first-comment
on that part sends it again.
Publishing options on a thread
configuration works on a thread exactly as it does on a
post, with one extra rule: a thread
publishes to the feed. An Instagram story or reel, and a Facebook Page story,
are each a single post, so a thread naming either placement is refused with
400 configuration_unsupported telling you to send it with
POST /api/v1/posts instead. Everything else a block carries (an Instagram
collaborator, a user tag, a Facebook link) applies to every part alike.
Publishing to several accounts
Name account_ids instead of account_id and one request publishes the thread
to up to 25 accounts. An account configuration takes posts, which replaces
the whole array for that account:
curl -X POST https://chirpie.ai/api/v1/threads \
-H "Authorization: Bearer chirpie_sk_YOUR_KEY" \
-H "Content-Type: application/json" \
-d '{
"account_ids": ["ACCOUNT_A", "ACCOUNT_B"],
"posts": [{ "text": "one" }, { "text": "two" }],
"account_configurations": {
"ACCOUNT_B": { "posts": [{ "text": "a" }, { "text": "b" }, { "text": "c" }] }
}
}'The response is a group_id plus one result per account, in the order the
accounts were named, 201 when every account succeeded and 207 Multi-Status
when at least one did not. Each result entry carries thread_id and thread
where a post fan-out carries post_id and post, and platform_post_url is
the first part's permalink.
{
"data": {
"group_id": "7c9e6679-7425-40de-944b-e07fc1f90ae7",
"results": [
{
"account_id": "ACCOUNT_A",
"platform": "x",
"success": true,
"thread_id": "thread-uuid-here",
"platform_post_url": "https://x.com/chirpie_ai/status/1234567890",
"status": "published",
"thread": { "id": "thread-uuid-here" },
"error": null
},
{
"account_id": "ACCOUNT_B",
"platform": "bluesky",
"success": false,
"thread_id": null,
"platform_post_url": null,
"status": null,
"thread": null,
"error": { "code": "upstream_error", "message": "Bluesky API error: ..." }
}
]
}
}Full detail, including what is refused whole versus per account and how quota is reserved and refunded: Post to several accounts at once.
Example
curl -X POST https://chirpie.ai/api/v1/threads \
-H "Authorization: Bearer chirpie_sk_YOUR_KEY" \
-H "Content-Type: application/json" \
-d '{
"account_id": "550e8400-e29b-41d4-a716-446655440000",
"posts": [
{ "text": "I just shipped a feature using Chirpie. Here'\''s how it went 🧵" },
{ "text": "First, I connected my X account via OAuth. Took about 30 seconds." },
{ "text": "Then I created an API key and made my first POST request. Done." },
{ "text": "The whole thing took under 2 minutes. Try it: chirpie.ai" }
]
}'Response 201 Created
{
"data": {
"id": "thread-uuid-here",
"platform": "x",
"account_id": "550e8400-e29b-41d4-a716-446655440000",
"posts": [
{
"id": "post-1-uuid",
"text": "I just shipped a feature using Chirpie...",
"platform_post_id": "1234567890",
"platform_post_url": "https://x.com/chirpie_ai/status/1234567890",
"status": "published",
"thread_order": 0,
"created_at": "2026-03-23T10:00:00.000Z"
},
{
"id": "post-2-uuid",
"text": "First, I connected my X account...",
"platform_post_id": "1234567891",
"platform_post_url": "https://x.com/chirpie_ai/status/1234567891",
"status": "published",
"thread_order": 1,
"created_at": "2026-03-23T10:00:00.000Z"
}
],
"status": "published",
"scheduled_at": null,
"group_id": null,
"created_at": "2026-03-23T10:00:00.000Z"
}
}group_id is the fan-out this thread belongs to when it was created by naming
account_ids, and null when it was created with a single account_id. Pass
it to GET /api/v1/posts?group_id=... to read every post of the fan-out back.
Each post in the response carries platform_post_url, its own public link on
the platform, every part of the thread included. See
platform_post_url for the link each
platform gives and the few cases where it is null.
Billing
A thread counts as N posts where N is the number of posts in the thread. Both post and scheduled limits are checked against the thread size.
A draft thread counts as zero. Nothing is charged while it sits saved, and the whole thread's quota is taken at the moment it is scheduled or published.
A first comment is a post on the platform too, so a thread that carries one counts as N + 1.
Error Responses
| Status | Code | Condition |
|---|---|---|
400 | bad_request | Less than 2 or more than 25 posts (1 to 25 when draft is true), invalid text, a missing required field (posts[0].text is required, which a draft may leave out), a schedule_at that is a local time with no timezone on the request and none saved on the account, a relative offset, or schedule_at in the past (a draft's remembered time may be any time) |
400 | bad_request | On a multi-account request: more than 25 ids, a duplicate id in account_ids, an account_configurations key that is not in account_ids, or an override posts array outside 2-25 parts (1-25 when draft is true). Nothing is published |
401 | unauthorized | Invalid or missing API key |
400 | first_comment_unsupported | first_comment was sent to an account whose platform has no first comment. The message names the platform and the four that take one. On a fan-out, give that account "first_comment": "". Nothing is published |
400 | configuration_unsupported | A configuration block on a platform that takes none, on a platform no account in the request uses, a field the placement does not carry, or an Instagram story or reel or a Facebook Page story, each of which is a single post. Nothing is published |
402 | x_link_posts_require_paid_plan | A post in the thread contains a link, the account is X, and the plan is Free |
404 | not_found | Account not found or inactive |
403 | insufficient_scope | The API key does not hold posts:write. See Scopes |
409 | idempotency_in_progress | An identical request with the same Idempotency-Key is still running. Retry for the replay |
422 | idempotency_key_reused | The same Idempotency-Key was used for a different request |
429 | rate_limited | The burst limit on your API key, which is your plan's ceiling. Carries Retry-After. See Rate limits |
429 | usage_limit_exceeded | The thread would cross your monthly post quota (Free plan) or your scheduled-post quota. The message names the arithmetic. Waiting does not help, so there is no Retry-After |
502 | upstream_error | Platform API error during publishing. The thread is all or nothing: every post already published is deleted from the platform, no rows are kept, and the whole thread's quota is refunded. error.thread_rollback names what was removed |
502 | thread_rollback_incomplete | The same, except Chirpie could not remove every post it had already published. error.thread_rollback.still_live names the ones that are still up, with their ids and URLs. Do not retry blindly: that would publish them twice |
On a multi-account request, a validation failure names the account. Every
account's thread is checked before anything publishes, so a part over one
platform's limit, unsupported media, a first_comment on an account that
cannot take one (give that account "first_comment": ""), a publishing option
a thread cannot carry, or an X link post on
Free refuses the
whole request with its usual status and code, and the message is prefixed
Account <id>: . Nothing is published and no quota is taken. Quota is reserved
once for the group, one unit per part per account, and an account whose thread
fails to publish gives back exactly its own share. A first comment is one unit
more, per account that actually sends one, taken when the comment goes out
rather than with the group's reservation, and given back if it does not land. A platform failure for one
account comes back as 207 Multi-Status with that account's results[].error
set, and the other accounts stay published.
X link posts: Each post in an X thread whose text contains a link is billed at $0.25 on paid plans, on top of
your monthly allowance, because X charges API operators $0.20 per post containing a URL. On the Free plan
the whole thread is rejected with 402 x_link_posts_require_paid_plan before any quota is consumed.
Exempt: X accounts connected with your own X API credentials, and every other platform.
Scale and Enterprise run on custom quotas rather than per-post overage. Talk to us at hey@chirpie.ai.
Scheduled Threads
When schedule_at is provided, all posts in the thread are stored with status: "scheduled". The entire thread is published atomically by the scheduler, so if any post fails, the whole thread is rescheduled for retry.
Draft threads
Send draft: true and the thread is saved without going anywhere. Every part is
stored with status: "draft", nothing reaches the platform, and nothing counts
against your quota until the thread is promoted.
A draft thread is held to far less than a real one: it may be a single part long
while you are still writing it, and a part may have no text yet. The response
carries a warnings array saying, per account, what would stop it publishing as
it stands, including whether the platform will publish the parts as standalone
posts rather than a reply chain.
curl -X POST https://chirpie.ai/api/v1/threads \
-H "Authorization: Bearer chirpie_sk_YOUR_KEY" \
-H "Content-Type: application/json" \
-d '{
"account_id": "550e8400-e29b-41d4-a716-446655440000",
"posts": [{ "text": "Opening line, rest to come" }],
"draft": true
}'Promote it with PATCH /api/v1/posts/:id on any of its parts: schedule_at
queues the whole thread, publish: true sends all of it now, and the response
names every part it promoted in promoted_from_draft_ids. Promotion runs every
rule a create runs, the two-post minimum included. Deleting any part of a draft
thread deletes the whole thread, and the response lists the ids in
deleted_ids, and a draft thread saved to several accounts goes in one call
too. Full detail: Drafts.
Cancelling a Scheduled Thread
A scheduled thread is cancelled as a unit, exactly as it is published as one. Cancelling one post in a scheduled thread cancels the thread. DELETE /api/v1/posts/:id on any post of the thread cancels every post in it, and returns their ids:
curl -X DELETE https://chirpie.ai/api/v1/posts/b2c3d4e5-... \
-H "Authorization: Bearer chirpie_sk_YOUR_KEY"{
"data": {
"id": "b2c3d4e5-...",
"deleted": true,
"cancelled_ids": ["a1b2c3d4-...", "b2c3d4e5-...", "c3d4e5f6-..."]
}
}Nothing is billed for a thread that never published, and the whole thread's monthly quota is returned. To change one post of a scheduled thread, cancel the thread and schedule a new one.