Skip to content
Talk to our solutions team

Scripted endpoints

A scripted endpoint is a custom HTTP route served by data.svc and implemented by a script instead of by the entity CRUD compiler. You declare it in the product schema under endpoints:; it is served at /data/x/<path>.

The script is a function taking one argument. Whatever it returns becomes the response body.

endpoints: is a schema top-level key, and only the product’s own definitions are read. A tenant overlay may contain an endpoints: block — it parses, and the merge path drops it. Endpoints are keyed by path, and across files the last declaration for a path wins.

KeyTypeRequiredMeaning
pathstringyesURL suffix under /data/x. Must start with /.
kindquery | mutateyesOperational semantics. Sets the default verb and gates caching.
scriptmappingyesThe handler. See the script reference.
descriptionstringnoSurfaced in the generated OpenAPI document.
verbGET | POST | PUT | PATCH | DELETEnoOverrides the verb derived from kind. Upper-cased at load.
authmappingnoEntry gate. Omitted means mode: required.
auth.moderequired | none | actionnoDefault required.
auth.actionstringwith mode: actionAction name. Load fails if mode: action and this is blank.
tx-mode"" | explicit | nonenoParsed and validated; nothing reads it at runtime.
tx-timeoutduration stringnoScript execution budget. Unset means 30s.
rate-limit.perminute | hour | day | ""noSee rate limits.
rate-limit.maxint ≥ 0noRequests per window.
rate-limit.per-tenantint ≥ 0noPer-tenant ceiling.
cache.ttlduration string ≥ 0noResponse cache lifetime. 0 disables.
cache.vary-bylist of stringnoExtra cache-key dimensions.
enabledboolnoDefault true. false leaves the endpoint declared but every request returns 404.

Any other value for kind, auth.mode, tx-mode or rate-limit.per is a load error naming the offending path. A negative cache.ttl, a negative rate-limit count, or a path that does not start with / are load errors too.

verb is the exception: it is upper-cased and stored without validation. A verb outside the five listed above loads cleanly, then matches none of the registered routes, so every request to that endpoint returns 405.

script: uses the same five keys as every other script slot in the schema — access rules, computed fields, validations, pointcuts and triggers.

KeyMeaning
languageWhich runtime evaluates the body.
expressionAn inline expression — one line, no function declarations.
scriptAn inline script body — a full program with functions and locals.
script-fileA path to an external script file.
functionEntry function to call. Defaults to main.

language accepts expr (also the empty default), cel, js, javascript, js-v8, javascript-v8, lua, starlark, go and wasm. Anything else is rejected when the endpoint is registered at engine build: the endpoint is skipped with a log line while the rest of the surface boots, and requests to its path get the ordinary 404 v2.endpoint_not_found, not an error naming the language.

Set the body with either expression: or script:. When both are present expression: wins and script: is discarded.

There is no file:, func:, entry:, inline:, source: or runtime: key. Those spellings decode to nothing, which produces an empty script reference and a missing script load error.

All five verbs register the same catch-all handler at /data/x/*path. The endpoint’s own declaration decides which one it answers.

DeclarationVerb served
kind: query, no verbGET
kind: mutate, no verbPOST
any kind, verb: PUTPUT

A request with a different verb gets 405 with an Allow header naming the single accepted verb and details.allowed carrying it as a string, not an array.

?datastore=<name> selects the datastore whose engine — and therefore whose endpoint registry — serves the request. Omitted, it is the schema’s default datastore.

The script’s single argument always has every key below present, zero-valued when unset. It is safe to read ctx.user.id or iterate ctx.user.roles without a nil guard.

PathTypeContents
ctx.req.pathobjectPath-parameter bindings. Always empty — see the caution above.
ctx.req.queryobject of stringQuery-string parameters.
ctx.req.bodyanyThe parsed JSON body, or null.
ctx.req.headersobject of stringAn allowlisted subset of request headers.
ctx.req.methodstringThe HTTP verb the route was hit with.
ctx.user.idstringThe caller’s user id. Empty on an auth: none endpoint.
ctx.user.tenantstringThe tenant the request is scoped to. Always populated.
ctx.user.rolesarray of stringThe caller’s roles. Empty array when none.

ctx.req.query is single-valued: ?tag=a&tag=b reaches the script as tag = "a". The second value is discarded.

ctx.req.headers carries exactly five headers, and only when the request sent them: Accept-Language, Content-Type, Idempotency-Key, X-Request-Id, User-Agent. Authorization, Cookie and the X-Forwarded-* family are stripped — a script cannot read the caller’s credentials.

ctx.req.body is parsed as JSON before the script runs. A kind: query endpoint hit with GET never reads a body. Otherwise an empty body yields null, and a body that is unreadable, not JSON, or larger than 1 MiB returns 400 v2.bad_body without invoking the script.

Data access is not on ctx. The script calls the data.* and schema.* namespaces directly. Every data.* call re-enters the engine, so it runs the full pipeline: validation, stateflow guards, computed fields, audit columns, history.

FunctionReturns
data.q(dsl)array of rows — a pipe query
data.query(entity, filter?, options?)array of rows
data.q(dsl)array of rows — the pipe syntax as a string
data.find_one(entity, filter)one row or null
data.find_by_id(entity, id)one row or null
data.count(entity, filter?)integer — but see the caution below
data.exists(entity, filter)boolean
data.aggregate(entity, ops)object
data.create(entity, record)the created row
data.create_many(entity, records)array of created rows
data.update(entity, id, changes)the updated row
data.update_many(entity, filter, changes)affected-row count
data.upsert(entity, record, conflict_key)the row
data.delete(entity, id)boolean

data.query’s filter is a map, so it can only express equality. For a range, a negation, a set membership or an ordering, use data.q — it takes the pipe syntax as a string and goes through the same parser, the same engine and the same access rules:

const recent = data.q(`orders | status = "paid", total > 100 | -created_at | 10`);

A syntax error is reported as one, before anything reaches the database. Build the string from literals and validated values — not by concatenating request input, which is the injection this form otherwise invites.

data.query’s filter is a map, so it can express equality and nothing else. When you need a range, a negation, a set membership or an ordering, data.q takes the pipe syntax instead:

const recent = data.q(`orders | status in ("paid","shipped"), total > 100 | -created_at | 20`);

It is the same parser and the same engine path as every other read, so access rules and row-level security apply unchanged — this is a more expressive way to ask, not a way around the rules. A syntax error is reported as a syntax error before anything reaches the database, and input the parser cannot fully consume is refused rather than being run as a smaller query.

FunctionReturns
schema.entity(name)entity descriptor
schema.fields(entity)array of field descriptors
schema.field(entity, name)field descriptor or null
schema.relations(entity)array of relation descriptors
schema.stateflow(entity)stateflow descriptor or null
schema.field_group(entity, group)array of field descriptors

The schema.* functions read the parsed schema and never touch the database.

filter is equality only. Every key becomes an = predicate joined with AND; there is no way to express a range, a set or a negation through this namespace. The full operator vocabulary is available over HTTP — see the Query DSL.

options reads four keys: limit (int), offset (int), order (a single string), fields (array of string). Anything else is ignored.

Bare-name functions are registered on the same runtime for older scripts: query, queryOne, queryById, count, exists, aggregate, insert, insertMany, update, updateMany, deleteRecord, upsert, the entity getters, and hash, hmac, randomUUID, randomBytes. Prefer the namespaced form — a locally defined query helper shadows the bare name, and data.query cannot be shadowed.

The data.* and schema.* namespaces are bound per request, carrying the calling principal. A query issued from a scripted endpoint sees exactly the rows that caller may see:

  • Access rules evaluate against the request’s identity, so user.id and user.roles are the person who made the call.
  • Row-level security compiles the same WHERE predicate it would for a direct /data/rest read.
  • Deny-by-default applies. An operation with no rule declared for it is refused here exactly as it is over REST.

The practical consequence is the one to design around: a scripted endpoint cannot see more than its caller. Where an endpoint legitimately needs a wider view — a report aggregating across owners, say — that is a modelling decision to make explicitly in the access rules, not something the endpoint acquires by virtue of being a script.

ctx.user remains the place to read the caller’s identity when you want to branch on it — refusing early, choosing a code path, writing an actor into an audit field — rather than to re-implement filtering the engine has already applied.

A script that returns normally produces 200 with Content-Type: application/json; charset=utf-8 and the return value serialised as the entire body. There is no data/count envelope, and the script cannot set the status code, add a header, or stream.

A script that throws produces 500 v2.script_error. To signal a business failure, return a value your client can read — the status will still be 200.

CodeHTTPCause
v2.endpoint_path_missing404The catch-all matched with an empty path.
v2.endpoint_not_found404No endpoint at that path, the tenant’s schema declares none, or enabled: false.
v2.method_not_allowed405Verb does not match. Allow header set.
v2.unauthenticated401mode: required or mode: action and no resolved principal.
v2.forbidden403The auth gate rejected the caller, including an unrecognised auth.mode.
v2.no_tenant403No tenant key on the request context.
v2.bad_body400Body unreadable, not JSON, or over 1 MiB.
v2.rate_limited429See rate limits.
v2.script_engine_missing500The tenant’s engine bundle carries no script runtime.
v2.script_error500The script threw, or its body failed to compile.
v2.engine_unavailable500The tenant’s engine bundle could not be resolved.

Every error body is {"error": "<message>", "code": "<code>", "details": {…}}, with details present only where the table above says so.

Compilation is lazy. The script body is compiled on the first request to that path, not at schema load or at boot, and the compile error is cached stickily. A syntax error therefore passes every load check and surfaces as a 500 to the first user who hits the route.

ModeBehaviour
requiredA resolved user id must be present, else 401. The default.
noneSkips the identity check.
actionIdentical to required today.

Tokens are consumed against a named limiter profile, keyed by tenant, user and profile. kind alone picks the endpoint profile:

kindProfile used
queryendpoint_query_default
mutateendpoint_mutate_default

A scripted-endpoint request is metered twice. The generic gate in front of every Data API route consumes a token first — v2_query_default for a GET, v2_mutate_default for any other verb — and the dispatcher then consumes a second token against the endpoint profile. A 429 from the first gate names the generic profile in details.profile.

An allowed request carries X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset (unix seconds). A denied one is 429 v2.rate_limited with those headers, a Retry-After in whole seconds (minimum 1), and details of {profile, limit, remaining, retry_after}. When no limiter is installed the request passes.

A response is cached only when cache.ttl is greater than zero and kind is query. Mutate endpoints never cache.

The lookup happens after the auth and rate-limit gates, so a cached body is only ever served to a request already proven entitled to it. The key is a hash of tenant, user id, endpoint path and the sorted query string.

Each cache.vary-by entry names a request header whose value is folded into the key. A header: prefix is stripped when present but is not required — header:Accept-Language and Accept-Language key identically, and any other non-blank string is also read as a header name. Blank entries are skipped.

A hit sets X-Cache: HIT. Any other response from a cacheable endpoint sets X-Cache: MISS, whether or not the store accepted the write — a write failure is logged and the response served anyway. A non-cacheable endpoint sends no X-Cache header at all.

tx-timeout is passed to the script runtime as the invocation’s timeout, defaulting to 30s.

Cancel an order, guarded on the caller’s tenant and the order’s current state.

endpoints:
- path: /orders/cancel
kind: mutate
description: Cancel an open order and record who cancelled it.
verb: POST
tx-timeout: 10s
auth:
mode: required
enabled: true
script:
language: javascript
function: main
script: |
function main(ctx) {
var body = ctx.req.body || {};
if (!body.order_id) {
return { ok: false, reason: "order_id is required" };
}
var order = data.find_by_id("orders", body.order_id);
if (!order || order.tenant_id !== ctx.user.tenant) {
return { ok: false, reason: "no such order" };
}
if (order.status !== "open") {
return { ok: false, reason: "order is " + order.status };
}
data.update("orders", order.id, {
status: "cancelled",
cancel_reason: body.reason || "",
cancelled_by: ctx.user.id
});
return {
ok: true,
order_id: order.id,
status: "cancelled",
cancelled_by: ctx.user.id
};
}

The explicit check earns its place even though data.find_by_id is already filtered by the caller’s row-level security: it turns “no row came back” into a specific, readable refusal instead of a generic not-found, and it states the endpoint’s intent where the next reader will see it.

Call it:

Terminal window
curl -X POST https://api.example.com/data/x/orders/cancel -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" -d '{"order_id":"9f1c...","reason":"customer request"}'
{
"ok": true,
"order_id": "9f1c...",
"status": "cancelled",
"cancelled_by": "u_4471"
}

A rejected cancellation is still a 200:

{
"ok": false,
"reason": "order is shipped"
}

Both run a script; they differ in what triggers them.

UseWhen
A scripted endpointA caller needs an operation the generated REST and GraphQL surface does not express
A pointcut or hookBehaviour must attach to an entity’s lifecycle no matter who triggers the change

The test is who has to remember. An endpoint runs only when someone calls it, so logic that must apply to every write — including writes through the generated API — belongs in a hook. Put it in an endpoint and the generated surface bypasses it.

Declared endpoints appear in the generated OpenAPI document at /x<path>, tagged scripted, with description rendered as the operation summary. Two things the document gets wrong: enabled: false endpoints are emitted alongside the live ones, so the document lists routes that answer 404; and on POST, PUT and PATCH operations the request body carries a placeholder schema reference that does not describe the script’s input. Supply the body shape to generated clients by hand. See Schema export and discovery for what the document does and does not describe. The endpoint-level HTTP reference for the rest of the surface is at Data API reference.