Node.js

The operate-as library for Node.js and TypeScript, with clients, actions, pass-through, errors and webhook verification.

operate-as is the official Node.js and TypeScript library. It needs Node 20 or newer and works in Bun and Deno. It's an ES module with types and no dependencies: it uses the built-in fetch and node:crypto. CommonJS code can require it on Node 20.19 or newer. Each service's actions come typed in a package of their own, such as @operate-as/sheets: install the ones you use.

Get started

Install

The library, and a package for each service you use:

npm install operate-as @operate-as/sheets

Create the client with your key

Keep the key from API & webhooks in an environment variable, never in code you share:

export OPERATE_AS_API_KEY="oa_..."
import { OperateAs } from "operate-as";

const operate = new OperateAs(process.env.OPERATE_AS_API_KEY);

Create one and reuse it. new OperateAs() with no key reads OPERATE_AS_API_KEY itself, and throws AuthenticationError straight away if there's none.

Several keys. Each OperateAs uses the key you give it, so a program can use several keys at once: make one per key. OPERATE_AS_API_KEY is only read when you pass no key. Each reaches only what its key allows.

const reporting = new OperateAs(process.env.OPERATE_AS_REPORTING_KEY); // reads sheets for the weekly report
const bookings = new OperateAs(process.env.OPERATE_AS_BOOKINGS_KEY); // writes to one client's calendar

Call

import { Sheets } from "@operate-as/sheets";

const miller = operate.client("6f1c2a7e-..."); // one of your clients, by id
const rows = await new Sheets(miller).readRows({ spreadsheetId: "1BxiM...", range: "A1:D20" });
console.log(rows.status, rows.data);

operate.client(clientId) sends nothing. The dashboard shows each client's id, and operate.clients.list() lists them with what each has connected:

const clients = await operate.clients.list();
// [{ id: "6f1c2a7e-...", name: "Miller Dental", services: ["sheets", "slack"] }]

Server-side only

Never use the library in browser code. Anyone who opens the page could read the key and call your clients' services as you.

Options

const operate = new OperateAs(process.env.OPERATE_AS_API_KEY, {
  baseUrl: "https://api.operate.as", // or set OPERATE_AS_BASE_URL
  timeout: 60_000, // milliseconds, the default
  fetch: myFetch, // your own fetch, for tests or proxies
});
OptionDefaultWhat it does
baseUrlOPERATE_AS_BASE_URL, then https://api.operate.asThe API's address, with or without /v1
timeout60000Milliseconds to wait before throwing APIConnectionError
fetchglobalThis.fetchThe fetch to call with, for tests, a proxy or a custom agent

Service packages

Every service with actions has a typed package: @operate-as/<service> on npm. Its class takes a client and has each action as a method:

import { AiInbx } from "@operate-as/ai-inbx";
import { Sheets } from "@operate-as/sheets";

const miller = operate.client(clientId);

await new AiInbx(miller).replyToEmail({
  emailId,
  body: { from: "kate@millerdental.com", html: "<p>Thanks for booking!</p>" },
});
const rows = await new Sheets(miller).readRows({ spreadsheetId: "1BxiM...", range: "A1:D20" });

Every input and body is typed and documented, so your editor completes them and flags a missing or misspelled one. Each class also has request() for any endpoint of the service's own API. The packages use the operate-as your app has, and install it if it doesn't.

You passA service the client hasn't connected
operate.client(clientId)Throws ReconnectRequiredError from the API
A client from operate.clients.list() or .get()Throws NotConnectedError before anything is sent, with what they do have in its message

Each integration's page in these docs names its package. A package's version moves on when its service's actions change. Without a package, client.service("sheets").run("read_rows", input) runs any action.

Actions and pass-through

const sheets = new Sheets(operate.client(clientId));
const rows = await sheets.readRows<{ values: string[][] }>({ spreadsheetId: "1BxiM...", range: "A1:D20" });
rows.status; // 200: the service's own status
rows.data.values; // the service's own response

Sheets comes from its package. operate.actions.run("sheets.read_rows", { clientId, input }) runs an action by name instead. Every action is on Actions.

The result

Every action and request resolve to a Result<T>:

interface Result<T = unknown> {
  status: number; // the service's own HTTP status
  data: T; // the service's own response
  encoding: "json" | "text" | "base64"; // how data came back
}

When the service answers with a file, result.encoding is "base64" and data is the file as a base64 string. Decode it with Buffer.from(result.data, "base64"). encoding is "json" for parsed JSON and "text" for plain text. See The result.

const result = await sheets.readRows({ spreadsheetId: "1BxiM...", range: "A1:D20" });
if (result.status >= 400) console.warn("Sheets said no:", result.status, result.data);

Errors

Every error is an OperateAsError:

ErrorStatusWhen it happens
InvalidRequestError400, 422An input is missing or the wrong type, or the path leaves the service's API
AuthenticationError401The key is wrong or revoked. Also thrown by the constructor with no key
PaymentRequiredError402The workspace has no plan: choose one in the dashboard
PermissionDeniedError403Outside the key's clients, services or actions, a service the client wasn't asked to connect, outside your plan, or the key is beyond your plan's key allowance, or the client paused the connection or allows only reads
NotFoundError404No such action, service or client
ReconnectRequiredError409The client hasn't connected this service, or their access ended
NotConnectedErrornoneThe client doesn't have this service connected, so the call wasn't sent. It's a ReconnectRequiredError too
RateLimitError429Your plan allows no more API calls this period
ServiceError502, 503The service didn't answer, or the provider can't renew access right now
APIConnectionErrornoneOperate As couldn't be reached, or the call timed out
SignatureVerificationErrornoneA webhook delivery failed verification

Any other status throws a plain OperateAsError. Every error has:

PropertyWhat it is
detailThe plain-words message
statusThe HTTP status, or undefined when no answer arrived
bodyThe parsed error body
headersThe response headers, with lower-case names
retryAfterRateLimitError only: seconds from Retry-After

message is detail with the status in front, such as 409: Miller Dental's Google access ended. .... APIConnectionError keeps the network failure as cause.

Catch reconnect-required

A client's access ending won't fix itself. Catch it, tell someone, and stop working for that client:

import { OperateAsError, RateLimitError, ReconnectRequiredError, ServiceError } from "operate-as";

try {
  await sheets.addRow({ spreadsheetId: "1BxiM...", range: "A1", values: ["2026-10-16", "Jane Doe"] });
} catch (error) {
  if (error instanceof ReconnectRequiredError) {
    await notifyTeam(`${clientId}: ${error.detail}`); // "...Send them their link to reconnect."
    await pauseWorkflow(clientId);
  } else if (error instanceof RateLimitError) {
    await new Promise((resolve) => setTimeout(resolve, (error.retryAfter ?? 60) * 1000));
  } else if (error instanceof ServiceError) {
    await retryLater();
  } else if (error instanceof OperateAsError) {
    console.error(error.status, error.detail);
  } else {
    throw error;
  }
}

See Retries for what's worth retrying.

Verify webhooks

OperateAs.webhooks.verify(payload, header, secret, options?) checks the Operate-As-Signature header and returns the event. Pass the raw body, before JSON.parse:

import express from "express";
import { OperateAs, SignatureVerificationError } from "operate-as";

const app = express();

app.post("/hooks/operate-as", express.raw({ type: "application/json" }), (req, res) => {
  let event;
  try {
    event = OperateAs.webhooks.verify(
      req.body, // the raw Buffer
      req.header("Operate-As-Signature"),
      process.env.OPERATE_AS_WEBHOOK_SECRET, // whsec_...
    );
  } catch (error) {
    if (error instanceof SignatureVerificationError) return res.sendStatus(400);
    throw error;
  }

  if (event.type === "connection.expired") {
    emailClient(event.data.clientName, event.data.reconnectUrl);
  }
  res.sendStatus(204);
});
PartDetail
payloadA string or Uint8Array (a Buffer works)
header, secretStraight from the request and process.env: a header sent twice uses its first value, and a missing secret throws
{ tolerance }How many seconds old a delivery may be. Default 300; null skips the check
ThrowsSignatureVerificationError for a missing or malformed header, a wrong signature, an old delivery, or a body that isn't JSON. Signatures are compared in constant time
OperateAs.webhooks.sign(payload, secret, timestamp?)Builds a valid header, for testing your handler
Also exportedwebhooks, SIGNATURE_HEADER, DEFAULT_TOLERANCE

See Webhooks for the events.

Types

TypeWhat it is
ClientOne of your clients: id, and service(name) to act for it
ConnectedClientA Client from operate.clients, with name and services
ServiceOne service for one client, with run and request
Action, ActionInputAn action and its inputs. ActionInput["type"] is "string" | "number" | "integer" | "boolean" | "array" | "object"
Result<T>{ status, data: T, encoding }
Page<T>{ items: T[], page, pageSize, total }, as actions.list returns it
RequestOptions, RunOptions, SearchOptions, ListActionsOptions, ListClientsOptionsThe options for request, actions.run, actions.search, actions.list and clients.list
OperateAsOptionsThe constructor's options
WebhookEvent, WebhookEventTypeA webhook delivery and its event names
Query, HttpMethod, FetchQuery parameters, methods, and the fetch signature

Every action, actions.run<T>() and request<T>() take a type for data. It's a promise about the service's response, not a check, so test status first.

On this page