Skip to content

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.

// Fetch a single record by ID
const customer = get('customers', record.customer_id);
// Query multiple records
const 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 record
update('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 matches
const dupes = list('accounts', "eq(code,'" + record.code + "')");
if (dupes.length > 0) { throw new Error('duplicate code'); }
// RIGHT: uncapped, exact
if (exists('accounts', "eq(code,'" + record.code + "')")) {
throw new Error('duplicate code');
}

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));
const items = list('order_items', 'eq(order_id, "' + record.id + '")');
record.total = sum(items, 'price'); // sum a field
record.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 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 null

date-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), /* ... */ });

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).

record.email = transform(record.email, ['lowercase', 'trim']);
record.price = transform(record.price, [{ name: 'round', decimals: 2 }]);
// Start a workflow (only in after_* triggers; runs asynchronously)
triggerWorkflow('expense_approval'); // current record
triggerWorkflow('invoice_review', record.invoice_id);
// Invoke another business rule by name
triggerRule('recalculate_totals', { order_id: record.id });

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 role
notifyRole('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');

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_*');

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' });

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);
const pdf = Document.generate('invoice_template', {
table: 'invoices',
record: record.id,
data: {
customer: customer.name,
total: record.total
}
});
exportTable('crm', 'leads', {
format: 'xlsx',
filter: 'eq(status, "qualified")'
});

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);
VariableDescription
recordThe current record being created/updated. Modify its fields to change the record.
oldRecordPrevious state of the record (defined only in update/delete triggers).
paramsParameters 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 typeYou readYou writeWatch out for
decimal (money)A string (e.g. "1250.00"), never a numberString, number, or int — all acceptedNever compute it with +/-/*//. A plain JS number is silently accepted with no precision warning; always use Decimal.* (below) and write the resulting string.
dateA full RFC3339 datetime string (e.g. "2026-07-08T00:00:00Z"), even though it stores a date onlyStrict YYYY-MM-DD — a value with a time component is rejectedWriting back a value you just read throws. Use datePart(value) to normalize.
datetimeRFC3339 datetime stringRFC3339 datetime string with timezoneRead and write shapes match — no gotcha, unlike date.
referenceA bare id stringA 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_arrayArray of bare id stringsArray of bare id strings, via a separate junction-table write path with no per-element validationKeep elements to plain id strings; malformed entries aren’t caught the way a scalar reference would be.
file / file_arrayBare file id string(s), no inline filename/mime/sizeSame string/uuid acceptance as referenceFetch the file’s own record if you need filename/mime/size.
Computed fields (formula, lookup, rollup, graph_rollup, counter)Computed valueRejected outright — writing to a computed field is a validation error, not a silent no-opDon’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.

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');
}

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 entry
create('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.

  • No access to eval, Function, require, or import.
  • 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.

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.