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-Keyon every write
Install
npm install bulkit
# or: pnpm add bulkit / yarn add bulkit / bun add bulkitNeeds 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:
BULKIT_API_KEY=bk_test_xxxxxxxxxxxxxxxximport { Bulkit } from "bulkit";
const bulkit = new Bulkit(); // reads BULKIT_API_KEYYou 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
| Option | Default | What it does |
|---|---|---|
apiKey | process.env.BULKIT_API_KEY | Your API key. |
baseURL | process.env.BULKIT_BASE_URL, then https://api.bulkit.io | Scheme and host of the API. |
timeout | 60000 | Timeout for each attempt, in milliseconds. |
maxRetries | 2 | How many times to retry a failure that is safe to retry. |
defaultHeaders | {} | Headers sent with every request. |
fetch | globalThis.fetch | Your own fetch, for proxies or tests. |
dangerouslyAllowBrowser | false | Allow 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.
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. eventis typed ontype, so TypeScript knows the shape ofevent.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;
}
}| Status | Error class |
|---|---|
400 | BadRequestError |
401 | AuthenticationError |
402 | QuotaExceededError |
403 | PermissionDeniedError |
404 | NotFoundError |
409 | ConflictError |
413 | PayloadTooLargeError |
415 | UnsupportedMediaTypeError |
422 | UnprocessableEntityError |
429 | RateLimitError (has retryAfter, in seconds) |
5xx | InternalServerError |
| no response | APIConnectionError, APIConnectionTimeoutError |
| you aborted | APIUserAbortError |
Every APIError has:
| Property | What it is |
|---|---|
status | The HTTP status. |
code | The error code, like content_too_long. See Errors. |
field | The part of the body (or the header) that caused it. |
details | Every field error, when more than one failed. |
requestId | Give this to support. |
error | The raw error object from the API. |
headers | The 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,503and504409with coderequest_in_progress500, 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) },
);| Option | What it does |
|---|---|
idempotencyKey | Your own key for this write. |
timeout | Timeout for each attempt, in milliseconds. |
maxRetries | Retries for this call only. |
signal | An AbortSignal to cancel the call. |
headers | Extra 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.