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.query(entity, filter?, options?)array of rows
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
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 caller’s identity does not reach data.*

Section titled “The caller’s identity does not reach data.*”

The dispatcher does not propagate the request context into the engine calls a script makes. Those calls run without a principal and without the auth-level marker the HTTP layer stamps, which has two consequences:

  • Deny-by-default does not apply. The absence of an auth level is the engine’s “trusted internal caller” signal, so an entity with no access rule for an operation is not refused inside a scripted endpoint, the way the same operation would be over /data/rest.
  • Rules that are declared still evaluate, but against the engine’s configured identity rather than the caller’s. A rule reading user.id or user.roles does not see the person who made the request, and the same is true of the bindings row-level security compiles into WHERE.

ctx.user is the only place the caller’s identity exists inside a scripted endpoint. Check it in the script, and scope every query you issue by ctx.user.tenant yourself.

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 gate fails open.

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 tenant check is not redundant. As described above, data.find_by_id does not carry the caller’s identity, so the row it returns has not been filtered by row-level security.

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"
}

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.