SalesOS

TypeScript SDK

@salesos/sdk — typed resources, retries, idempotency, pagination, OAuth helpers and webhook verification for Node 18+ and browsers.

@salesos/sdk is the official TypeScript client. Its types are generated from the OpenAPI document and the event catalog, and it implements the contracts in these guides for you: retries and rate limits, idempotency keys, pagination, errors, OAuth and webhook signatures.

It has no runtime dependencies and uses the platform's fetch and Web Crypto, so the same package runs in Node 18+, Deno, Bun, workers and browsers. It ships ES modules, CommonJS and type declarations.

The SDK is not on npm yet. Until it is, build it from the SalesOS repository (packages/sdk-js) and install it from a path or tarball (npm pack).

Quick start

import { SalesOS } from '@salesos/sdk';

const salesos = new SalesOS({ apiKey: process.env.SALESOS_API_KEY });

const lead = await salesos.leads.create({ firstName: 'Ada', lastName: 'Lovelace', company: 'Analytical' });
await salesos.leads.update(lead.id, { status: 'CONTACTED' });

for await (const deal of salesos.opportunities.iterate({ owner: 'me', open: true })) {
  console.log(deal.name, deal.amount);
}
OptionDefault
apiKeySent as X-API-Key
accessTokenOAuth access token, or a function returning the current one
authAny credential provider (see Authentication)
baseUrlhttps://www.salesos.orgPaths get /v1 added when they don't carry it
timeoutMs30000Per attempt
maxRetries2Extra attempts after the first
maxRetryDelayMs30000A longer Retry-After is not waited for
autoIdempotencytrueAdds an Idempotency-Key to every POST/PATCH
organizationIdSent as X-Organization-ID
fetchglobalThis.fetchBring your own (tests, proxies)
onRetryCalled before each retry, for logging

Resources

Each core object has list, iterate, pages, get, create, update and delete, with request and response types from the OpenAPI document:

leads, contacts, accounts, opportunities, tasks, quotes, orders, cases, and activities (no update; activities are immutable).

ResourceMethods
customObjectslist, get, create, update, delete, records(objectId) → list / iterate / create / update / delete
webhookslist, get, create, update, delete, activate, deactivate, rotateSecret, test(id, type), deliveries, delivery, replay
eventscatalog(), get(type) (no credentials needed)
bulkcreateJob, getJob, listJobs, cancelJob, waitForJob, results, failures
customActionslist, get, create, update, delete, rotateSecret, test(id, input), run, invocations
oauthAppslist, get, create, update, rotateSecret, delete (session only)
developerOrgslist, current, create, open, leave, delete, components, createChangeSet (session only)
metadatatypes, retrieve, deploy, runTests, listDeploys, getDeploy, checkAgain, cancel, waitForDeploy, getTestPolicy, setTestPolicy
functionslist, get, create, update, delete, versions, createVersion, deploy, rollback, setSecret, deleteSecret, test, invoke, invocations, invocation
eventStreamlisten, trigger (see listen)
apiUsagesummary

Everything else is one call away:

const pipelines = await salesos.request('GET', '/v1/pipelines');
const { data, status, headers, requestId } = await salesos.requestWithResponse('POST', '/v1/leads/{id}/convert', {
  pathParams: { id: lead.id },
  body: { createOpportunity: true },
});

Or by operationId, fully typed from the spec. The operation table lives in a subpath so it stays out of bundles that don't use it:

import { callOperation } from '@salesos/sdk/operations';

const stats = await callOperation(salesos, 'leads_getStats', {});

Pagination

list() returns one page in the list envelope (data, page, limit, total, totalPages, hasMore). iterate() walks every page lazily, 100 records per request unless you pass limit (the server caps it at 200):

for await (const contact of salesos.contacts.iterate({ search: 'acme' }, { maxItems: 500 })) {
  // …
}

for await (const page of salesos.accounts.pages({ limit: 200 })) {
  console.log(`page ${page.page}/${page.totalPages}`);
}

Errors

Every failure is a SalesOSError:

Field
statusHTTP status, or 0 when no response arrived
codeThe API's stable code (for example VALIDATION_RULE_FAILED), the OAuth error, HTTP_<status>, or NETWORK_ERROR / TIMEOUT / ABORTED
messageValidation messages are joined with ;
requestIdQuote it to support. The SDK sends an X-Request-ID on every request, so there is always one
retryAfterMs, body, headers, method, url
import { SalesOSError } from '@salesos/sdk';

try {
  await salesos.opportunities.update(id, { stage: 'CLOSED_WON' });
} catch (err) {
  if (err instanceof SalesOSError && err.code === 'STAGE_REQUIREMENTS_NOT_MET') {
    console.log((err.body as { missingFields: string[] }).missingFields);
  } else throw err;
}

Retries and idempotency

  • GET, HEAD and OPTIONS are retried on network errors, timeouts, 408, 500, 502, 503 and 504, with exponential backoff and jitter.
  • A Retry-After header is honoured (seconds or an HTTP date). If it is longer than maxRetryDelayMs the error is thrown instead, with retryAfterMs set.
  • 429 with Retry-After is retried for any method: the request was refused before it ran.
  • Writes are retried only when they carry an Idempotency-Key. With autoIdempotency (the default) every POST and PATCH gets a random key, reused across its retries, so a retry replays the first response instead of creating a duplicate. A 409 IDEMPOTENCY_REQUEST_IN_PROGRESS waits and tries again.
  • PUT and DELETE are not retried unless you pass idempotent: true. Multipart uploads are never retried, because the server doesn't deduplicate them.
await salesos.orders.create(order, { idempotencyKey: `order-${externalId}` }); // your own key
await salesos.leads.create(lead, { idempotencyKey: false });                    // none
await salesos.leads.list({}, { timeoutMs: 5000, signal: controller.signal });

Authentication

import { SalesOS, OAuthClient, ClientCredentialsAuth, OAuthTokenAuth, SessionAuth } from '@salesos/sdk';

new SalesOS({ apiKey: 'sk_…' });
new SalesOS({ accessToken: async () => getTokenFromMyStore() });

Client credentials

The token is fetched on first use and again a minute before it expires; concurrent requests share one fetch, and a 401 triggers one fresh token.

const oauth = new OAuthClient({ clientId: process.env.CLIENT_ID!, clientSecret: process.env.CLIENT_SECRET! });
const salesos = new SalesOS({ auth: new ClientCredentialsAuth(oauth, { scope: 'READ_LEADS WRITE_TASKS' }) });

Authorization code + PKCE

const oauth = new OAuthClient({ clientId: 'sos_ci_…' }); // public client: no secret

// 1. Send the user to SalesOS. Keep state and codeVerifier (e.g. in the session).
const req = await oauth.createAuthorizationRequest({
  redirectUri: 'https://app.example.com/oauth/callback',
  scope: ['openid', 'email', 'offline_access', 'READ_LEADS'],
});
redirect(req.url);

// 2. On the callback: validates state, iss and error, then exchanges the code.
const code = oauth.parseCallback(callbackUrl, req.state);
const tokens = await oauth.exchangeCode({ code, redirectUri: req.redirectUri, codeVerifier: req.codeVerifier });

// 3. Use and refresh. Refresh tokens rotate: persist every new pair.
const salesos = new SalesOS({
  auth: new OAuthTokenAuth(
    oauth,
    { accessToken: tokens.access_token, refreshToken: tokens.refresh_token, expiresAt: tokens.expiresAt },
    { onTokens: (t) => saveTokens(userId, t) },
  ),
});

OAuthTokenAuth refreshes a minute before expiry and once after a 401, and runs one refresh at a time. That matters: presenting a refresh token that was already used revokes the whole token family, so two parallel refreshes would sign the user out. The token endpoint itself is never retried for the same reason.

oauth.refresh(), oauth.revoke(token, hint), oauth.introspect(token) and oauth.userinfo(accessToken) are available directly too.

Sessions

OAuth app management and developer orgs refuse API keys and OAuth tokens. For those, SessionAuth sends a signed-in user's token and adds the CSRF token to writes. developerOrgs.open() and leave() return a new session, and the client switches to it automatically.

const auth = new SessionAuth({ accessToken, csrfToken });
const salesos = new SalesOS({ auth });
await salesos.developerOrgs.open(devOrgId); // later calls run in the developer org

Webhooks

Verify against the raw body, before any JSON parsing:

import express from 'express';
import { constructWebhookEvent, verifyWebhookSignature } from '@salesos/sdk';

app.post('/salesos/webhooks', express.raw({ type: 'application/json' }), async (req, res) => {
  let event;
  try {
    event = await constructWebhookEvent(req.body, req.headers, process.env.SALESOS_WEBHOOK_SECRET!);
  } catch {
    return res.sendStatus(400);
  }
  switch (event.type) {
    case 'opportunity.won':
      console.log(event.data.record.amount); // typed from the event catalog
      break;
  }
  res.sendStatus(204);
});

// Or just the boolean (Fetch API handlers):
const ok = await verifyWebhookSignature(await request.text(), request.headers, secret, { toleranceSec: 300 });

headers can be a Headers object, a Node header record or the X-SalesOS-Signature value itself. Timestamps more than toleranceSec (default 300) from your clock are rejected, and any matching v1= is accepted, so a rotated secret keeps working during the overlap. Dedupe on event.id: it is stable across retries and replays. signWebhookPayload() produces the same header SalesOS sends, for your own tests.

Types: WebhookEvent<'lead.created'>, AnyWebhookEvent (a discriminated union), WebhookEventDataMap, WEBHOOK_EVENT_TYPES, and LegacyWebhookEvent for webhooks still on payloadVersion: 1.

Bulk jobs

import { readFile } from 'node:fs/promises';

const job = await salesos.bulk.createJob({
  object: 'contact',
  operation: 'upsert',
  externalIdField: 'email',
  file: await readFile('contacts.csv'),
  fileName: 'contacts.csv',
});
const done = await salesos.bulk.waitForJob(job.id, {
  onProgress: (j) => console.log(`${j.processedRows}/${j.totalRows}`),
});
if (done.failedRows) console.log(await salesos.bulk.failures(done.id));

Inline input works too: { object: 'lead', operation: 'insert', records: [...] }. See Bulk data for formats and limits.

Types

Every component schema is available as Schemas['Name'], and every operation as Operations['operationId'] (pathParams, query, body, response, scopes). Shortcuts exist for the core objects: Lead, LeadCreate, LeadUpdate, LeadListQuery, and the same for the other resources.

API_VERSION is the spec version the types were generated from; responses carry the server's own in the API-Version header (see versioning).

Metadata, functions and live events

const pkg = await salesos.metadata.retrieve();                 // { files: { path: text }, … }
const queued = await salesos.metadata.deploy({ files: pkg.files, checkOnly: true });
const done = await salesos.metadata.waitForDeploy(queued.id);  // results per component

// Tests: a check-only deploy that runs the package's tests (runTests 'SPECIFIED' | 'ALL')
const run = await salesos.metadata.runTests({ files: pkg.files, runTests: 'ALL' });
const tested = await salesos.metadata.waitForDeploy(run.id);    // tested.testResults.results[]
await salesos.metadata.getTestPolicy();                         // { requireTestsFor: ['FUNCTION', 'FLOW'] }

await salesos.functions.invoke('score_lead', { leadId });
for await (const frame of salesos.eventStream.listen({ events: ['lead.created'] })) {
  if (frame.event === 'event') console.log(JSON.parse(frame.data));
}
const usage = await salesos.apiUsage.summary({ days: 30 });

These resources use hand-written types until the next type regeneration from the live spec. METADATA_TYPES lists every component type the metadata API takes (MetadataType is its union). See Metadata as code and Testing.

On this page