ChirpieDocs
SDK

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/sdk
yarn add @chirpie/sdk
pnpm add @chirpie/sdk

Quick 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  # Optional
import { 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";

On this page