Node.js SDK

The official TypeScript / JavaScript client for the Bulkit API.

The bulkit package wraps the API in typed methods. It handles retries, timeouts, idempotency keys, pagination, large uploads and webhook signatures for you.

  • Fully typed, generated from the Bulkit OpenAPI spec
  • Zero runtime dependencies, ESM and CommonJS
  • Retries with backoff and an Idempotency-Key on every write

Install

npm install bulkit
# or: pnpm add bulkit / yarn add bulkit / bun add bulkit

Needs Node.js 22 or later. Other runtimes with fetch and Web Crypto (Bun, Deno, Cloudflare Workers) should work too.

Set up the client

Create an API key and put it in an environment variable:

.env
BULKIT_API_KEY=bk_test_xxxxxxxxxxxxxxxx
import { Bulkit } from "bulkit";

const bulkit = new Bulkit(); // reads BULKIT_API_KEY

You can also pass it yourself: new Bulkit({ apiKey: "bk_test_…" }).

Start with a test key (bk_test_…). It checks and saves everything like a live key, but never posts and is never billed. bulkit.isTestMode tells you which kind of key a client holds.

Use a different API address

The SDK talks to https://api.bulkit.io by default. To use another address (a self-hosted API, a local server, a proxy), set baseURL or BULKIT_BASE_URL. Give only the scheme and host — the SDK adds /public/v1 itself:

const bulkit = new Bulkit({ baseURL: "http://localhost:3001" });

All options

OptionDefaultWhat it does
apiKeyprocess.env.BULKIT_API_KEYYour API key.
baseURLprocess.env.BULKIT_BASE_URL, then https://api.bulkit.ioScheme and host of the API.
timeout60000Timeout for each attempt, in milliseconds.
maxRetries2How many times to retry a failure that is safe to retry.
defaultHeaders{}Headers sent with every request.
fetchglobalThis.fetchYour own fetch, for proxies or tests.
dangerouslyAllowBrowserfalseAllow running in a browser. See below.

Your API key is a secret. The client refuses to run in a browser, because every visitor could read the key. Call Bulkit from your server. Only set dangerouslyAllowBrowser: true for something like an internal tool.

Your first post

// 1. What can I post to, and what does each platform accept?
const channels = await bulkit.channels.list();
const platforms = await bulkit.platforms.list();

// 2. Schedule ONE post to several channels.
const post = await bulkit.posts.create({
  intent: "schedule",
  scheduledAt: "2026-11-01T09:00:00Z",
  channels: [
    { channelId: "chn_x", blocks: [{ content: "Shipping today." }] },
    { channelId: "chn_li", blocks: [{ content: "A longer version, for LinkedIn." }] },
  ],
});

console.log(post.id, post.status); // "scheduled"

Call posts.create once with several entries in channels. Calling it once per channel creates separate posts, each with its own id and schedule.

The body is the same as POST /posts. See Create posts for drafts, threads, titles and per-platform options.

Publishing is asynchronous

intent: "now" and posts.publish() return as soon as the work is queued, with status publishing. That does not mean the post is live. Each channel publishes on its own, so a post can end up partially_published.

Find out what happened with webhooks (best), or by calling posts.retrieve(id). See Check the result.

const post = await bulkit.posts.create({ intent: "now", channels });
// later…
const result = await bulkit.posts.retrieve(post.id);
for (const channel of result.channels) {
  console.log(channel.channelId, channel.status);
}

Lists and pages

List methods (posts.list, posts.comments.list, webhooks.deliveries) return a page. await it for the first page, or loop over it with for await to get every item across every page:

for await (const post of bulkit.posts.list({ status: ["failed"] })) {
  await bulkit.posts.publish(post.id); // retries; channels already live are skipped
}

To page by hand:

let page = await bulkit.posts.list({ limit: 50 });
console.log(page.data);

while (page.hasNextPage()) {
  page = await page.getNextPage();
  console.log(page.data);
}

page.iterPages() gives you each page in turn. The end of the list is a null nextCursor, not an empty page.

Upload media

media.upload() sends a file in one request: images up to 10 MB, video and PDFs up to 100 MB, subtitles up to 5 MB.

import { openAsBlob } from "node:fs";

const image = await bulkit.media.upload(
  await openAsBlob("photo.jpg", { type: "image/jpeg" }),
  { fileName: "photo.jpg" },
);

await bulkit.posts.create({
  intent: "draft",
  channels: [{ channelId: "chn_x", blocks: [{ content: "Look.", mediaIds: [image.id] }] }],
});

For large video, use media.uploadMultipart(). It opens an upload session, uploads the parts in parallel (4 at a time by default), and finishes the session. If any step fails, it cancels the session for you.

const video = await bulkit.media.uploadMultipart(await openAsBlob("launch.mp4"), {
  fileName: "launch.mp4",
  mimeType: "video/mp4",
  concurrency: 4,
});

A file can be a Blob, a File, an ArrayBuffer or a Uint8Array. fileName is required unless you pass a File. In Node, fs.openAsBlob() avoids loading the whole file into memory.

More in Upload media.

Webhooks

Use verifyWebhook() to check the signature and parse the event. It needs the raw request body — parsed and re-encoded JSON will not match.

Express
import express from "express";
import { verifyWebhook, WebhookVerificationError } from "bulkit";

const app = express();

app.post("/hooks/bulkit", express.raw({ type: "application/json" }), async (req, res) => {
  try {
    const event = await verifyWebhook(req.body, req.headers, {
      secret: process.env.BULKIT_WEBHOOK_SECRET!,
    });

    // Answer fast, then do the work. Dedupe on event.id.
    res.sendStatus(200);

    switch (event.type) {
      case "post.published":
      case "post.failed":
      case "post.partially_failed":
        console.log(event.type, event.data.channelId, event.data.post.id);
        break;
      case "channel.needs_reconnect":
        console.log("Reconnect", event.data.channel.id);
        break;
    }
  } catch (err) {
    res.sendStatus(err instanceof WebhookVerificationError ? 400 : 500);
  }
});
  • verifyWebhook() needs no API key, so a receiver doesn't need a client. bulkit.webhooks.unwrap() does the same thing.
  • It rejects events signed more than 5 minutes ago. Change this with toleranceSeconds.
  • event is typed on type, so TypeScript knows the shape of event.data.
  • Delivery is at-least-once: dedupe on event.id.

Test your receiver

bulkit.webhooks.test(endpointId) sends a signed webhook.test event right away. It works with test keys too.

In unit tests, use signWebhookPayload() to build a valid x-bulkit-signature header for any body:

import { signWebhookPayload } from "bulkit";

const body = JSON.stringify({ id: "evt_1", type: "webhook.test", createdAt: new Date().toISOString(), data: {} });
const signature = await signWebhookPayload(body, "whsec_test");
// send `body` with header `x-bulkit-signature: ${signature}`

More in Webhooks.

Errors

Every API failure throws a subclass of APIError. Branch on code, not on message — the message is text for humans and may change.

import { APIError, QuotaExceededError, UnprocessableEntityError } from "bulkit";

try {
  await bulkit.posts.create(params);
} catch (err) {
  if (err instanceof QuotaExceededError) {
    // The plan's allowance is spent. Retrying won't help: see bulkit.usage.retrieve().
  } else if (err instanceof UnprocessableEntityError) {
    // A platform rule refused it. `field` points at the bad part of the body.
    console.error(err.code, err.field, err.message);
  } else if (err instanceof APIError) {
    console.error(err.status, err.code, err.requestId);
  } else {
    throw err;
  }
}
StatusError class
400BadRequestError
401AuthenticationError
402QuotaExceededError
403PermissionDeniedError
404NotFoundError
409ConflictError
413PayloadTooLargeError
415UnsupportedMediaTypeError
422UnprocessableEntityError
429RateLimitError (has retryAfter, in seconds)
5xxInternalServerError
no responseAPIConnectionError, APIConnectionTimeoutError
you abortedAPIUserAbortError

Every APIError has:

PropertyWhat it is
statusThe HTTP status.
codeThe error code, like content_too_long. See Errors.
fieldThe part of the body (or the header) that caused it.
detailsEvery field error, when more than one failed.
requestIdGive this to support.
errorThe raw error object from the API.
headersThe response headers.

All SDK errors, including WebhookVerificationError, extend BulkitError.

Retries and idempotency

Every POST and PUT is sent with an Idempotency-Key. The SDK makes one per call and reuses it for its own retries, so a retry can never post twice.

To stay safe across process restarts (a job that crashed and runs again), pass your own key:

await bulkit.posts.create(params, { idempotencyKey: `campaign-${campaignId}` });

The SDK retries these failures (twice by default), waiting longer each time and following Retry-After:

  • connection errors and timeouts
  • 408, 429, 502, 503 and 504
  • 409 with code request_in_progress
  • 500, but only on reads

A 500 on a write is not retried: the API would replay the same failure for the same key. Check the post's state first. 402 and other 4xx errors are never retried.

Per-request options

Every method takes an optional last argument:

await bulkit.posts.list(
  { status: ["scheduled"] },
  { maxRetries: 0, timeout: 5_000, signal: AbortSignal.timeout(10_000) },
);
OptionWhat it does
idempotencyKeyYour own key for this write.
timeoutTimeout for each attempt, in milliseconds.
maxRetriesRetries for this call only.
signalAn AbortSignal to cancel the call.
headersExtra headers for this call.

Response details

Add .withResponse() to any call to get the request id, the rate limit and the raw Response along with the data:

const { data, requestId, rateLimit, response } = await bulkit.channels.list().withResponse();

console.log(rateLimit.limit, rateLimit.remaining, rateLimit.reset);

Include the requestId when you contact support. See Limits.

TypeScript types

Every request and response type is exported by name:

import type { Channel, Post, PostCreateParams, PostStatus, WebhookEvent } from "bulkit";

function buildPost(channels: Channel[]): PostCreateParams {
  return {
    intent: "draft",
    channels: channels.map((c) => ({ channelId: c.id, blocks: [{ content: "Hi" }] })),
  };
}

Versioning

The package follows SemVer. Each major version of the SDK talks to one API version (/public/v1 today). VERSION exports the installed version.

Next steps

On this page