JavaScript API
Business rules can run JavaScript (or TypeScript, compiled to JavaScript) via the execute_script action or a script-type rule. Scripts run in a sandboxed runtime with access to oikapi’s data, notification, caching, and HTTP APIs. Standard JavaScript globals — Date, Math, String, Number, Array, Object, JSON — are available.
Data operations
Section titled “Data operations”// Fetch a single record by IDconst customer = get('customers', record.customer_id);
// Query multiple recordsconst deals = list('deals', { filter: 'eq(status, "open")', sort: '-created_at', limit: 50});// list() also accepts a bare filter string:const open = list('deals', 'eq(status, "open")');
// Create a record (runs in the same transaction as the main operation)const invoice = create('invoices', { customer_id: customer.id, amount: totalAmount, status: 'draft'});
// Update a recordupdate('deals', record.deal_id, { status: 'won', closed_at: new Date().toISOString()});
// Soft-delete a record (hard deletion is not available from scripts)deleteRecord('tasks', record.task_id);create()/update()/deleteRecord() against another table only trigger that table’s own business rules when the application has opted into rule chaining (rules.chains.enabled, off by default). With chaining off — the default — a nested write applies without cascading into that table’s rules, which avoids accidental loops. Turn chaining on for an app when a script’s correctness genuinely depends on the target table’s own gates running for real (e.g. a booking rule that needs the target’s balance/approval checks to actually execute rather than be skipped).
// WRONG for large tables: silently checks only the first 100 matchesconst dupes = list('accounts', "eq(code,'" + record.code + "')");if (dupes.length > 0) { throw new Error('duplicate code'); }
// RIGHT: uncapped, exactif (exists('accounts', "eq(code,'" + record.code + "')")) { throw new Error('duplicate code');}Bulk operations
Section titled “Bulk operations”More efficient than calling the single-record functions in a loop. Each is all-or-nothing:
createMany('order_items', [ { order_id: record.id, product_id: 'uuid-1', quantity: 2 }, { order_id: record.id, product_id: 'uuid-2', quantity: 1 }]);
updateMany('tasks', tasks.map(t => ({ id: t.id, changes: { status: 'completed' }})));
removeMany('files', tempFiles.map(f => f.id));Aggregation helpers
Section titled “Aggregation helpers”const items = list('order_items', 'eq(order_id, "' + record.id + '")');record.total = sum(items, 'price'); // sum a fieldrecord.avg_rating = avg(reviews, 'rating');record.item_count = count(items);count also has a table form — count(tableName, filter?) — that runs a server-side, uncapped count instead of counting an already-fetched (and possibly capped) array. Pair it with exists(tableName, filter?) (returns a boolean, always exact) for uniqueness/existence checks:
const total = count('invoices', 'eq(customer_id, "' + record.customer_id + '")');const hasOpenInvoice = exists('invoices', 'and(eq(customer_id, "' + record.customer_id + '"), eq(status, "open"))');Reference and date helpers
Section titled “Reference and date helpers”Reference fields normally read back as a bare id string, but if you assign a fetched record onto a reference field, unwrap it with refId() first:
const accountId = refId(record.debit_account); // handles {id, ...}, a bare id string, or nulldate-typed fields read back as a full RFC3339 datetime string even though they only store a date, so writing one back as-is fails validation (it requires bare YYYY-MM-DD). datePart() normalizes it:
create('journal_entries', { date: datePart(record.occurred_on), /* ... */ });Decimal arithmetic
Section titled “Decimal arithmetic”Use Decimal for money and other precise values — never JavaScript +/-/*//, which introduce floating-point errors. Decimal values are passed as strings:
const tax = Decimal.mul(record.amount, '0.08');record.total = Decimal.add(record.amount, tax);Decimal provides add, sub, mul, div, round, floor, ceil, abs, neg, the comparisons (equals, greaterThan, lessThan, …), the predicates (isZero, isPositive, isNegative), and toNumber (for display only).
Field transformations
Section titled “Field transformations”record.email = transform(record.email, ['lowercase', 'trim']);record.price = transform(record.price, [{ name: 'round', decimals: 2 }]);Workflows and other rules
Section titled “Workflows and other rules”// Start a workflow (only in after_* triggers; runs asynchronously)triggerWorkflow('expense_approval'); // current recordtriggerWorkflow('invoice_review', record.invoice_id);
// Invoke another business rule by nametriggerRule('recalculate_totals', { order_id: record.id });Notifications
Section titled “Notifications”notify() sends to a single user via one or more channels (in_app, email, sms). The second argument is an options object. See Notifications for details.
notify(record.assigned_to, { title: 'Task assigned', body: 'You have a new task: ' + record.title, channels: ['in_app', 'email'], metadata: { record_id: record.id }});
// Notify everyone with a rolenotifyRole('manager', { title: 'Approval needed', body: 'Order ' + record.id + ' is awaiting approval'});
// Convenience wrappers (address the recipient directly)sendEmail('ops@example.com', 'Order received', 'Order ' + record.id + ' was placed');sendSMS('+15551234567', 'Your order shipped');Cache API
Section titled “Cache API”Per-application key-value cache with TTL. See App Caching for details.
Cache.set('rate_limit_' + userId, count + 1, '1m');const count = Cache.get('rate_limit_' + userId);Cache.delete('some_key');Cache.clear();Cache.keys('rate_limit_*');HTTP client
Section titled “HTTP client”Make outbound HTTP requests. See HTTP Client for details.
const response = fetch('https://api.example.com/data', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ key: 'value' })});
if (response.ok) { const data = response.json();}You can attach a stored credential to a request by name with the credential option, so secrets never appear in script code:
fetch('https://api.example.com/data', { credential: 'stripe_key' });Stored credentials
Section titled “Stored credentials”Manage named secrets without ever reading them back into JavaScript. The store returns a reference you pass to fetch()’s credential option:
const ref = Credential.store('crm', 'stripe_key', { api_key: 'sk_live_...' });Credential.update(ref, { api_key: 'sk_live_new' });Credential.delete(ref);Document generation
Section titled “Document generation”const pdf = Document.generate('invoice_template', { table: 'invoices', record: record.id, data: { customer: customer.name, total: record.total }});Data export
Section titled “Data export”exportTable('crm', 'leads', { format: 'xlsx', filter: 'eq(status, "qualified")'});Logging
Section titled “Logging”console.log, console.warn, and console.error write to the rule execution’s logs (visible when testing a script). They are for debugging, not production logging.
console.log('Processing order', record.id);Pre-populated variables
Section titled “Pre-populated variables”| Variable | Description |
|---|---|
record | The current record being created/updated. Modify its fields to change the record. |
oldRecord | Previous state of the record (defined only in update/delete triggers). |
params | Parameters submitted via an action’s modal form (manual triggers). Always defined; empty object if none. |
Field types: what you read vs. what you write
Section titled “Field types: what you read vs. what you write”The JS value you read from record/get()/list() isn’t always the shape you must write back:
| Field type | You read | You write | Watch out for |
|---|---|---|---|
decimal (money) | A string (e.g. "1250.00"), never a number | String, number, or int — all accepted | Never compute it with +/-/*//. A plain JS number is silently accepted with no precision warning; always use Decimal.* (below) and write the resulting string. |
date | A full RFC3339 datetime string (e.g. "2026-07-08T00:00:00Z"), even though it stores a date only | Strict YYYY-MM-DD — a value with a time component is rejected | Writing back a value you just read throws. Use datePart(value) to normalize. |
datetime | RFC3339 datetime string | RFC3339 datetime string with timezone | Read and write shapes match — no gotcha, unlike date. |
reference | A bare id string | A bare id string (an object like {id, ...} is rejected) | Only relevant if you assign a whole fetched record, not just read the field — wrap it in refId(...). |
reference_array | Array of bare id strings | Array of bare id strings, via a separate junction-table write path with no per-element validation | Keep elements to plain id strings; malformed entries aren’t caught the way a scalar reference would be. |
file / file_array | Bare file id string(s), no inline filename/mime/size | Same string/uuid acceptance as reference | Fetch the file’s own record if you need filename/mime/size. |
Computed fields (formula, lookup, rollup, graph_rollup, counter) | Computed value | Rejected outright — writing to a computed field is a validation error, not a silent no-op | Don’t include these fields in create()/update() payloads. |
PATCH semantics: in update()’s changes object (and REST bodies generally), an explicit null clears a field; omitting the key leaves it untouched.
Errors
Section titled “Errors”Throwing an error rolls back the transaction and surfaces the message to the caller:
if (record.total < 0) { throw new Error('Total cannot be negative');}Ledger scripting
Section titled “Ledger scripting”The native double-entry ledger’s journal_entries and journal_lines tables are platform-managed — direct create/update/delete calls against them are always rejected, from a script or the REST API alike:
{"code": "FORBIDDEN", "message": "Access denied: cannot create journal_entries directly — this table is maintained by the platform (system_managed); journal_entries is system-managed and cannot be written directly. Post an external transaction by creating a finance_transactions row; ..."}Use one of the sanctioned intake tables instead:
// Post a single balanced transaction (debit_account +amount, credit_account -amount)create('finance_transactions', { external_id: 'pay_9F3K2', amount: '1250.00', debit_account: cashAccountId, credit_account: revenueAccountId, occurred_on: new Date().toISOString()});
// Author a manual, multi-line entry (requires a distinct second approver before it posts)const batch = create('manual_journal_batches', { reference: 'ADJ-2026-014', date: '2026-07-10' });create('manual_journal_lines', { batch: batch.id, account: expenseAccountId, amount: '4200.00' });create('manual_journal_lines', { batch: batch.id, account: accruedLiabilityId, amount: '-4200.00' });update('manual_journal_batches', batch.id, { status: 'submitted' }); // books a draft, pending approval// ... a different user later flips status to 'approved', which posts it through the approval gates
// Reverse a posted entrycreate('reversal_requests', { original_entry: entryId, reason: 'Duplicate payment reversed' });For a trial balance or account balances, call GET /api/ledger/reconciliation/trial-balance (optional ?as_of=YYYY-MM-DD) rather than hand-summing journal_lines in a script — it returns exact decimal-string totals per account plus a balanced flag.
Security and limits
Section titled “Security and limits”- No access to
eval,Function,require, orimport. - No file I/O or raw network access — use
fetch()for outbound HTTP. get()/list()and the write functions respect the caller’s permissions (unless the rule runs with elevated permissions) and enforce application boundaries.- Each script has an execution-time limit (default 5 seconds, maximum 10 seconds) and a cap on the number of operations it can perform.
Testing scripts
Section titled “Testing scripts”Run a script without creating a business rule:
POST /api/rules/execute-script{ "script": "const users = list('system_users', ''); return users.length;", "language": "js", "persist": false}Set persist: true and provide use_record with an app, table, and id to execute against a real record and save changes. Use language: "ts" to compile TypeScript first. The response includes any console logs, the resulting record, and the operations performed.