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.
How a run happens
Section titled “How a run happens”- Something happens in a business — an invoice is paid, a customer is created — and OneBooks records the event.
- 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.
- 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.
Runtime
Section titled “Runtime”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 withFUNCTION_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
BUSINESSresource works well (it needsapp-data:readandprofile:read) — and read them withctx.api. - Standard web APIs —
fetch,Request/Response,URL,TextEncoder, Web Crypto and friends — are available as in any Worker.
Handler contract
Section titled “Handler contract”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 member | Purpose |
|---|---|
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.id | The business the triggering event happened in |
ctx.app.clientId | Your app’s public client_id |
ctx.run.id | This run’s id, for correlation |
ctx.api
Section titled “ctx.api”- OneBooks API only.
pathis a relative path (/invoices/…, orinvoices?page=2without the leading slash) or an absolute URL that starts with the API origin exactly. Anything else — another host or scheme, a protocol-relative//hostor/\host— rejects before any request is made, withctx.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 globalfetch()instead. - Headers. Requests carry
Accept: application/jsonand, when there’s abody,Content-Type: application/json.OneBooks-Versionis 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 ininit.headers— a record, aHeadersobject or[name, value]pairs — override those defaults, so aOneBooks-Versionthere sets the version for that one call.AuthorizationandHost, in any letter case, are dropped, and the run token is set last, asAuthorization: Bearer …— so it can be neither replaced nor sent elsewhere. - Responses. The JSON helpers resolve with the parsed JSON body,
nullfor an empty body (a204, 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>) carryingstatusandbody— the parsed JSON when possible, otherwise the text (never truncated), ornullwhen empty — so you can branch onerr.statusand the API’s errorcodeinerr.body.code. With@onebooks/functions,isFunctionApiError(err)narrows the type. - No redirects.
ctx.apinever follows one: a3xxcomes back as-is —ctx.api.fetch()resolves it, and the JSON helpers reject with that3xxstatus. ctx.api.fetch(path, init?)is the escape hatch:initcan also carrymethodand a rawbody, the same origin rule, token and header handling apply, and it resolves the rawResponsefor any status — it never rejects on one, so checkres.okyourself.
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.
Declared egress
Section titled “Declared egress”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: thefetch()promise resolves with an HTTP403response whose body is{"error":"Egress to this host is not allowed"}. Checkres.okorres.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 that403response 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.
Limits
Section titled “Limits”| Limit | Value |
|---|---|
| Functions per app | 20 |
| Source size | 1 MB, one ES module |
| Events per function | At 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 hosts | 10 per function |
| CPU time | 50 ms per run |
| Subrequests | 20 per run — every ctx.api call and every fetch() counts |
| Wall-clock timeout | 15 s |
| Runs | About 5,000 per app, per business, per UTC day — all of the app’s functions together |
| Logs | 16 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 value | 8 KB, serialized once — a larger one is replaced by a truncated preview (OneBooks doesn’t keep it either way) |
| Error message | 2,000 characters — a longer one is cut off |
| Run answer | 64 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.
Deploying
Section titled “Deploying”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.
onebooks functions deploy --app $APP_ID --name sync-invoices \ --file handler.js --events invoice.paid,payment.received \ --egress hooks.example.com --activate--app is your app’s internal id (onebooks apps list), not its
client_id. The first deploy of a name creates the function, and
--events is required then (the CLI stops before uploading anything
without it); --egress and --description are also only used at creation.
For an existing function the CLI ignores all three, with a note — change
them on the console’s Functions tab. Omit --activate to upload a
version without making it live. See the
CLI reference for every flag.
Testing before you rely on it
Section titled “Testing before you rely on it”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:
onebooks functions test --app $APP_ID --name sync-invoices \ --business $SANDBOX_BUSINESS_ID --event invoice.paidThe 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.
Retries
Section titled “Retries”Each run is tried at most four times:
| Attempt | Delay after the previous failure |
|---|---|
| 1 | immediate |
| 2 | 30 seconds |
| 3 | 2 minutes |
| 4 | 10 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
4xxincluded) 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).
Run statuses
Section titled “Run statuses”| Status | Meaning |
|---|---|
QUEUED | Waiting for its first attempt, or for a retry after a failure |
RUNNING | Executing now |
SUCCEEDED | The handler returned without throwing |
FAILED | Attempt 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. |
SKIPPED | Never 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.
Run tokens
Section titled “Run tokens”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.logandconsole.*alike) orerror, 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).
Troubleshooting
Section titled “Troubleshooting”| Symptom | Likely cause |
|---|---|
Run SKIPPED — Hosted functions are not enabled on this environment | Hosted functions aren’t switched on where this run happened (a platform rollout setting, not your code) |
Run SKIPPED — Daily run limit reached | Your 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 active | OneBooks suspended your app, or it was deactivated — see Go live |
Run FAILED — This app is no longer installed in the business | The business uninstalled your app while the run was queued |
Run FAILED — The user who installed this app is no longer active | The 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 lease | Attempt 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 expired | The 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 KB | Your 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 API | You 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 allowed | The 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 error | Reduce 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 truncated | You hit the 16 KB cap — log less, or log summaries instead of full payloads |
Upload rejected with FUNCTION_SOURCE_NO_HANDLER | The 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 module | The 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 runs | Check 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 |
Where next
Section titled “Where next”Functions SDK — types and a local test harness for writing functions in TypeScript.