Posts
Create, save as a draft, list, retrieve, edit, and delete posts via the Chirpie API, and publish a first comment under a post.
Create a Post
Posts are published immediately by default. Add schedule_at to schedule for later.
POST /api/v1/posts
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 again. Max 255 characters. See Idempotency |
Request Body
| Field | Type | Required | Description |
|---|---|---|---|
account_id | string (UUID) | Yes, unless account_ids is given | ID of the connected account (X, Bluesky, LinkedIn, Threads, Mastodon, Instagram, Facebook, or Telegram) |
account_ids | string[] (UUID) | Yes, unless account_id is given | 1 to 25 connected accounts to publish to in one request, given instead of account_id. Duplicates are refused. Naming it selects the fan-out response shape below, 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 text, first_comment ("" publishes that account with none), configuration (replacing the whole block for that account), and one of media, media_ids, media_urls. See Post to several accounts at once |
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) |
media | object[] | No | Uploaded files and public links, each with optional alt text. An entry carries either id (from an upload) or url. See Media |
media_ids | string[] | No | Ids from an upload, when no alt text is needed |
media_urls | string[] | No | Public image/video URLs (up to 4 on most platforms, 10 on Instagram, Facebook and Telegram, 1 on Threads; see Media below) |
first_comment | string | No | A comment to publish under the post the moment it goes out. X, Threads, Instagram and Facebook only. It counts as one post against your monthly quota. See First comment |
configuration | object | No | Per-platform publishing options, keyed by platform. Instagram takes a feed post, a story or a reel; a Facebook Page takes a feed post or a story. Leave it out and everything publishes to the feed. See Publishing options |
schedule_at | string (ISO 8601) | No | Schedule for a future time. Either an absolute instant carrying a timezone (2027-04-01T14:00:00Z, 2027-04-01T16:00:00+02:00, 2027-04-01T09:00: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 it is only the time you have in mind, so it may be any time at all |
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 post without sending it. Nothing reaches the platform, nothing is queued, and nothing counts against your quota. See Drafts |
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 post carries, is one of
the fields that cannot be sent back: it is set by naming account_ids, never
by hand.
Publishing to several accounts
Name account_ids instead of account_id and one request publishes to up to
25 accounts, each with optional per-account text and media in
account_configurations. The response is a group_id plus one result per
account, 201 when every account succeeded and 207 Multi-Status when at
least one did not.
curl -X POST https://chirpie.ai/api/v1/posts \
-H "Authorization: Bearer chirpie_sk_YOUR_KEY" \
-H "Content-Type: application/json" \
-d '{
"account_ids": ["ACCOUNT_A", "ACCOUNT_B"],
"text": "Shipping new features today!",
"account_configurations": {
"ACCOUNT_B": { "text": "Shipping new features today. Write-up on the blog." }
}
}'{
"data": {
"group_id": "7c9e6679-7425-40de-944b-e07fc1f90ae7",
"results": [
{
"account_id": "ACCOUNT_A",
"platform": "x",
"success": true,
"post_id": "a1b2c3d4-...",
"platform_post_url": "https://x.com/chirpie_ai/status/1234567890",
"status": "published",
"post": { "id": "a1b2c3d4-..." },
"error": null
},
{
"account_id": "ACCOUNT_B",
"platform": "bluesky",
"success": false,
"post_id": null,
"platform_post_url": null,
"status": null,
"post": null,
"error": { "code": "upstream_error", "message": "Bluesky API error: ..." }
}
]
}
}Full detail, including how media overrides replace rather than merge, what is refused whole versus per account, and how quota is reserved and refunded: Post to several accounts at once.
First comment
first_comment publishes one comment under the post the moment the post goes
out: the "link in the first comment" pattern. The comment is posted by the same
account as a reply to the post, is recorded as one of your own comments, and
appears in the post's comment thread with own: true.
curl -X POST https://chirpie.ai/api/v1/posts \
-H "Authorization: Bearer chirpie_sk_YOUR_KEY" \
-H "Content-Type: application/json" \
-d '{
"account_id": "550e8400-e29b-41d4-a716-446655440000",
"text": "We rebuilt scheduling this week.",
"first_comment": "Full write-up: https://example.com/blog/scheduling"
}'Which platforms take one
| Platform | First comment |
|---|---|
| X/Twitter | Yes |
| Threads | Yes |
| Yes | |
| Yes | |
| Bluesky, LinkedIn, Mastodon, Telegram | No |
Sending first_comment to any other platform is refused with
400 first_comment_unsupported, naming the platform that refused and the four
that take one. It is never silently dropped.
Length, and what it costs
The character limit is the account's own post limit: 280 on standard X, 25,000 on X Premium, 500 on Threads, 2,200 on Instagram, 63,206 on Facebook.
A first comment is a post on the platform, so it counts as one post against
your monthly quota, exactly as a comment reply does. On
X, a first comment whose text contains a link carries the same
link-post charge a link post
does: $0.25 on paid plans, and on the Free plan the request is rejected with
402 x_link_posts_require_paid_plan before the post is published, so nothing
goes out. X accounts connected with your own X API credentials are exempt.
Where else it can be sent
POST /api/v1/threads: one comment for the whole thread, published under the last part and reported on that part.- Fan-out requests: a top-level
first_commentapplies to every account. An entry inaccount_configurationsmay replace it with its own, or set it to""so that account publishes with no first comment, which is how one request reaches an account whose platform has none. See Post to several accounts at once. - Drafts: stored with the draft and carried into the post when the draft is
promoted. See
warnings. PATCH /api/v1/posts/:id: replaces the first comment, and the empty string""removes it.- Scheduled posts: the first comment stays
pendinguntil the post publishes. Both go out at the scheduled time, the post first.
What comes back
Every post payload carries first_comment: null when none was asked for, and
otherwise an object.
{
"first_comment": {
"text": "Full write-up: https://example.com",
"status": "posted",
"comment_id": "6f1c2d3e-4a5b-6c7d-8e9f-0a1b2c3d4e5f",
"error": null
}
}| Field | Meaning |
|---|---|
text | The comment as it will be, or was, published |
status | pending, posted or failed. pending means the post has not published yet, or the comment is on its way |
comment_id | The comment's id in the post's comment thread, so it can be read, hidden or deleted through the Comments API like any other comment. Null before the comment is posted, and, rarely, on a posted comment Chirpie could not record: the comment is live on the platform, and the next comment refresh picks it up |
error | A worded reason when status is failed, and null otherwise |
A failed first comment never fails its post. The post publishes as normal,
first_comment.status is failed, and error says why. Send it again with
POST /api/v1/posts/:id/first-comment.
Publishing options
configuration says how a platform should publish this post, keyed by platform
slug. One post can mean different things on different platforms: an Instagram
account takes the same picture as a feed post, a story or a reel, and a
Facebook Page takes it as a feed post or a story. Every other platform
publishes one way and takes no configuration block.
{
"account_id": "550e8400-e29b-41d4-a716-446655440000",
"text": "Behind the scenes of this week.",
"media_urls": ["https://example.com/clip.mp4"],
"configuration": {
"instagram": { "placement": "reel", "share_to_feed": true }
}
}| Platform | Placements | Options |
|---|---|---|
feed (default), story, reel | collaborators, user_tags, cover, video_cover_timestamp_ms, share_to_feed, trial_reel | |
feed (default), story | link |
Each platform's placements, the options each one carries, the media rules per placement and what can and cannot be deleted: Instagram publishing options and Facebook publishing options.
Four rules apply to every block:
- Nothing is dropped quietly. A block keyed on a platform that takes no
options, on a platform no account in the request uses, or a field the chosen
placement does not carry, is refused with
400 configuration_unsupportednaming the platform and the field. Nothing is published. - One block per platform, not per account. A top-level
configurationapplies to every account of that platform in the request. An entry inaccount_configurationsreplaces the whole block for one account, rather than merging into it. - A story or a reel is a single post. A thread asking for either placement
is refused; send it with
POST /api/v1/posts. - A story carries no caption and no first comment. Send the post with empty
text, and leave
first_commentout.
The placement decides the media rules too, so a story is held to a story's
limits rather than the feed's. On a PATCH, configuration: {}
puts a post back to a plain feed post.
Media Support
Pass public URLs in media_urls, or upload the files first and name them in
media. Chirpie downloads, validates, and uploads them to the target platform
automatically. Use one of media, media_ids and media_urls, not several:
sending two is refused with 400 bad_request. Full detail, including alt text
and the upload endpoint: Media.
| Platform | Images | Max Image Size | Max Images | Video | Max Video Size |
|---|---|---|---|---|---|
| X/Twitter | JPEG, PNG, WebP, GIF | 5 MB | 4 | MP4, MOV | 512 MB (1 per post) |
| Bluesky | JPEG, PNG, WebP, GIF | 977 KB | 4 | Not supported | n/a |
| JPEG, PNG, GIF | 8 MB | 4 | Not supported | n/a | |
| Threads | JPEG, PNG, WebP, GIF | 5 MB | 1 | Not supported | n/a |
| Mastodon | JPEG, PNG, WebP, GIF | 8 MB | 4 | MP4, MOV | 40 MB (1 per post) |
| JPEG, PNG | 8 MB | 10 (carousel) | Not supported | n/a | |
| JPEG, PNG, GIF | 10 MB | 10 | Not supported | n/a | |
| Telegram | JPEG, PNG, GIF, WebP | 5 MB | 10 | MP4 | 20 MB (1 per post) |
More platforms coming soon.
A Mastodon video may take a moment. Mastodon transcodes video after it is
uploaded, so Chirpie waits for the instance to finish before publishing the status.
That can add a few seconds to the request. If the instance is still working after a
minute, the call returns 502 upstream_error saying the instance is still
processing the video, and nothing is published. Send the same request again.
Images publish immediately.
Media is never dropped silently. Anything the target platform cannot accept is
refused with 400 unsupported_media and nothing is posted, so a post either carries
every item in media_urls or does not publish at all. Sending a video to Bluesky,
LinkedIn, Threads, Instagram or Facebook comes back as
Bluesky does not support video. Remove the video or post it to X, Mastodon or Telegram.
The same applies to a format the platform cannot decode (WebP on LinkedIn) and to a
file over its size limit.
Describe your media. Every entry in media takes an alt string, which is
sent to X, Bluesky, LinkedIn, Mastodon, Instagram and Facebook. Leave it out and
no description is sent: nothing is invented for you. See
Media for the per-platform limits.
Other media requirements: a video is always posted on its own, never alongside images. Threads takes 1 image per post. Instagram requires media on every post, so text-only posts are not supported: a feed post takes up to 10 images as a carousel, and a story takes one image or one video, and a reel takes one video. Facebook supports Pages only (not personal profiles).
X media needs its own permission. If an X post with media_urls comes back with
Reconnect your X account to upload media., X has not granted Chirpie permission to
upload on that account. Reconnect it from
Accounts and the post will go through. Text
posts are unaffected either way.
For scheduled posts, media is downloaded, validated and stored immediately, since the URLs may expire before publish time. That means an unusable file is reported when you schedule the post, not silently at publish time. The stored media is uploaded to the platform when the post publishes and cleaned up afterwards.
Example
curl -X POST https://chirpie.ai/api/v1/posts \
-H "Authorization: Bearer chirpie_sk_YOUR_KEY" \
-H "Content-Type: application/json" \
-d '{
"account_id": "550e8400-e29b-41d4-a716-446655440000",
"text": "Shipping new features today!",
"media_urls": ["https://example.com/screenshot.png"],
"schedule_at": "2026-04-01T14:00:00Z"
}'Response 201 Created
{
"data": {
"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"platform": "x",
"account_id": "550e8400-e29b-41d4-a716-446655440000",
"text": "Shipping new features today!",
"media_urls": null,
"first_comment": null,
"platform_post_id": null,
"platform_post_url": null,
"status": "scheduled",
"scheduled_at": "2026-04-01T14:00:00.000Z",
"published_at": null,
"thread_id": null,
"thread_order": null,
"group_id": null,
"error_message": null,
"retry_count": 0,
"created_at": "2026-03-23T10:00:00.000Z"
}
}group_id on a returned post
Every post object carries group_id: the fan-out it belongs to when it was
created by naming account_ids, and null when it was created with a single
account_id. Pass it to List Posts to read every post of the
same fan-out back. Thread objects carry it too.
account_id on a returned post
Every post object carries account_id, the account it went out through. It is
null when that account has since been
disconnected: disconnecting removes the
connection but keeps everything the account published, so an old post outlives the
account it belongs to. platform still says where the post went.
On a request body account_id is always required; only a returned post can carry
null.
platform_post_url
Every post object (create, get, and list) carries platform_post_url: the public
link to the post on the platform. It is null until the post is published.
| Platform | Link |
|---|---|
| X | https://x.com/<username>/status/<id> |
| Bluesky | https://bsky.app/profile/<handle>/post/<rkey> |
| Mastodon | <instance>/@<user>/<id> |
https://www.linkedin.com/feed/update/<urn>/ | |
https://www.facebook.com/<pageId>_<postId> | |
| Telegram | https://t.me/<channel>/<id>, for public @channel chats only |
| Threads | The link Threads gives for the post, such as https://www.threads.com/@<username>/post/<code> |
The link Instagram gives for the post, such as https://www.instagram.com/p/<code>/ or /reel/<code>/ |
Most platforms' links are built from platform_post_id. Threads and Instagram
answer a publish with an internal media id that no public link can be built
from, so Chirpie asks the platform for the post's own link the moment it
publishes and keeps it. That covers every part of a thread, carousels, and
reels.
Two cases can still leave it null on Threads or Instagram:
- The platform gave no link. Instagram may return none for a story, and omits it for media it has flagged for copyrighted material. A story is only visible for 24 hours in any case.
- The link could not be read when the post went out. The post is published
all the same, since a missing link never fails a post. The next
GET /api/v1/posts/{id}asks the platform once more and keeps the answer, so from then on every listing carries it too. Listings never ask the platform themselves.
A Telegram message in a private chat or a group with no public @name has no
public link, and stays null.
error_message and retry_count
Every post object carries both, so a post that is not where you expected it can say why without a second request.
Value of error_message | What it means |
|---|---|
null | Nothing went wrong |
Any other sentence, with status: "failed" | The platform's own refusal, after Chirpie ran out of retries |
"account_disconnected" | Cancelled because the account was disconnected |
"account_deactivated" | Cancelled because the account was switched off |
"connection_lost" | Cancelled because the platform stopped accepting the account's credential |
The last three come with status: "deleted": Chirpie cancelled the post rather
than you deleting it, and the monthly quota it had reserved was returned. The
account's inactive_reason says the same
thing from the other side. See When a connection dies.
retry_count is how many publish attempts have been made. Chirpie retries a
failing scheduled post three times before giving up, so a post still
scheduled with a non-zero retry_count has already missed at least one
attempt and is waiting to try again.
Comments on a post
Once a post is published, the comments it receives are available through the
Comments API, keyed on the post's id:
GET /api/v1/posts/:id/comments. From there you can reply to a comment, hide it, or
delete it, on the platforms that allow each. A reply counts as one post against your
monthly quota.
A first comment is one of those comments: it is listed with
the rest, carries own: true, and its comment_id is the id you hide or
delete it by.
Error Responses
| Status | Code | Condition |
|---|---|---|
400 | bad_request | A missing required field (account_id and text are required), an unknown top-level field, text over the account's limit, too many media_urls for the platform, a schedule_at with no timezone anywhere to read it in, a timezone that is not an IANA name, a relative offset, a past time, or a time within 5 minutes of another scheduled post on the same account |
400 | unsupported_media | A media_urls item the platform cannot accept: a video where it takes images only, a format it cannot decode, or a file over its size limit. Nothing is posted |
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 posted |
400 | configuration_unsupported | A configuration block on a platform that takes none, on a platform no account in the request uses, or a field the chosen placement does not carry. Also a story or a reel with text or a first_comment. The message names the field and what to do. Nothing is posted |
400 | bad_request | On a multi-account request: more than 25 ids, a duplicate id in account_ids, or an account_configurations key that is not in account_ids. Nothing is posted |
400 | bad_request | A group_id on List Posts that is not a UUID |
401 | unauthorized | Invalid or missing API key |
402 | x_link_posts_require_paid_plan | X post contains a link and the account is on the Free plan |
404 | not_found | Account not found or inactive |
403 | insufficient_scope | The API key does not hold posts:write, or posts:read on a read. 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 | Your monthly post quota, or your monthly scheduled-post quota, is spent. Waiting does not help, so there is no Retry-After |
502 | upstream_error | Platform API returned an error |
On a multi-account request, a validation failure names the account. Every
account's body is checked before anything publishes, so a text 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 placement does not 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. Only the platform
call itself fails per account, and that comes back as 207 Multi-Status with
the failure in results[].error.
X link posts: X charges API operators $0.20 for every post containing a URL. On paid plans, an X post whose text
contains a link (an http(s):// URL or a bare domain X would auto-link, such as chirpie.ai) is
billed at $0.25, on top of your monthly allowance. On the Free plan the request is rejected with
402 x_link_posts_require_paid_plan.
Exempt: X accounts connected with your own X API credentials carry no surcharge, and you pay X directly at your own rate. Posts to every other connected platform are never surcharged.
Scale and Enterprise run on custom quotas rather than per-post overage. Talk to us at hey@chirpie.ai.
Drafts
Send draft: true on a create call and Chirpie saves the post and does nothing
else. Nothing reaches the platform, nothing is queued, and nothing is charged: a
draft counts against neither your monthly post quota nor your scheduled post
quota until you promote it.
Because a draft is work in progress, the usual checks are relaxed. text may be
empty or left out, media may be missing even on a platform that normally
requires it, a draft thread may be a single part where a real thread needs at
least two, and schedule_at is only the time you have in mind: it is remembered,
it is not a promise to publish, and it may already have passed.
What is still refused outright, with the same codes as always: an account id that
is not yours or is not active (404 not_found), a media id you do not hold
(404 media_not_found), an unknown top-level field, text over 25,000 characters,
more than 25 ids in account_ids, a duplicate id, and two media spellings in one
request (the rest are 400 bad_request).
Save a draft
curl -X POST https://chirpie.ai/api/v1/posts \
-H "Authorization: Bearer chirpie_sk_YOUR_KEY" \
-H "Content-Type: application/json" \
-d '{
"account_id": "550e8400-e29b-41d4-a716-446655440000",
"text": "Half an idea. Finish it later.",
"draft": true
}'The post comes back with status: "draft". account_ids works the same way, and
the answer is then the fan-out shape, a group_id plus one result per account,
with success: true and status: "draft" on every entry.
POST /api/v1/threads takes draft too.
warnings
Every draft response carries a warnings array, and an empty array is the useful
answer "nothing would go wrong". Each entry says, for one account, what would
happen if the draft were sent as it stands, so you find out while you are still
writing rather than at publish time.
{
"data": {
"id": "a1b2c3d4-...",
"platform": "bluesky",
"account_id": "550e8400-...",
"text": "A long first attempt...",
"status": "draft",
"scheduled_at": null,
"published_at": null,
"warnings": [
{
"account_id": "550e8400-...",
"platform": "bluesky",
"code": "bad_request",
"message": "Text must be 300 characters or less (Bluesky account). Got 400."
}
]
}
}Anything that would really be refused carries the same code and the same
sentence the API would answer the send with, so there is one vocabulary to read
rather than two: bad_request, unsupported_media,
first_comment_unsupported, configuration_unsupported,
x_link_posts_require_paid_plan. A draft carries
its first_comment and its
configuration too, into the post it becomes when it is
promoted, and a draft whose account cannot take one comes back with a
first_comment_unsupported warning rather than an error. Three codes describe
a change rather than a refusal:
| Code | What it means |
|---|---|
thread_not_native | The platform has no reply chain, so each part publishes as its own standalone post |
x_link_post_billed | The post contains a link, so X bills it on top of your monthly quota |
schedule_at_in_past | The time the draft remembers has already passed. Pick a new one before scheduling it |
A warning never stops the save. The draft is stored either way.
Finish and promote a draft
A draft is edited with PATCH /api/v1/posts/:id like any other
post that has not gone out, and an edit of a draft is relaxed in the same way:
the text may be cleared, and anything that would be refused comes back on
warnings rather than as an error. An edit that leaves the post a draft answers
with the draft and its warnings.
Two fields promote it, and promotion is always explicit:
# Queue it for a time
curl -X PATCH https://chirpie.ai/api/v1/posts/DRAFT_ID \
-H "Authorization: Bearer chirpie_sk_YOUR_KEY" \
-H "Content-Type: application/json" \
-d '{"schedule_at": "2027-04-01T14:00:00Z"}'
# Send it now
curl -X PATCH https://chirpie.ai/api/v1/posts/DRAFT_ID \
-H "Authorization: Bearer chirpie_sk_YOUR_KEY" \
-H "Content-Type: application/json" \
-d '{"publish": true}'publish: true and schedule_at are never valid together and answer
400 bad_request. To change the time a draft remembers without promoting it,
send draft: true alongside schedule_at. draft: false is refused with a
400 saying to send schedule_at or publish: true instead.
Promotion runs every rule a create runs, the character limits, the media rules, the X link surcharge, the 5 minute spacing between scheduled posts and the two-post minimum for a thread, and spends the quota the draft never spent. If any of that refuses, the draft is left exactly where it was, unchanged.
The answer is the new post, carrying promoted_from_draft_id, or
promoted_from_draft_ids on a draft thread, which names every part that was
promoted. A promoted draft becomes a new post with a new id, and the draft itself
is gone. A draft thread is promoted whole: promoting any part queues or publishes
all of it.
On a post that is not a draft, publish and draft are both 400. A draft
somebody else already promoted answers 409 post_not_editable, the same as a
post that has already gone out.
List and delete drafts
curl "https://chirpie.ai/api/v1/posts?status=draft" \
-H "Authorization: Bearer chirpie_sk_YOUR_KEY"Ask for them by name. A listing with neither status nor group_id is the
record of what has been sent, queued or tried, so drafts are left out of it.
?status=draft is how you read them back, and ?group_id=… returns everything
one request created, drafts included.
Drafts never enter the scheduler's queue and never publish on their own.
DELETE /api/v1/posts/:id on a draft removes it and refunds nothing, because
nothing was charged. A draft is deleted as the one thing you saved: every
account it was addressed to and every part of it go together, and the response
always lists the ids in deleted_ids, however many there were.
From the dashboard
The compose dialog has a Save draft button in both Post now and Schedule mode. Saved drafts live on the Drafts tab of the Schedule page, newest first, each card showing its accounts, its text, how many parts and how many media items it carries, and the time it remembers. Open puts it all back in the compose dialog, where you can save it again, schedule it, or post it.
List Posts
GET /api/v1/posts
Authorization: Bearer chirpie_sk_YOUR_KEYQuery Parameters
| Parameter | Type | Default | Description |
|---|---|---|---|
limit | integer | 20 | Results per page (max 100) |
offset | integer | 0 | Pagination offset |
status | string | None | Filter: draft, scheduled, publishing, published, failed, deleted. With neither this nor group_id, drafts are left out: ask for them with status=draft |
account_id | string | None | Filter by account UUID |
group_id | string | None | Return every post of one fan-out, by the group_id its create call returned. Must be a UUID; anything else is 400 bad_request. See Post to several accounts at once |
include_hidden | boolean | false | Include the posts you have hidden. Off by default on every filter, including status and group_id. See Hide a post |
status=draft returns the posts you have saved but not sent. See
Drafts.
status=deleted returns the posts you deleted. Deleting a post never removes
it from Chirpie: the row keeps its id, its text and its history, and only its
status changes. It stays in an unfiltered listing, where the dashboard shows it
as "Deleted". To stop seeing a post without touching the platform, hide it.
Example
curl "https://chirpie.ai/api/v1/posts?status=published&limit=10" \
-H "Authorization: Bearer chirpie_sk_YOUR_KEY"Response 200 OK
data is a plain array of post objects, newest first, in the same shape as a create
response. There is no total count or cursor: page with limit and offset, and stop
when a page comes back shorter than limit.
{
"data": [
{
"id": "a1b2c3d4-...",
"text": "Hello world!",
"status": "published",
"platform_post_id": "1234567890",
"platform_post_url": "https://x.com/chirpie_ai/status/1234567890",
"published_at": "2026-03-23T10:01:00.000Z"
}
]
}Get a Post
GET /api/v1/posts/:id
Authorization: Bearer chirpie_sk_YOUR_KEYResponse 200 OK
Returns a single post object (same shape as list items).
| Status | Code | Condition |
|---|---|---|
404 | not_found | Post doesn't exist or belongs to another user |
Update a Post
Edit a post that has not gone out yet: its text, its media, or the time it
publishes. The same endpoint finishes a draft, and promotes it when
you ask it to: schedule_at queues the draft, publish: true sends it now.
PATCH /api/v1/posts/:id
Authorization: Bearer chirpie_sk_YOUR_KEY
Content-Type: application/jsonRequest Body
Every field is optional, and only what you send changes.
| Field | Type | Description |
|---|---|---|
text | string | Replacement text. The same per-platform character limits apply as on create |
media | object[] | Replacement media, following the same per-platform rules as on create, with optional alt text. Send [] to remove the media |
media_ids | string[] | Replacement media by upload id. Send [] to remove the media |
media_urls | string[] | Replacement media URLs, following the same per-platform rules as on create. Send [] to remove the media |
first_comment | string | A new first comment, replacing the one the post carries. Send "" to remove it. This is also how a first comment is added to a post that has not published yet. On a thread it belongs to the thread, so it applies whichever part you addressed |
configuration | object | New publishing options, replacing the ones the post carries. The media is checked again against the new placement, so a post becoming a reel needs a video attached. Send {} to put the post back to a plain feed post |
schedule_at | string | A new ISO 8601 publish time, in the future. Either an absolute instant carrying a timezone, or a local time with no offset read in timezone or the timezone saved on your account. On a draft it promotes: the draft becomes a scheduled post at that time, unless draft: true is sent alongside it |
timezone | string (IANA) | The timezone a schedule_at with no offset is read in, such as America/New_York. See Timezones |
draft | boolean | Only on a draft, and only true: keep it a draft, so a schedule_at changes the time it remembers instead of queueing it |
publish | boolean | Only on a draft: promote it and publish it now. Never valid together with schedule_at |
Leaving schedule_at out keeps the time the post already has. On a queued
post this endpoint never publishes anything and never turns a scheduled post
into an immediate one, so fixing a typo is only ever a typo fix. A draft is the
one thing it can send, and only when the request asks: publish: true sends it
now, schedule_at queues it. See Drafts.
Only a post that has not published yet can be edited: a scheduled post or a
draft. A post that is publishing, published, failed or deleted is refused
with 409 post_not_editable, and so is a draft somebody else has already
promoted.
Editing does not count against your quota.
Rescheduling one post of a thread moves the whole thread. Every part of a
scheduled thread shares one publish time, so a new time applies to all of them and
the response lists them in rescheduled_post_ids. Text and media, by contrast,
belong to the one part you named.
Example
curl -X PATCH https://chirpie.ai/api/v1/posts/a1b2c3d4-... \
-H "Authorization: Bearer chirpie_sk_YOUR_KEY" \
-H "Content-Type: application/json" \
-d '{"text": "Now with the typo fixed"}'Move it to a new time instead, changing nothing else:
curl -X PATCH https://chirpie.ai/api/v1/posts/a1b2c3d4-... \
-H "Authorization: Bearer chirpie_sk_YOUR_KEY" \
-H "Content-Type: application/json" \
-d '{"schedule_at": "2027-04-02T09:00:00Z"}'Response 200 OK
The post as it now stands, in the same shape as every other post object. When a
schedule_at was given, rescheduled_post_ids names every post that moved.
{
"data": {
"id": "a1b2c3d4-...",
"platform": "x",
"account_id": "e5f6g7h8-...",
"text": "Now with the typo fixed",
"media_urls": null,
"status": "scheduled",
"scheduled_at": "2027-04-01T14:00:00.000Z",
"published_at": null,
"created_at": "2027-03-30T10:00:00.000Z"
}
}Errors
| Status | Code | Condition |
|---|---|---|
400 | bad_request | Nothing to change, an unknown field, text or media the platform refuses, a time in the past, or a time within 5 minutes of another post on the same account |
400 | bad_request | publish together with schedule_at, draft: false, or publish or draft on a post that is not a draft |
400 | unsupported_media | A media URL the platform cannot accept. Nothing is changed |
400 | first_comment_unsupported | A first_comment on a post whose platform has no first comment. Nothing is changed |
400 | configuration_unsupported | A publishing option the post's platform or the new placement does not carry, or text or a first comment left on a post becoming a story. Nothing is changed |
402 | x_link_posts_require_paid_plan | New text adds a link to an X post on the Free plan, or a new first_comment adds one |
404 | not_found | Post doesn't exist, belongs to another user, or its account is no longer connected |
409 | post_not_editable | The post is publishing, published, failed or deleted, so it can no longer be edited, or the draft has already been promoted |
429 | usage_limit_exceeded | Promoting the draft would cross your monthly post quota or your scheduled-post quota. The draft is left exactly as it was |
Retry a First Comment
Post a first comment that failed, again. The request has no body: it re-sends the text the post already carries.
The text cannot be changed once the post is out.
PATCH /api/v1/posts/:id refuses a published post with
409 post_not_editable, so write or change a first comment while the post is
still a draft or still queued. If the platform keeps refusing the comment, post
it yourself.
POST /api/v1/posts/:id/first-comment
Authorization: Bearer chirpie_sk_YOUR_KEY
Idempotency-Key: retry-first-comment-a1b2c3d4An optional Idempotency-Key makes a retry of this call safe: the same key on the same post
replays the original answer for 24 hours rather than sending a second comment. See
Idempotency.
curl -X POST "https://chirpie.ai/api/v1/posts/a1b2c3d4-.../first-comment" \
-H "Authorization: Bearer chirpie_sk_YOUR_KEY"Response 200 OK
The post, in the same shape as every other post object, with
first_comment.status now posted.
{
"data": {
"id": "a1b2c3d4-...",
"platform": "x",
"status": "published",
"first_comment": {
"text": "Full write-up: https://example.com",
"status": "posted",
"comment_id": "6f1c2d3e-...",
"error": null
}
}
}A retry that works costs one post from your monthly quota, exactly as the first attempt would have.
Errors
| Status | Code | Condition |
|---|---|---|
404 | not_found | Post doesn't exist or belongs to another user |
404 | first_comment_not_found | The post has no first comment to send |
409 | first_comment_not_retryable | The first comment is already posted, the post has not published yet, or another attempt is in flight |
502 | first_comment_failed | The platform refused the retry. The post is untouched, and the message says what happened |
A retry runs every check the first attempt ran, so it can also answer with any
of those refusals: 429 usage_limit_exceeded when the month's posts are spent,
429 first_comment_rate_limited when the platform is throttling posts from the
account, 402 x_link_posts_require_paid_plan for a link on the Free plan,
409 comment_permission_required when the account needs reconnecting, and
501 platform_coming_soon for a platform that is not open yet. The post stays
published in every case.
Delete a Post
DELETE /api/v1/posts/:id
Authorization: Bearer chirpie_sk_YOUR_KEYOn a published post, delete means delete on the platform. Chirpie tells the platform first, and reports the post deleted only once the platform confirms it is gone. A post the platform says it has never heard of, or has already removed, counts as gone.
On a post that has not gone out, there is nothing to tell a platform. A
queued post is cancelled and its quota returned, and the answer names every
row that went with it in cancelled_ids. A draft is simply marked deleted
and the answer names its rows in deleted_ids: nothing is returned, because a
draft never counted against anything in the first place.
The post is never removed from Chirpie. It keeps its id, its text and its
history, with status: "deleted". GET /api/v1/posts/:id still returns it,
?status=deleted lists it, and the dashboard shows it as "Deleted". Nothing a
delete does is ever a hole in your record.
Which platforms Chirpie can delete from
| Platform | Delete | What happens |
|---|---|---|
| X | Yes | DELETE /2/tweets/{id}. A post already gone counts as deleted |
| Bluesky | Yes | deleteRecord, which is idempotent |
| LinkedIn (profile and Pages) | Yes | DELETE /rest/posts/{urn}, idempotent |
| Threads | Yes | Needs the delete permission the connect asks for. An account connected long ago may need reconnecting first, and the error says so |
| Mastodon | Yes | DELETE /api/v1/statuses/{id} |
| Facebook (feed) | Yes | Deletes the Page post |
| Facebook (story) | No | Facebook publishes no delete for a Page story. 501 delete_unsupported, and the story disappears on its own 24 hours after it was posted |
| Telegram | Yes, within 48 hours | Telegram refuses to delete a message more than 48 hours old, and the error says so |
| Yes | DELETE /v5/pins/{id} | |
| YouTube | Yes | Deletes the video |
| Google Business Profile | Yes | Deletes the local post |
| Instagram (connected via Facebook) | Yes | Deletes the post, whatever the placement. A carousel is deleted whole |
| Instagram (connected through Instagram) | No | Chirpie cannot delete a published post from an account connected that way. 501 delete_unsupported. Connect the account via Facebook if you want deletes |
| TikTok | No | TikTok publishes no delete for a post made through its API. 501 delete_unsupported |
A failed platform delete is reported, not hidden. If the platform refuses or
is unavailable, nothing changes: the post keeps its current status, stays in your
listings, and the call returns 502 upstream_error with a message naming the
platform. Retry the same request. Chirpie never answers deleted: true for a post
that is still public.
Where a delete cannot reach the platform it is refused, not faked.
Chirpie cannot delete a published TikTok post, nor a published post from an
Instagram account connected through Instagram rather than
via Facebook,
and Facebook publishes a delete for a Page feed post but not for a Page
story, so the call returns 501 delete_unsupported
and nothing changes. Delete the post in the platform's own app, or wait for a
Page story to expire 24 hours after it was posted. If you only
want it out of your Chirpie listings, hide it: that is
reversible and reaches no platform.
Cancelling one post in a scheduled thread cancels the thread. A thread is
published as a unit, so it is cancelled as a unit too: deleting any post of a
scheduled thread cancels every post in it. The response lists the ids in
cancelled_ids. Nothing is billed for a thread that never published, and the
whole thread's quota is returned.
Response 200 OK
{
"data": {
"id": "a1b2c3d4-...",
"deleted": true
}
}Cancelling a post that belongs to a scheduled thread returns every id it cancelled:
{
"data": {
"id": "b2c3d4e5-...",
"deleted": true,
"cancelled_ids": ["a1b2c3d4-...", "b2c3d4e5-...", "c3d4e5f6-..."]
}
}Errors
| Status | Code | Condition |
|---|---|---|
404 | not_found | Post doesn't exist or belongs to another user |
501 | delete_unsupported | Chirpie cannot delete a published post on this platform (TikTok), on this account (an Instagram account connected through Instagram rather than via Facebook), or on this placement (a Facebook Page story). Nothing changed. Delete it in the platform's own app, and hide it here if you do not want to see it |
502 | upstream_error | The platform refused the delete or was unavailable. Nothing changed: the post keeps its status and is still in your listings. Retry the same request |
Hide a Post
POST /api/v1/posts/:id/hide
POST /api/v1/posts/:id/unhide
Authorization: Bearer chirpie_sk_YOUR_KEYHiding takes a post out of your Chirpie listings. Nothing reaches the
platform. The post stays exactly as it is, keeps its analytics and its
comments, and unhide puts it back. Nothing is charged and no quota moves.
This is the answer to "I do not want to see this any more". DELETE is the
answer to "take it down", and the two are never the same request. Hiding is
also the only thing on offer for a post Chirpie cannot delete, such as a
published TikTok post.
A hidden post is left out of GET /api/v1/posts unless you pass
include_hidden=true, on every filter. It is always readable by id with
GET /api/v1/posts/:id, and every post payload carries hidden.
A thread, or a fan-out made in one request, is hidden as the one thing it was
made as, so hidden_ids can name more posts than the one you passed. Unhiding
a post that was never hidden succeeds and changes nothing.
Hiding a queued post does not stop it publishing. Hide only decides what
Chirpie shows you; the scheduler pays no attention to it, so a hidden
scheduled post goes out at its time as normal. To stop one going out, delete
it: DELETE /api/v1/posts/{id} cancels a post that has not published yet and
returns its quota.
curl -X POST "https://chirpie.ai/api/v1/posts/a1b2c3d4-.../hide" \
-H "Authorization: Bearer chirpie_sk_YOUR_KEY"Response 200 OK
{
"data": {
"id": "a1b2c3d4-...",
"hidden": true,
"hidden_ids": ["a1b2c3d4-...", "b2c3d4e5-...", "c3d4e5f6-..."]
}
}Errors
| Status | Code | Condition |
|---|---|---|
404 | not_found | Post doesn't exist or belongs to another user |
Google Business Profile posting API for AI agents
How Chirpie lets an AI agent publish Google Business Profile updates from one API call or an MCP tool, with an optional photo and scheduling.
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.