The official TypeScript SDK for the Curviate API. Agent-native LinkedIn infrastructure for AI engineers and agent builders.
Status: pre-1.0. Full v2 API parity; the surface is public but not yet stability-promised.
npm install @curviate/sdkRequires Node 18+. Works in Cloudflare Workers and Vercel Edge.
Get your API key from the Curviate dashboard. Store it as an environment variable.
import { Curviate } from "@curviate/sdk";
const curviate = new Curviate({
apiKey: process.env.CURVIATE_API_KEY!,
// Optional:
// baseUrl: "https://api.curviate.com", // default
// timeout: 30_000, // per-attempt timeout, ms
// maxRetries: 3, // GET/HEAD retries with backoff
});Every LinkedIn operation (messages, member profiles, invites, posts) is tied to a managed account, a LinkedIn session you have connected via the connect flow (curviate.auth.intent()). A new connect names where LinkedIn sees the account connecting from, with exactly one of country (e.g. "US"), ip or your own proxy:
await curviate.auth.intent({ seat_id, auth_method: "credentials", credentials: { email, password }, country: "US" });The curviate.account(id) accessor fixes the account_id on every call so you do not have to thread it manually:
// Root-level: tenant-wide operations (accounts, auth, webhooks, drafts)
const { items: accounts } = await curviate.accounts.list();
// Account-scoped: all LinkedIn ops under a specific account
const acc = curviate.account(accounts?.[0]?.account_id ?? "");
// Now every resource call is scoped to that account:
const { items: chats } = await acc.messaging.listChats();
const me = await acc.users.get("me"); // your own profile
const profile = await acc.users.get("some-user-id"); // someone else'sA Draft is a stored, editable, unpublished post. Give it an account and a scheduled_at and Curviate publishes it at that time. Drafts are tenant-wide, so drafts hangs off the root client (a Draft's account_id is a field, and optional). acc.posts.create() still publishes immediately and does not schedule.
const draft = await curviate.drafts.create({
account_id: "acc_...",
text: "Three things we learned shipping our first agent integration.",
scheduled_at: "2026-10-12T09:00:00+02:00", // ISO 8601 with offset; 5 min to 365 days ahead
});
// A video or PDF (up to 50 MiB) goes up as the raw body; images (up to 5 MiB) can ride inline as base64.
await curviate.drafts.uploadAttachment(draft.id, await readFile("clip.mp4"), {
filename: "clip.mp4",
contentType: "video/mp4",
});
await curviate.drafts.update(draft.id, { scheduled_at: null }); // unschedule
const { id: postId } = await curviate.drafts.publish(draft.id); // or publish now
const { items } = await curviate.drafts.list({ status: ["scheduled", "published"] });Refusals carry their own codes: DRAFT_LIMIT_REACHED, MEDIA_QUOTA_EXCEEDED, ACCOUNT_REQUIRED, DRAFT_NOT_PUBLISHABLE, SCHEDULE_CONFLICT and DRAFT_PUBLISHING. A scheduled publish that fails raises the post.publish_failed webhook (post.published on success); a failure_code of outcome_unknown means the post may be live, so check the account's posts before retrying.
This snippet lists your connected accounts, picks the first one, and sends a message, something an agent might do to automate outreach:
import { Curviate, isCurviateError } from "@curviate/sdk";
const curviate = new Curviate({ apiKey: process.env.CURVIATE_API_KEY! });
async function sendFirstMessage() {
// 1. List connected accounts
const { items: accounts } = await curviate.accounts.list();
if (!accounts || accounts.length === 0) throw new Error("No active accounts.");
const first = accounts[0]!;
const acc = curviate.account(first.account_id ?? "");
// 2. List recent chats
const { items: chats } = await acc.messaging.listChats({ limit: 5 });
if (!chats || chats.length === 0) return;
// 3. Send a message to the first chat
const chat = chats[0]!;
await acc.messaging.sendMessage(chat.id ?? "", {
text: "Hi, following up from our conversation.",
});
console.log("Message sent.");
}
sendFirstMessage().catch(console.error);Every API error is a CurviateError. Use isCurviateError to narrow in catch, then switch on err.code for exhaustive handling:
import { isCurviateError } from "@curviate/sdk";
try {
await acc.messaging.sendMessage("c_123", { text: "hello" });
} catch (err) {
if (!isCurviateError(err)) throw err; // re-throw network errors etc.
switch (err.code) {
case "RECIPIENT_UNREACHABLE":
console.warn("Recipient can't receive messages.");
break;
case "RATE_LIMIT_ACCOUNT":
// err.retryAfterMs tells you exactly how long to wait
await sleep(err.retryAfterMs ?? 5_000);
break;
case "BUDGET_EXHAUSTED":
// Curviate's OWN account-safety ceiling, not a request-rate limit:
// nothing reached LinkedIn and nothing was spent, so backing off is the
// wrong move. Wait until err.resetAt, or raise the setting the hint
// names on PATCH /v1/{account_id}/safety-policy. The ceiling comes from the
// account's safety policy or the tenant default (GET/PATCH /v1/safety-policy).
// resetAt is null in the two cases no clock frees: the pending_invites
// backlog, and an InMail credit pool LinkedIn regrants on its own schedule.
console.warn(`${err.budgetRow} is spent until ${err.resetAt ?? "no fixed time"}`);
console.warn(`change ${err.safetyHint?.parameter} to lift it`);
break;
case "ACCOUNT_NOT_FOUND":
console.error("Account does not exist for this tenant.");
break;
case "NOT_STORED":
// A cache_only read the store could not answer, so nothing was fetched.
// The id may be perfectly good, so do not go looking for it again:
// re-read with mode "refill" or "auto", and on a narrowed listing drop
// the narrowing parameters. See "Retrieval modes" below.
console.warn("Nothing stored for that resource under this mode.");
break;
default:
if (err.retryLikelyToSucceed) {
// Safe to retry: server-side transient error
await retry();
}
}
}Every error code is documented in the API reference. The
exported ErrorCode type is the complete set, so tsc tells you when a switch over
err.code has missed one.
A 403 from this API is one of three completely separate refusals. They read alike
in prose and they are fixed in three different places, so branch on err.code and
never on the message.
| Code | What is missing | Who fixes it, and where |
|---|---|---|
NO_ACTIVE_SEAT |
Your Curviate workspace has no active paid seat covering the account. | You, in Curviate billing. Buy or attach a seat, then retry. |
LINKEDIN_FEATURE_NOT_SUBSCRIBED |
The LinkedIn account itself lacks the LinkedIn subscription the operation needs, such as Sales Navigator or Recruiter. | The account owner, on LinkedIn. No amount of Curviate billing lifts this one. |
BETA_NOT_ENABLED |
The operation is beta-gated and your workspace has not opted into beta operations. | A human, in Settings ("Allow beta operations"), or per request with the X-Curviate-Beta header. |
There is no product tier. One ordinary paid seat entitles the whole API surface, Sales Navigator and Recruiter included. Nothing on a seat names a product, no refusal asks you to upgrade a plan, and there is no field on the error saying which tier to buy, because there are no tiers to buy. What a premium namespace still needs is the LinkedIn account's own subscription, which is the second row above.
try {
await acc.salesNavigator.searchPeople({ keywords: "cto" });
} catch (err) {
if (!isCurviateError(err)) throw err;
switch (err.code) {
case "NO_ACTIVE_SEAT":
// Curviate-side. Attach a seat in Billing.
break;
case "LINKEDIN_FEATURE_NOT_SUBSCRIBED":
// LinkedIn-side. The account needs its own Sales Navigator subscription.
break;
case "BETA_NOT_ENABLED":
// Consent-side. A human enables beta in Settings for this workspace.
break;
}
}Request validation runs before every entitlement check, so INVALID_REQUEST tells
you nothing about entitlement. A malformed body is rejected with 400 INVALID_REQUEST while the seat, the LinkedIn subscription and beta consent are all
still unexamined. A caller that reads a 400 as "my request was fine, my entitlement
is not" has it backwards: fix the request, send it again, and only then does a 403
mean anything about what you are entitled to. The reverse holds too, and is the more
useful half: a 403 from one of the three codes above proves the request itself
parsed and validated cleanly.
Some operations are marked beta, because they have not been exercised against a real
LinkedIn subscription yet and their response shapes may still move. Every beta method
in this SDK carries an @beta tag in its JSDoc, so your editor tells you before you
call it; whole namespaces that are beta say so on the namespace as well.
The Sales Navigator, Recruiter and inboxes surfaces are beta in full today. Beta is
per operation, not per namespace, so a mostly-stable namespace can carry a beta method:
companies.chats() and companies.searchChats() are beta while the rest of
companies is not. Read the tag on the method you are calling rather than inferring it
from its neighbours.
The badge is a superset of the gate: an operation can be badged beta and still be
callable by anyone. A beta-GATED operation is the narrower set that actually refuses
BETA_NOT_ENABLED until a human enables beta operations for the workspace in
Settings, or the individual request carries X-Curviate-Beta: true. Because the two
move independently, read the code on a refusal rather than inferring the gate from
the badge.
Some reads can be answered from Curviate's own store instead of a live LinkedIn
fetch. Those reads take the same two query parameters, mode and max_age:
users.get(), includingusers.get("me")(GET /v1/{account_id}/users/{user_id})messaging.getChat()(GET /v1/{account_id}/chats/{chat_id})messaging.listMessages()(GET /v1/{account_id}/chats/{chat_id}/messages)
No other read accepts them, so do not pass them elsewhere. These two keys are
refused rather than ignored: sending either to a read that does not declare it
is a 400, and so is sending either one twice. A read that accepted
mode=cache_only and then called LinkedIn anyway would break the one guarantee
that parameter makes, so neither case is resolved quietly.
mode |
What the read does |
|---|---|
auto (default) |
serves a stored copy while it is inside the resource's freshness threshold, otherwise fetches |
live |
always fetches |
refill |
serves a stored copy at any age, and fetches once when this read has none |
cache_only |
never fetches, and throws NOT_STORED when the store cannot answer |
max_age is the mechanism the first three are presets over: the oldest stored
copy, in seconds, the read will accept. It overrides them in both directions,
and max_age: 0 is the same as mode: "live". It cannot be combined with
cache_only, whose guarantee is not a freshness threshold; that pair is
rejected with INVALID_REQUEST rather than one of the two being quietly
dropped.
// Serve whatever is stored, at any age; reach LinkedIn only if nothing is.
const chat = await acc.messaging.getChat("chat_1", { mode: "refill" });
// Never reach LinkedIn. Throws NOT_STORED when the store holds nothing.
const profile = await acc.users.get("me", { mode: "cache_only" });
// Accept a stored copy up to five minutes old, fetch otherwise.
const page = await acc.messaging.listMessages("chat_1", { max_age: 300 });Every one of these responses carries fields that say what you are holding:
sourceis"store"or"live", andobserved_atis when the data was seen on LinkedIn. A stored answer can carry less than a live one, because some fields are dropped before anything is written, sosource: "store"is how you know to ask again withmode: "live"when a field you need is missing.withdrawnis always present, never inferred from a missing field.truemeans LinkedIn has said the resource is gone, such as a removed profile, and the answer you are holding is the copy Curviate still has: do not act on it.withdrawn_atrides along when it istrue. This is the field to branch on before messaging or acting on anything served from the store, andrefill, which serves a copy at any age, is the mode most likely to hand you one.
NOT_STORED under cache_only does not always mean the resource is unknown.
On listMessages it also fires when the request narrows the page in a way the
stored copy cannot reproduce, so a fully stored chat can refuse a narrowed
cache_only read. Re-read without the narrowing parameters, or with a mode that
may fetch.
Resources that return lists support cursor pagination. curviate.paginate() is an async iterator that follows the cursor field automatically, so you pull items one at a time without managing cursors:
// Iterate over every chat across all pages
for await (const chat of curviate.paginate(acc.messaging.listChats.bind(acc.messaging), {})) {
console.log(chat.id);
}
// With initial params
for await (const account of curviate.paginate(
curviate.accounts.list.bind(curviate.accounts),
{ limit: 50 },
)) {
console.log(account.account_id);
}Register a webhook to receive real-time events, then verify each delivery with constructEvent:
import { constructEvent, WebhookSignatureError } from "@curviate/sdk";
// Express (Node 18+)
app.post("/webhook", express.raw({ type: "application/json" }), async (req, res) => {
const sig = req.headers["curviate-signature"] as string;
const secret = process.env.CURVIATE_WEBHOOK_SECRET!;
let event;
try {
event = await constructEvent(req.body, sig, secret);
} catch (err) {
if (err instanceof WebhookSignatureError) {
// 'invalid_signature' | 'replay_detected' | 'malformed_header' | 'malformed_payload'
console.warn("Bad webhook:", err.reason);
return res.sendStatus(400);
}
throw err;
}
switch (event.event) {
case "message.received":
// event.data is MessagePayload: account_id, message_id, and so on.
handleNewMessage(event.data);
break;
case "account.connected":
handleAccountConnected(event.data);
break;
// Every other event type is a case in the CurviateEvent union.
}
res.sendStatus(200);
});
// Hono / Vercel Edge. Always await; Web Crypto is async.
app.post("/webhook", async (c) => {
const rawBody = await c.req.text();
const event = await constructEvent(rawBody, c.req.header("curviate-signature")!, secret);
return c.text("ok");
});constructEvent always returns a Promise<CurviateEvent>. Always await it.
Each event also carries its delivery metadata: event.id, event.webhook_id, and
event.delivered_at.
Delivery is at-least-once, so your handler can see the same logical event more than
once. The key that survives a retry is the payload composite event.event plus
event.data.account_id plus event.data.occurred_at, which the platform re-sends
byte-identical on every attempt:
const key = `${event.event}:${event.data.account_id}:${event.data.occurred_at}`;
if (await alreadyProcessed(key)) return res.sendStatus(200);Do not key on event.id. That is a wdl_ id minted per delivery attempt, so a
retry arrives with a different one and storing it would record the same event twice,
missing exactly the duplicates you were guarding against. It is still worth logging:
event.id is how you match one attempt to one line in your delivery logs, keyed
alongside the stable occurred_at composite above.
WebhookSignatureError is NOT a CurviateError. Narrow with instanceof WebhookSignatureError.
Its reason tells you where to look: malformed_header means the signature header could not be
read, invalid_signature means the HMAC did not match (check the secret, and check that you passed
the raw request bytes), replay_detected means the event is outside the replay window, and
malformed_payload means the signature was valid but the body was not a Curviate event, so
your secret and header are both fine.
Upgrading from 0.18.x or earlier? The event discriminant is
event.event, notevent.type. See the 0.19.0 entry in CHANGELOG.md; the change is a one-word edit and it breaks no working code, becauseconstructEventnever returned successfully before 0.19.0.
- API reference: https://docs.curviate.com
- Issues: https://github.com/curviate/curviate-sdk/issues
- Changelog: CHANGELOG.md
MIT © Redmer Holding GmbH