Skip to content

Functions SDK

@onebooks/functions is a types-and-tooling package for authoring hosted functions — it adds no capability the runtime doesn’t have; it makes the ones it has type-safe, plus a local runner for fast iteration before you deploy with the CLI or the console. Zero runtime dependencies, ESM, Node 20 or later.

A hosted function is a single ES module. Any of these shapes works:

notify-on-paid.ts
import { defineFunction } from '@onebooks/functions';
export default defineFunction(async (event, ctx) => {
if (event.type !== 'invoice.paid') return;
const invoice = await ctx.api.get(`/invoices/${event.data.id}`);
ctx.log.info('invoice paid', invoice.invoiceNumber, invoice.total);
});
// or a named export:
export async function onEvent(event, ctx) { /* ... */ }
// or a plain default function:
export default async function (event, ctx) { /* ... */ }

defineFunction(handler) is an identity function — it returns the handler unchanged. Its only job is letting an editor infer event’s data type from defineFunction<MyEventData>((event, ctx) => …) without you writing out FunctionHandler<MyEventData> yourself. It does not register events or egress hosts — those are set when you create the function (console, or onebooks functions deploy --events … --egress …), not in code.

Whatever you write it in, upload one bundled .js ES module: the sandbox loads only that file, so compile TypeScript and bundle any dependencies first (esbuild, Rollup or similar). An upload that isn’t an ES module exporting a handler — CommonJS module.exports included — is rejected with FUNCTION_SOURCE_NO_HANDLER. That check only reads the source text, so it can’t prove the bundle loads: run a test once it’s active.

type FunctionHandler<T = unknown> = (
event: OneBooksEvent<T>,
ctx: FunctionContext,
) => Promise<void> | void;
interface OneBooksEvent<T = unknown> {
id: string;
type: string; // e.g. "invoice.paid"
apiVersion: string;
businessId: string;
createdAt: string;
data: T; // a compact snapshot — see the Event catalog for each type's shape
}
interface FunctionContext {
api: {
get<T>(path, init?): Promise<T>;
post<T>(path, body?, init?): Promise<T>;
put<T>(path, body?, init?): Promise<T>;
patch<T>(path, body?, init?): Promise<T>;
delete<T>(path, init?): Promise<T>;
fetch(path, init?): Promise<Response>; // escape hatch for the raw Response
};
log: { info(...args): void; warn(...args): void; error(...args): void };
business: { id: string };
app: { clientId: string };
run: { id: string };
}

ctx.api is pre-authenticated with a 5-minute run token scoped to the installation that owns the function — you never handle credentials yourself. It works the same way here, in runLocally() and in a hosted run:

  • It reaches only the OneBooks API — a relative path, or an absolute URL that starts with the API origin exactly. Anything else (another host or scheme, a protocol-relative //host or /\host) is refused before a request is made: the returned promise rejects with ctx.api may only call the OneBooks API (<origin>). Every method, ctx.api.fetch() included, rejects this way — none throws synchronously.
  • OneBooks-Version is sent only when there’s a version to send. In a hosted run, that’s your app’s pinned API version; an unpinned app’s requests carry no version header, and the API resolves the version as it does for any other request of your app. In runLocally(), it’s options.apiVersion, and nothing is sent when you leave that out. A OneBooks-Version in init.headers overrides either for that call.
  • init.headers override the defaults (Accept: application/json, and Content-Type: application/json when there’s a body) — except Authorization and Host, in any letter case, which are dropped; the token is always set last. ctx.api.fetch()’s init also takes method and a raw body, and its headers can be a Headers object or [name, value] pairs as well as a record.
  • body is sent as JSON. The JSON helpers resolve the parsed body (the text if it isn’t JSON), or null for an empty one.
  • A non-2xx response rejects with a FunctionApiError: an Error — ctx.api <METHOD> <path> failed with <status> — carrying status and body (parsed JSON, else the text, never truncated; null when empty). ctx.api.fetch() instead resolves the raw Response for any status.
  • Redirects are never followed: a 3xx comes back as-is — ctx.api.fetch() resolves it, and the JSON helpers reject with that status.
import { defineFunction, isFunctionApiError } from '@onebooks/functions';
export default defineFunction(async (event, ctx) => {
try {
await ctx.api.get(`/invoices/${event.data.id}`);
} catch (err) {
if (isFunctionApiError(err) && err.status === 404) return; // gone — nothing to do
throw err; // anything else fails the run, which is then retried
}
});

To reach a host you declared as egress, call the global fetch() — never ctx.api, and never with the run token. event.data’s shape matches the corresponding entry in the Event catalog — e.g. for invoice.paid, event.data.id is the invoice id.

Hosted limits (enforced by the real runner, not this package): 1 MB source, 50 ms CPU, 20 subrequests, 15 s wall-clock, about 5,000 runs per app per business per UTC day, and egress restricted to the OneBooks API plus whatever hosts you declared. What a run sends back is capped too: logs at 16 KB and 500 entries, the error message at 2,000 characters, and the return value at 8 KB (a larger one becomes a truncated preview — OneBooks doesn’t keep it either way); an answer over 64 KB in all fails the run. See Hosted functions.

Runs a handler in your own Node process for local development:

import { runLocally } from '@onebooks/functions';
import handler from './notify-on-paid.js'; // any of the export shapes above works
const result = await runLocally(
handler,
{
id: 'evt_test',
type: 'invoice.paid',
apiVersion: '2026-09-22',
businessId: 'biz_sandbox_1',
createdAt: new Date().toISOString(),
data: { id: 'inv_123', invoiceNumber: 'INV-0042', status: 'PAID', balanceDue: 0 },
},
{
accessToken: process.env.ONEBOOKS_ACCESS_TOKEN, // a token for a sandbox business
apiBase: 'https://api.getonebooks.com',
apiVersion: '2026-09-22', // only if your app is pinned: sent as OneBooks-Version, as a hosted run does
egressHosts: ['hooks.example.com'], // mirror whatever you declared for the real function
},
);
console.log(result); // { ok, error?, logs, durationMs }

runLocally never rejects: every failure (a thrown error, an unrecognized handler shape, a timeout) comes back as { ok: false, error: { message, stack? } }, so a caller has one shape to render. A refused fetch() isn’t a failure of the run — as in a hosted run, your handler gets a 403 response (see below). ctx.log.* calls are captured, not printed — they land in result.logs as { level, args, timestamp }[]. console.* output isn’t captured here; it prints as usual, whereas a hosted run captures it too (see Logs).

The two ways out of a handler are kept apart exactly as in the hosted runtime, with the same rules:

  • ctx.api reaches only the API — paths resolve against apiBase’s origin — and always carries options.accessToken, plus OneBooks-Version: <options.apiVersion> when you set that option (and no version header when you don’t).
  • While the handler runs, runLocally() wraps the global fetch() so it reaches only the API host (exact host and port) and the hosts in options.egressHosts (default []) over https:// on the default port — never with credentials in the URL, an IP address or a localhost name, and never with a token attached.

A refused fetch() behaves as it does in a hosted run: the promise resolves (it doesn’t reject) with an HTTP 403 response whose body is {"error":"Egress to this host is not allowed"} — and runLocally() also adds why it was refused to result.logs, as a warn entry. Redirects are followed hop by hop, with every hop checked again, so a redirect to a host that isn’t allowed ends in that 403; redirect: 'manual' hands you the 3xx instead. (If you pass your own options.fetch, the global fetch() is left untouched: that option replaces the fetch behind ctx.api, for tests.)

options.maxSubrequests (default 20, counting ctx.api calls) and options.wallMs (default 15000) are soft mirrors of the hosted limits; options.clientId and options.runId set ctx.app.clientId and ctx.run.id.

Hosted functions — the runtime contract, limits and deploy flow this package targets.