Skip to content

Hosted functions

A hosted function is JavaScript you upload to OneBooks that runs automatically whenever an event you subscribed to fires — no server, no polling, no webhook endpoint to keep online. OneBooks runs it for you, in an isolated sandbox, once per matching event per installation.

  1. Something happens in a business — an invoice is paid, a customer is created — and OneBooks records the event.
  2. For every active function subscribed to that event, of every app installed in that business whose installation holds the event’s gating scope (and that isn’t suspended or deactivated), OneBooks queues one run — never more than one per function per event. An installation without the scope gets no run at all, not a skipped one.
  3. The run executes in the sandbox with a short-lived run token, and is retried if it fails.

Functions receive business and privacy events. The app-lifecycle events (app.installed, app.uninstalled, app.scopes_updated, business.redact, app_data.updated) are webhook-only: a function runs inside an installation, and those events are about the installation itself.

Functions run in V8 isolates on Cloudflare’s Workers runtime (with its Node.js compatibility layer) — not in a Node.js process, and never on OneBooks’ own API servers. Each function version is loaded into its own isolate, which may be reused from one run to the next; don’t keep state in module-level variables and expect it to survive. Because runs of the same version can share that isolate, code that patches built-ins or exhausts memory can break its own later runs — for every business that installed your app — but never another app’s.

  • One file. Upload a single, self-contained ES module that exports your handler — onEvent, or a default export that’s a function or { onEvent }. Bundle any npm dependencies into it first — nothing else is loaded alongside it. CommonJS (module.exports) is rejected at upload with FUNCTION_SOURCE_NO_HANDLER. That upload check only reads the source text, so run a test to confirm the module actually loads.
  • No environment variables or secrets store. Keep per-business settings your function needs as app data — a field on the BUSINESS resource works well (it needs app-data:read and profile:read) — and read them with ctx.api.
  • Standard web APIs — fetch, Request/Response, URL, TextEncoder, Web Crypto and friends — are available as in any Worker.
export async function onEvent(event, ctx) {
// event: { id, type, apiVersion, businessId, createdAt, data }
if (event.type !== 'invoice.paid') return;
// The OneBooks API: through ctx.api, which carries the run token.
const invoice = await ctx.api.get(`/invoices/${event.data.id}`);
ctx.log.info('processing invoice', invoice.invoiceNumber);
// A declared egress host: through the global fetch(), which never does.
const res = await fetch('https://hooks.example.com/notify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ invoiceNumber: invoice.invoiceNumber, total: invoice.total }),
});
if (!res.ok) ctx.log.warn('notify failed', res.status);
}
// Also accepted: export default { onEvent } or export default async (event, ctx) => {}

event.data is the event’s compact payload, shaped as in the Event catalog — fetch the full record through ctx.api when you need more. A handler that returns normally succeeds; one that throws (or rejects) fails the run. There’s nothing to return: OneBooks doesn’t keep a handler’s return value. The sandbox still serializes it once — a value over 8 KB is replaced by a truncated preview, and one that can’t be serialized doesn’t fail the run — so return nothing rather than spend CPU time on it.

ctx memberPurpose
ctx.api.get(path, init?) / ctx.api.delete(path, init?)Call the OneBooks API; resolve the parsed JSON body
ctx.api.post/put/patch(path, body?, init?)The same, sending body as JSON
ctx.api.fetch(path, init?)Lower level — resolves the raw Response
ctx.log.info/warn/error(...)Log lines, captured with the run — see Logs
ctx.business.idThe business the triggering event happened in
ctx.app.clientIdYour app’s public client_id
ctx.run.idThis run’s id, for correlation
  • OneBooks API only. path is a relative path (/invoices/…, or invoices?page=2 without the leading slash) or an absolute URL that starts with the API origin exactly. Anything else — another host or scheme, a protocol-relative //host or /\host — rejects before any request is made, with ctx.api may only call the OneBooks API (<origin>). Every method, ctx.api.fetch() included, reports that as a rejected promise, never a synchronous throw. A declared egress host is reached with the global fetch() instead.
  • Headers. Requests carry Accept: application/json and, when there’s a body, Content-Type: application/json. OneBooks-Version is sent only when your app is pinned to an API version (the console’s API version setting), with the pinned version. An unpinned app’s requests carry no version header, and the API resolves their version as it does for any other request of your app. Headers you pass in init.headers — a record, a Headers object or [name, value] pairs — override those defaults, so a OneBooks-Version there sets the version for that one call. Authorization and Host, in any letter case, are dropped, and the run token is set last, as Authorization: Bearer … — so it can be neither replaced nor sent elsewhere.
  • Responses. The JSON helpers resolve with the parsed JSON body, null for an empty body (a 204, say), or the raw text if the body isn’t JSON.
  • Errors reject. A non-2xx response rejects with an Error (ctx.api <METHOD> <path> failed with <status>) carrying status and body — the parsed JSON when possible, otherwise the text (never truncated), or null when empty — so you can branch on err.status and the API’s error code in err.body.code. With @onebooks/functions, isFunctionApiError(err) narrows the type.
  • No redirects. ctx.api never follows one: a 3xx comes back as-is — ctx.api.fetch() resolves it, and the JSON helpers reject with that 3xx status.
  • ctx.api.fetch(path, init?) is the escape hatch: init can also carry method and a raw body, the same origin rule, token and header handling apply, and it resolves the raw Response for any status — it never rejects on one, so check res.ok yourself.

runLocally() mirrors this contract for local testing.

ctx.log.info/warn/error(...) and four console methods are captured with the run: console.log and console.info as info, console.warn as warn, console.error as error. Other console methods (console.debug, console.table, …) aren’t kept. A line’s arguments are joined with spaces — strings as they are, errors by their stack, other objects as JSON.

A run keeps only info, warn and error entries, at most 500 of them and at most 16 KB in all. Once the 16 KB cap is reached, later lines are dropped and a Log output truncated marker is added. The run token is replaced by [REDACTED] wherever it appears in a run’s logs or error. Logs show on the console’s Functions tab and in onebooks functions logs; merchants never see them.

List every outside host your function calls as the function’s egressHosts — up to 10, each an exact, lowercase, fully-qualified hostname (no wildcards, no subdomain matching, no IP addresses, nothing on localhost, .local or .internal; the OneBooks API host is always allowed and must not be listed). Your code reaches them with the global fetch():

  • Only https:// on the default port — http:// and custom ports are refused even for a declared host.
  • fetch() never carries the run token; send whatever credentials the outside service needs yourself.
  • A request that isn’t allowed — to a host you didn’t declare, over http://, on a custom port — never leaves the sandbox, and it doesn’t reject either: the fetch() promise resolves with an HTTP 403 response whose body is {"error":"Egress to this host is not allowed"}. Check res.ok or res.status.
  • Redirects are followed one hop at a time, and every hop is checked again against the same rules: a hop to an undeclared host, to plain http:// or to an IP address gets that 403 response instead of being followed.

Merchants see the declared hosts before they install: your marketplace listing shows them under Where your data goes, and the app’s Data tab in their Installed apps drawer lists them too. Both always show the live union of your active functions’ egressHosts, so keep the list accurate and minimal.

LimitValue
Functions per app20
Source size1 MB, one ES module
Events per functionAt least 1, and at most every event a function can receive (the 40 business and privacy events); each one’s gating scope must be one your app requests
Declared egress hosts10 per function
CPU time50 ms per run
Subrequests20 per run — every ctx.api call and every fetch() counts
Wall-clock timeout15 s
RunsAbout 5,000 per app, per business, per UTC day — all of the app’s functions together
Logs16 KB and 500 entries per run, info/warn/error only — once the size cap is hit, later lines are dropped and a Log output truncated marker is added (Logs)
Return value8 KB, serialized once — a larger one is replaced by a truncated preview (OneBooks doesn’t keep it either way)
Error message2,000 characters — a longer one is cut off
Run answer64 KB — everything a run sends back (result, logs and error together); a larger answer fails the run

Exceeding the CPU, subrequest or wall-clock limit fails the run (and it’s retried), and so does an answer over 64 KB — which a normal run never comes near, since its result, logs and error are capped well below it. The daily cap counts every run created that day in that business — whatever its status, console test runs included — and once an app reaches it, each further event that day is recorded as a SKIPPED run with Daily run limit reached. The cap is approximate: events arriving at the same moment are counted side by side, so a burst can take an app slightly past 5,000 before runs start being skipped.

Developer console → your app → Functions → create a function: a name (2–40 characters — lowercase letters, numbers and hyphens, starting with a letter), an optional description (blank it later to clear it), the events it subscribes to and its egress hosts. Then upload the source as a new version and activate it (the upload dialog can activate immediately). Only one version is active at a time; uploading a new one doesn’t affect the live version until you activate it, and activating an older version rolls back. Changing functions needs org admin rights.

Trigger a synthetic run against a sandbox business without waiting for a real event — from the function’s Run test panel in the console, or:

Terminal window
onebooks functions test --app $APP_ID --name sync-invoices \
--business $SANDBOX_BUSINESS_ID --event invoice.paid

The business must be one of your organization’s sandboxes with the app installed, and the function must be active with an active version — a test always runs the active version. The run gets the event catalog’s sample payload for that event unless you pass your own with --data, and its event.id is the run’s own id. Test runs go through the same executor, limits and logging as real ones (trigger TEST instead of EVENT), but a test executes exactly once and is never retried: you see that one attempt’s outcome. If it doesn’t finish — the server running it restarted mid-run, say — it’s marked FAILED with The test run did not finish before its lease expired, and it isn’t run again.

Each run is tried at most four times:

AttemptDelay after the previous failure
1immediate
230 seconds
32 minutes
410 minutes

A failed attempt is retried whether the failure was your code’s or a passing infrastructure problem:

  • Your function failed — the handler threw or rejected, the module didn’t load, the run hit the 15-second wall-clock or CPU limit, or its answer was over 64 KB.
  • The sandbox couldn’t be reached cleanly — it answered with an error status (a 4xx included) or a response OneBooks couldn’t read, the network failed, OneBooks stopped waiting for it after 20 seconds, or the call failed unexpectedly on OneBooks’ side.
  • OneBooks couldn’t start the run — the function version is gone, your app is no longer installed in the business, or the user it runs as is no longer active (see Run tokens).

If attempt 4 fails too, the run is marked FAILED and not retried further. An attempt that never reports back — the worker running it stopped mid-run — still counts as one of the four: the run is queued again once that attempt’s one-minute lease runs out or, if it was attempt 4, marked FAILED with Run exceeded the attempt limit while recovering an expired lease. Test runs make one attempt and are never retried. Every attempt is the same run for the same event — a retry never creates a second run — so side effects your function causes before failing can happen more than once: make them safe to repeat (for example, an Idempotency-Key derived from event.id — see Integration patterns).

StatusMeaning
QUEUEDWaiting for its first attempt, or for a retry after a failure
RUNNINGExecuting now
SUCCEEDEDThe handler returned without throwing
FAILEDAttempt 4 failed or never finished — whether the function itself failed or OneBooks couldn’t start it each time (see Retries) — or a test run’s single attempt failed or never finished. Read the run’s error and logs.
SKIPPEDNever reached the sandbox and never will — it isn’t retried. The run’s error names the cause: your app reached its daily run cap in that business (Daily run limit reached), OneBooks suspended your app (This app has been suspended by OneBooks), your app is no longer active (This app is no longer active), or OneBooks’ own environment can’t run functions (Hosted functions are not enabled on this environment, or a configuration problem).

Runs appear on the console’s Functions tab (Runs, with logs) and via onebooks functions logs. Merchants see each run’s function, event and status — not its logs — in their Installed apps drawer under Automations, so a string of FAILED runs is visible to them.

Each run gets its own OAuth access token, minted just before the run starts and revoked the moment it ends:

  • 5-minute lifetime at most.
  • Acts as the user who installed your app, capped by the installation’s scopes — the same permissions that user’s own session has. If that user disconnects your app while other users of the business still have it connected, runs act for the remaining connected user who connected first from then on (see Installation & lifecycle).
  • Never in your logs — wherever it appears in a run’s logs (ctx.log and console.* alike) or error, it’s replaced by [REDACTED].

ctx.api attaches the token for you, so your code never needs it. It isn’t in event, in ctx or in the body of the request that starts the run, and the sandbox’s wrapper keeps it away from the built-ins your module can replace or patch (fetch, Headers, Promise and the like). That’s hardening, not a promise that code sharing the isolate can never reach it — so treat the token as the live credential it is. What bounds it is what it can do: your installation’s scopes, the OneBooks API only, for at most five minutes, and revoked when the run ends.

If the user a run acts for is no longer active in the business, the attempt fails with The user who installed this app is no longer active (and is retried like any failure).

SymptomLikely cause
Run SKIPPED — Hosted functions are not enabled on this environmentHosted functions aren’t switched on where this run happened (a platform rollout setting, not your code)
Run SKIPPED — Daily run limit reachedYour app reached its daily cap of about 5,000 runs in that business (UTC day); events from 00:00 UTC run again, but skipped runs aren’t retried
Run SKIPPED — This app has been suspended by OneBooks / This app is no longer activeOneBooks suspended your app, or it was deactivated — see Go live
Run FAILED — This app is no longer installed in the businessThe business uninstalled your app while the run was queued
Run FAILED — The user who installed this app is no longer activeThe user the installation acts for was removed or suspended in that business — see Run tokens
Run FAILED — Run exceeded the attempt limit while recovering an expired leaseAttempt 4 never reported back — the worker running it stopped mid-run — see Retries
Test run FAILED — The test run did not finish before its lease expiredThe test’s single attempt never finished (the server running it restarted, say) — run the test again
Run fails with The function answered with more than 64 KBYour module changed what the sandbox sends back — for example by patching built-ins such as Object.prototype — since a normal run’s answer stays well under the cap
ctx.api may only call the OneBooks APIYou passed an outside URL to ctx.api — use the global fetch() for declared egress hosts
An outside call returns 403 Egress to this host is not allowedThe host isn’t in egressHosts, you used http:// or a custom port, or a redirect led to a host like that
Run fails with a CPU/time-limit errorReduce work per run — offload heavy processing to your own server via a declared egress call instead of doing it inline
Logs end with Log output truncatedYou hit the 16 KB cap — log less, or log summaries instead of full payloads
Upload rejected with FUNCTION_SOURCE_NO_HANDLERThe file isn’t an ES module exporting onEvent or a default handler (CommonJS module.exports included) — bundle it as ESM
Upload accepted, but runs fail with Failed to load the function moduleThe upload check only reads the source text; the module itself doesn’t load — run a test as soon as you activate a version
Function never runsCheck it’s active, has an active version, is subscribed to the event type, and the business’s installation holds the event’s scope — and that it isn’t an app-lifecycle event, which functions never receive

Functions SDK — types and a local test harness for writing functions in TypeScript.