TypeScript SDK
Use the @chirpie/sdk package for type-safe API access from Node.js and TypeScript.
The official TypeScript SDK provides a type-safe client for the Chirpie API.
Installation
npm install @chirpie/sdkyarn add @chirpie/sdkpnpm add @chirpie/sdkQuick Start
import { ChirpieClient } from "@chirpie/sdk";
const chirpie = new ChirpieClient({
apiKey: process.env.CHIRPIE_API_KEY!,
});
// Create a post
const post = await chirpie.createPost({
account_id: "YOUR_ACCOUNT_ID",
text: "Hello from the SDK!",
});
console.log(post.id, post.status);Configuration
const chirpie = new ChirpieClient({
apiKey: "chirpie_sk_YOUR_KEY",
baseUrl: "https://chirpie.ai", // Optional, defaults to chirpie.ai
});Or use environment variables:
export CHIRPIE_API_KEY=chirpie_sk_YOUR_KEY
export CHIRPIE_BASE_URL=https://chirpie.ai # Optionalimport { ChirpieClient, requireConfig } from "@chirpie/sdk";
const config = requireConfig(); // Reads from env or ~/.chirpie/config.json
const chirpie = new ChirpieClient({
apiKey: config.api_key,
baseUrl: config.base_url,
});Methods
Media
import { readFile } from "node:fs/promises";
// Upload a file and get the id a post can attach. Valid for 7 days.
const media = await chirpie.uploadMedia({
data: await readFile("./screenshot.png"),
filename: "screenshot.png",
});
// Or hand Chirpie a public URL to fetch and store.
const fromUrl = await chirpie.uploadMedia({ url: "https://example.com/a.png" });
// Attach it, with a description for people using a screen reader.
await chirpie.createPost({
account_id: "uuid",
text: "Shipping new features today!",
media: [{ id: media.id, alt: "The new dashboard" }],
});A media entry takes either id (from an upload) or url (a public link).
Use media, media_ids or media_urls, not more than one. Full detail:
Media.
Posts
// Create a post
const post = await chirpie.createPost({
account_id: "uuid",
text: "Hello world!",
schedule_at: "2026-04-01T14:00:00Z", // Optional
});
// Schedule in local time. `schedule_at` with no offset is read in `timezone`,
// or in the timezone saved on your account. Daylight saving is worked out for
// the date you name, which an offset computed today gets wrong.
await chirpie.createPost({
account_id: "uuid",
text: "Half nine, my time.",
schedule_at: "2026-11-01T09:30:00",
timezone: "America/New_York",
});
// Retry safely. `createPost` and `createThread` generate an Idempotency-Key per
// call; pass your own so the key survives a process restart, or null for none.
await chirpie.createPost(
{ account_id: "uuid", text: "Hello world!" },
{ idempotencyKey: "campaign-2026-11-01-launch" }
);
// Post to several accounts in one call, with optional per-account overrides.
// The response is a group id plus one result per account, in the order given.
const { group_id, results } = await chirpie.createPost({
account_ids: ["uuid-a", "uuid-b"],
text: "Shared text",
account_configurations: {
"uuid-b": { text: "Text for just this account" },
},
});
for (const r of results) console.log(r.account_id, r.success, r.error);
// List posts, newest first. Page with limit/offset; a short page is the last one.
const posts = await chirpie.listPosts({
status: "published", // draft | scheduled | publishing | published | failed | deleted
account_id: "uuid", // Optional filter
group_id: "uuid", // Optional: every post of one multi-account send
limit: 20, // Default 20, max 100
offset: 0,
});
// Get a single post
const post = await chirpie.getPost("post-uuid");
// Edit a post that has not published yet. Leaving `schedule_at` out keeps the
// time it already has, so this never publishes a queued post.
await chirpie.updatePost("post-uuid", { text: "Now with the typo fixed" });
await chirpie.updatePost("post-uuid", { schedule_at: "2027-04-02T09:00:00Z" });
// Replace the first comment, or pass "" to remove it. This is also how a first
// comment is added to a post that has not published yet.
await chirpie.updatePost("post-uuid", { first_comment: "Full write-up: https://example.com" });
// Delete a post from the platform. Chirpie keeps the post, marked deleted,
// so it stays in your history. Deleting any post of a scheduled thread
// cancels the whole thread; `result.cancelled_ids` then lists every post that
// was cancelled. On TikTok, on an Instagram account connected through
// Instagram rather than via Facebook, and on a Facebook Page story, this
// throws `501 delete_unsupported`: a published post there cannot be deleted.
const result = await chirpie.deletePost("post-uuid");
// Hide a post from your Chirpie listings. Nothing reaches the platform: the
// post stays exactly as it is, and `unhidePost` puts it back. A thread or a
// fan-out moves whole, and `hidden_ids` names every post that moved.
await chirpie.hidePost("post-uuid");
await chirpie.unhidePost("post-uuid");
// Hidden posts are left out of every listing unless you ask for them.
const withHidden = await chirpie.listPosts({ include_hidden: true });First comment
first_comment publishes one comment under the post the moment it goes out.
X, Threads, Instagram and Facebook only: anywhere else the call throws
400 first_comment_unsupported. It counts as one post against your monthly
quota. See First comment.
const post = await chirpie.createPost({
account_id: "uuid",
text: "We rebuilt scheduling this week.",
first_comment: "Full write-up: https://example.com/blog/scheduling",
});
post.first_comment;
// { text: "Full write-up: ...", status: "pending" | "posted" | "failed",
// comment_id: string | null, error: string | null }
// A failed first comment never fails its post. Send it again with the text the
// post already carries. That text cannot be changed once the post is out, so
// set it while the post is still a draft or still queued, with `updatePost`.
if (post.first_comment?.status === "failed") {
const retried = await chirpie.retryFirstComment(post.id);
console.log(retried.first_comment?.status); // "posted"
}createThread takes first_comment too: one comment for the whole thread,
published under the last part and reported on that part.
Publishing options
configuration says how a platform publishes the post, 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.
await chirpie.createPost({
account_id: "uuid",
text: "Behind the scenes of this week.",
media_urls: ["https://example.com/clip.mp4"],
configuration: { instagram: { placement: "reel", share_to_feed: true } },
});Nothing is dropped quietly: an option the platform or the placement does not
carry throws 400 configuration_unsupported naming the field. A story carries
no caption and no first comment, and a story or a reel is a single post, so
createThread refuses either placement. updatePost takes configuration
too, and {} puts a post back to a plain feed post. Full detail:
Instagram publishing options and
Facebook publishing options.
A createPost call naming account_ids returns { group_id, results }
instead of a single post, even for one account. Full detail:
Post to several accounts at once.
Threads
A thread is atomic. If any part fails, every part that had already published is
deleted from the platform and the whole thread's quota is refunded. Read what
happened with ChirpieApiError.threadRollback():
import { ChirpieApiError } from "@chirpie/sdk";
try {
await chirpie.createThread({ account_id: "uuid", posts });
} catch (err) {
const rollback = err instanceof ChirpieApiError ? err.threadRollback() : null;
if (rollback?.still_live.length) {
// `thread_rollback_incomplete`: these are really still up, so retrying
// the same call would publish them twice.
for (const part of rollback.still_live) {
console.error(part.platform_post_url ?? part.platform_post_id);
}
}
}An empty still_live, which comes with the code upstream_error, means
nothing of the thread is on the platform and the same call can simply be
repeated. See A thread that failed part-way.
const thread = await chirpie.createThread({
account_id: "uuid",
posts: [
{ text: "First post in thread" },
{ text: "Second post in thread" },
{ text: "Third post in thread" },
],
schedule_at: "2026-04-01T14:00:00Z", // Optional
});
// The same thread to several accounts. An override's `posts` replaces the
// whole array for that account.
const { group_id, results } = await chirpie.createThread({
account_ids: ["uuid-a", "uuid-b"],
posts: [{ text: "one" }, { text: "two" }],
account_configurations: {
"uuid-b": { posts: [{ text: "a" }, { text: "b" }, { text: "c" }] },
},
});Drafts
A draft is saved and nothing else: it never reaches the platform, never
publishes on its own, and costs no quota until you promote it. While it is a
draft the usual checks are relaxed, and everything that would go wrong comes
back on warnings, one entry per account.
// Save a post without sending it
const draft = await chirpie.createPost({
account_id: "uuid",
text: "Half an idea. Finish it later.",
draft: true,
});
for (const w of draft.warnings) console.log(w.platform, w.code, w.message);
// A draft thread may be a single part while you are still writing it
const draftThread = await chirpie.createThread({
account_id: "uuid",
posts: [{ text: "Opening line, rest to come" }],
draft: true,
});
// Everything you have saved
const drafts = await chirpie.listPosts({ status: "draft" });
// Change the time a draft remembers, without queueing it
await chirpie.updatePost("draft-uuid", {
schedule_at: "2027-04-02T09:00:00Z",
draft: true,
});
// Promote it: queue it for a time, or send it now
const queued = await chirpie.updatePost("draft-uuid", {
schedule_at: "2027-04-02T09:00:00Z",
});
const published = await chirpie.updatePost("draft-uuid", { publish: true });Promotion runs every rule a create runs and spends the quota the draft never
spent, so anything it refuses leaves the draft exactly as it was. The promoted
post is a new post with a new id, and it carries promoted_from_draft_id, or
promoted_from_draft_ids for a thread, which is promoted whole. Full detail:
Drafts.
Comments
// List the comments on a post you published, newest first.
const { comments, next_cursor, capabilities, sync } = await chirpie.listComments(
"post-uuid",
{
limit: 25, // Default 25, max 100
cursor: undefined, // next_cursor from the previous page
since: "2026-09-18T00:00:00Z",
sync: "auto", // "auto" | true | false
include_hidden: false, // true: also comments you hid (hidden: true)
include_deleted: false, // true: also deleted ones (deleted: true), text kept
}
);
// Your own replies have own: true, and their author is the connected account.
// capabilities says what this platform allows, so nothing has to be hard-coded.
// delete_own and delete_any are separate: on most platforms you may delete only
// replies you wrote.
if (capabilities.hide) { /* render a Hide control */ }
// sync.status says how fresh the stored comments are. Anything but "ok" carries
// a ready-worded sentence in sync.reason.
if (sync.status !== "ok") console.log(sync.reason);
// Reply to a comment. It is published to the platform, so it counts as one post
// against your monthly quota.
const reply = await chirpie.replyToComment(
"post-uuid",
"comment-uuid",
"Yes, pass schedule_at and we will queue it."
);
// Hide and unhide. Facebook, Instagram and Threads only; everywhere else these
// throw comment_action_unsupported rather than quietly doing nothing.
await chirpie.hideComment("post-uuid", "comment-uuid");
await chirpie.unhideComment("post-uuid", "comment-uuid");
// Delete. Check capabilities.delete_any before offering it on someone else's
// comment: most platforms allow deleting only your own replies. On Facebook,
// Instagram, Threads and LinkedIn the replies under it are deleted with it.
const removed = await chirpie.deleteComment("post-uuid", "comment-uuid");
console.log(removed.deleted);Accounts
// List connected accounts (inactive ones included)
const accounts = await chirpie.listAccounts();
// Accounts connected past your plan's account limit come back inactive rather
// than being dropped. One Facebook authorization can grant several Pages.
const parked = accounts.filter(
(a) => !a.is_active && a.inactive_reason === "plan_limit"
);
// Choose which accounts publish. Deactivating frees a plan slot and keeps the
// account connected, so activating another one needs no reauthorization.
//
// Deactivating also CANCELS the account's scheduled posts, since a switched-off
// account cannot publish. `scheduled_posts` says how many would go, and
// `canceled_posts` on the result says how many did. Reactivating does not
// restore them.
console.log(accounts[0].scheduled_posts);
const off = await chirpie.deactivateAccount(accounts[0].id);
console.log(off.canceled_posts);
await chirpie.activateAccount(parked[0].id);
// Disconnect an account you are done with. It stops publishing straight away and
// stops counting against the plan's account limit, and the stored credential is
// removed, so connecting it again means authorizing it on the platform again.
// This also CANCELS the account's scheduled posts and returns their quota;
// `canceled_posts` says how many. They are not restored. Posts the account
// already published are kept.
const gone = await chirpie.disconnectAccount(accounts[0].id);
console.log(gone.disconnected, gone.canceled_posts);
// Connect an X account (starts OAuth flow)
const { authorization_url } = await chirpie.connectXAccount();
// Use your own X developer app so posts bill your X API credits.
// See /docs/x-byo-keys for the X app setup this requires.
await chirpie.setXKeys({
client_id: process.env.X_CLIENT_ID!,
client_secret: process.env.X_CLIENT_SECRET!,
label: "Acme social app", // Optional
});
const xKeys = await chirpie.getXKeysStatus(); // Never includes the secret
await chirpie.removeXKeys();
// Connect a Bluesky account (app password). The identifier takes the first part
// of the handle alone, a full handle, a custom-domain handle, or the account email.
// app_password must be a Bluesky app password (xxxx-xxxx-xxxx-xxxx, from Settings,
// Privacy and Security, App Passwords). Anything else, the account password
// included, is refused with bluesky_app_password_required.
const blueskyAccount = await chirpie.connectBlueskyAccount({
platform: "bluesky",
identifier: "yourhandle.bsky.social",
app_password: "xxxx-xxxx-xxxx-xxxx",
});
// Connect a LinkedIn account (starts OAuth flow)
const { authorization_url: linkedinUrl } = await chirpie.connectLinkedInAccount();
// The LinkedIn Pages you administer are a separate connection (coming soon):
const { authorization_url: linkedinPagesUrl } =
await chirpie.connectLinkedInPagesAccount();
// Connect a Threads account (starts Meta OAuth flow):
const { authorization_url: threadsUrl } = await chirpie.connectThreadsAccount();
// Connect a Mastodon account (starts OAuth flow). instance_url takes the server,
// a handle ("@you@mastodon.social") or a profile URL; only the server is kept.
const { authorization_url: mastodonUrl } = await chirpie.connectMastodonAccount({
platform: "mastodon",
instance_url: "@you@mastodon.social",
});
// Connect an Instagram account. Two routes, and `via` picks:
// "instagram" (the default) signs in with Instagram.
const { authorization_url: instagramUrl } = await chirpie.connectInstagramAccount();
// "facebook" signs in with Facebook and connects the Instagram accounts linked
// to the Pages the user shares. It is the route that can delete a published
// post; everything else is identical. Several accounts can arrive at once, and
// any beyond the plan's account limit are inactive with reason "plan_limit".
// Connecting an account you already have through the other route moves it
// rather than adding a second one: same id, same history, and only deleting
// changes hands. Where the two routes report different Instagram accounts you
// get a second account instead, so read `listAccounts()` back afterwards.
const { authorization_url: instagramViaFacebookUrl } =
await chirpie.connectInstagramAccount({ via: "facebook", reconnect: true });
// Connect Facebook Pages (starts the Facebook OAuth flow).
// Every Page the user grants is imported as its own account; any beyond your
// plan's account limit arrive inactive with inactive_reason "plan_limit".
const { authorization_url: facebookUrl } = await chirpie.connectFacebookAccount();
// Re-running the flow for someone who has authorized Facebook before: pass
// reconnect so Facebook asks again about anything they turned down last time.
const { authorization_url: facebookAgainUrl } = await chirpie.connectFacebookAccount({
reconnect: true,
});
// Connect a Telegram bot (bot token auth)
const telegramAccount = await chirpie.connectTelegramAccount({
platform: "telegram",
bot_token: "123456789:ABCdefGHIjklMNOpqrSTUvwxYZ",
chat_id: "-1001234567890",
});
// Pinterest, TikTok, YouTube, and Google Business Profile are coming soon.API Keys
// Create a key. `key` is returned once and never again; keys expire after 90 days.
const { key, prefix, name, expires_at } = await chirpie.createKey("My Bot");
// Create a narrower key. Omitting `scopes` copies the calling key's own
// scopes, so a bare name gives full access only when the caller has it.
// A key can never grant a scope it does not itself hold.
const scoped = await chirpie.createKey({
name: "Publishing bot",
scopes: ["posts:write", "media:write"],
});
// List keys. `scopes` is on each one, and null means full access.
const keys = await chirpie.listKeys();
// Revoke a key
await chirpie.revokeKey("key-uuid");Analytics
const analytics = await chirpie.getPostAnalytics("post-uuid");
console.log(analytics.impressions, analytics.likes);
// Ask the platform now instead of reading the stored snapshot. Allowed once
// per post every 5 minutes; past that it throws 429
// analytics_refresh_rate_limited, and the stored numbers are still one
// ordinary call away.
const fresh = await chirpie.getPostAnalytics("post-uuid", { refresh: true });Idempotency and retries
createPost() and createThread() send an Idempotency-Key on every call, generated for
you. uploadMedia(), retryFirstComment() and replyToComment() take the same options
object but generate nothing, so pass a key when you want one.
await chirpie.uploadMedia({ url: "https://example.com/a.png" }, { idempotencyKey: "hero-1" });
await chirpie.retryFirstComment("post-uuid", { idempotencyKey: "retry-comment-7" });
await chirpie.replyToComment("post-uuid", "comment-id", "Thanks", { idempotencyKey: "reply-9" });
// Send no key at all.
await chirpie.createPost({ account_id: "uuid", text: "Hi" }, { idempotencyKey: null });The same key with the same request replays the first answer for 24 hours rather than publishing again. See Idempotency.
Scopes
A key created with scopes can only reach the routes those scopes cover, and a call outside
them throws a ChirpieError carrying insufficient_scope. A key created without scopes
can do exactly what the key that made it can do, which is full access for every key that
predates scopes. See Scopes.
Error Handling
import { ChirpieClient, ChirpieApiError } from "@chirpie/sdk";
try {
await chirpie.createPost({ ... });
} catch (err) {
if (err instanceof ChirpieApiError) {
console.error(err.code); // "usage_limit_exceeded", "rate_limited", "x_link_posts_require_paid_plan", ...
console.error(err.message); // "Monthly post limit exceeded..." / "X charges $0.20 per post containing a link..."
console.error(err.status); // 429 for quota, 402 for X link posts on the Free plan
}
}TypeScript Types
All types are exported:
import type {
ApiPost, // A post, as returned by createPost/getPost/listPosts
ApiThread, // A thread, as returned by createThread
ApiAccount, // A connected account
InstagramConnectVia, // "instagram" | "facebook": which route connects an Instagram account
ApiAccountUpdate, // An account plus canceled_posts, from activate/deactivate
ApiAccountDisconnect, // id + disconnected + canceled_posts, from disconnectAccount
AccountList, // listAccountsWithLimits(): accounts + accounts_limit + accounts_active
ApiAnalytics, // Post metrics
ApiFirstComment, // A post's first comment: text, status, comment_id, error
ApiComment, // A comment on a published post
ApiCommentUpdate, // A comment after a hide or unhide
CommentList, // listComments(): comments + next_cursor + capabilities + sync
CommentCapabilities, // What the platform allows: reply, hide, delete_own, delete_any
CommentSync, // How fresh the stored comments are, and why
ListCommentsOptions,
ApiKeyInfo, // API key metadata, prefix only
ApiScope, // One scope: "posts:write", "analytics:read", and so on
CreateKeyInput, // createKey({ name, scopes })
RequestOptions, // Per-call options: { idempotencyKey }
ApiMedia, // An uploaded file, as returned by uploadMedia
ApiPostMedia, // One item of a post's media: its url and its alt text
MediaInput, // One item to attach: { id? , url?, alt? }
UploadMediaInput, // What uploadMedia accepts: a file, bytes, or a url
XKeysStatus, // Your own X developer app, status only
SetXKeysInput,
CreatePostInput,
CreateThreadInput,
UpdatePostInput,
PostAccountConfiguration, // One account's overrides on a multi-account post
ThreadAccountConfiguration, // One account's overrides on a multi-account thread
FanOutPostResult, // One account's outcome in a multi-account post
FanOutPostResponse, // createPost() with account_ids: group_id + results
FanOutThreadResult, // One account's outcome in a multi-account thread
FanOutThreadResponse, // createThread() with account_ids: group_id + results
DraftWarning, // What one account would be told if a draft were sent
DraftPostResponse, // createPost({ draft: true }): the post plus warnings
DraftThreadResponse, // createThread({ draft: true }): the thread plus warnings
FanOutDraftPostResponse, // A draft post saved for several accounts
FanOutDraftThreadResponse, // A draft thread saved for several accounts
ConnectBlueskyInput,
ConnectMastodonInput,
ConnectFacebookInput,
ConnectTelegramInput,
ListPostsOptions,
} from "@chirpie/sdk";