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.
Install
Section titled “Install”Writing a function
Section titled “Writing a function”A hosted function is a single ES module. Any of these shapes works:
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.
The handler contract
Section titled “The handler contract”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
//hostor/\host) is refused before a request is made: the returned promise rejects withctx.api may only call the OneBooks API (<origin>). Every method,ctx.api.fetch()included, rejects this way — none throws synchronously. OneBooks-Versionis 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. InrunLocally(), it’soptions.apiVersion, and nothing is sent when you leave that out. AOneBooks-Versionininit.headersoverrides either for that call.init.headersoverride the defaults (Accept: application/json, andContent-Type: application/jsonwhen there’s a body) — exceptAuthorizationandHost, in any letter case, which are dropped; the token is always set last.ctx.api.fetch()’sinitalso takesmethodand a rawbody, and its headers can be aHeadersobject or[name, value]pairs as well as a record.bodyis sent as JSON. The JSON helpers resolve the parsed body (the text if it isn’t JSON), ornullfor an empty one.- A non-2xx response rejects with a
FunctionApiError: anError—ctx.api <METHOD> <path> failed with <status>— carryingstatusandbody(parsed JSON, else the text, never truncated;nullwhen empty).ctx.api.fetch()instead resolves the rawResponsefor any status. - Redirects are never followed: a
3xxcomes 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.
runLocally(handler, event, options)
Section titled “runLocally(handler, event, options)”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.apireaches only the API — paths resolve againstapiBase’s origin — and always carriesoptions.accessToken, plusOneBooks-Version: <options.apiVersion>when you set that option (and no version header when you don’t).- While the handler runs,
runLocally()wraps the globalfetch()so it reaches only the API host (exact host and port) and the hosts inoptions.egressHosts(default[]) overhttps://on the default port — never with credentials in the URL, an IP address or alocalhostname, 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.
Where next
Section titled “Where next”Hosted functions — the runtime contract, limits and deploy flow this package targets.