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/sheetsCreate 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 calendarCall
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
});| Option | Default | What it does |
|---|---|---|
baseUrl | OPERATE_AS_BASE_URL, then https://api.operate.as | The API's address, with or without /v1 |
timeout | 60000 | Milliseconds to wait before throwing APIConnectionError |
fetch | globalThis.fetch | The 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 pass | A 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 responseSheets 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:
| Error | Status | When it happens |
|---|---|---|
InvalidRequestError | 400, 422 | An input is missing or the wrong type, or the path leaves the service's API |
AuthenticationError | 401 | The key is wrong or revoked. Also thrown by the constructor with no key |
PaymentRequiredError | 402 | The workspace has no plan: choose one in the dashboard |
PermissionDeniedError | 403 | Outside 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 |
NotFoundError | 404 | No such action, service or client |
ReconnectRequiredError | 409 | The client hasn't connected this service, or their access ended |
NotConnectedError | none | The client doesn't have this service connected, so the call wasn't sent. It's a ReconnectRequiredError too |
RateLimitError | 429 | Your plan allows no more API calls this period |
ServiceError | 502, 503 | The service didn't answer, or the provider can't renew access right now |
APIConnectionError | none | Operate As couldn't be reached, or the call timed out |
SignatureVerificationError | none | A webhook delivery failed verification |
Any other status throws a plain OperateAsError. Every error has:
| Property | What it is |
|---|---|
detail | The plain-words message |
status | The HTTP status, or undefined when no answer arrived |
body | The parsed error body |
headers | The response headers, with lower-case names |
retryAfter | RateLimitError 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);
});| Part | Detail |
|---|---|
payload | A string or Uint8Array (a Buffer works) |
header, secret | Straight 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 |
| Throws | SignatureVerificationError 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 exported | webhooks, SIGNATURE_HEADER, DEFAULT_TOLERANCE |
See Webhooks for the events.
Types
| Type | What it is |
|---|---|
Client | One of your clients: id, and service(name) to act for it |
ConnectedClient | A Client from operate.clients, with name and services |
Service | One service for one client, with run and request |
Action, ActionInput | An 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, ListClientsOptions | The options for request, actions.run, actions.search, actions.list and clients.list |
OperateAsOptions | The constructor's options |
WebhookEvent, WebhookEventType | A webhook delivery and its event names |
Query, HttpMethod, Fetch | Query 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.