.md

Scripted endpoints

A REST API (RAPI) template can declare endpoints: HTTP routes under the instance's endpoints/ prefix that are answered by one of your project's scripts instead of a built-in model route. The script runs inside the instance with read-only access to its data and returns the response body.

Prerequisites

  • A project with a loader configuration (dcupl.lc.json) and at least one application — see Applications
  • An app runner available for deployment — see REST APIs
  • SDK 2.0 and an app runner that supports scripted endpoints (the runners dcupl hosts do)
  • A paid plan with scripted endpoints: dcupl turns them on for your project; ask your dcupl contact. Until they are on, the template form has no Endpoints section to edit (a template that already declares endpoints shows a warning there instead), and deploying a template that declares endpoints fails with 400 Scripted endpoints are not enabled for this project …

Step 1: write the script

Create a JavaScript file under scripts/ in your project. An endpoint script is one function body that branches on script_type and returns the response body in endpoint mode — the full contract is in Scripts and script modes.

scripts/orders-summary.js
if (script_type === 'endpoint') {
  // GET …/endpoints/orders-summary?customer=acme → request.query.customer
  const { customer } = request.query;

  const orders = dcupl.query.execute({
    modelKey: 'Order',
    queries: customer ? [{ attribute: 'customer', operator: 'eq', value: customer }] : [],
  });

  // a plain value or a Promise — it becomes the JSON response body
  return { count: orders.length, items: orders.slice(0, 10) };
}

throw new Error('unsupported script type: ' + script_type);

Open the file in the Console and use the Run panel to execute it against your loaded application with a mock request before you deploy anything. Outside the Console, dcupl generate script --file-name orders-summary.js --mode endpoint writes the generic endpoint starter (a query of the first model with a limit) and registers it in dcupl.lc.json (see Scaffolding); replace the starter's body with the script shown here.

Step 2: register it in the loader configuration

Add a type: 'script' resource to dcupl.lc.json. Its scriptKey is the name the template refers to. Tag it so the application the template uses includes it. dcupl generate script, dcupl generate api and the Run panel's Add resource button all write the entry with "tags": [], and an application with resourceTags skips it. Add "api" to its tags, either in the file or in the loader form. In the Run panel, the key field next to Add resource proposes orders_summary for this file. Change it to orders-summary before you add the resource.

dcupl.lc.json
{
  "resources": [
    {
      "url": "${baseUrl}/scripts/orders-summary.js",
      "type": "script",
      "scriptKey": "orders-summary",
      "tags": ["api"]
    }
  ],
  "applications": [{ "key": "production", "resourceTags": ["api"] }]
}
Note

Only scripts the instance's application actually loads can answer an endpoint. If the template's processOptions.resourceTags (or the application's) exclude the resource, the runner reports EndpointScriptMissing at startup. The same happens when the template's dcuplConfig.apiKey is a scoped key whose files.read permission does not cover the script files — see API keys.

Step 3: declare the endpoint in the template

Add an endpoints array to the API template (api/<key>.api.json). In the Console, the template form has an Endpoints section for this; the file is what gets deployed. With the CLI, dcupl generate api --name orders-api --endpoint "GET orders/:id=order-detail" writes a template with that endpoint and scaffolds and registers any script it does not find.

api/orders-api.api.json
{
  "key": "orders-api",
  "name": "Orders API",
  "vGuardEnabled": false,
  "auth": [{ "type": "api-key", "value": "your-secure-api-key" }],
  "dcuplConfig": {
    "projectId": "your-project-id",
    "apiKey": "your-sdk-readonly-key",
    "processOptions": { "applicationKey": "production" }
  },
  "endpoints": [
    {
      "path": "orders-summary",
      "method": "GET",
      "scriptKey": "orders-summary",
      "description": "Order count and the latest orders of a customer"
    },
    {
      "path": "orders/:id",
      "method": "GET",
      "scriptKey": "order-detail"
    }
  ]
}

Endpoint fields

Field Required Value
path yes The route relative to endpoints/, without a leading slash. Segments are separated by /; a segment that starts with : is a path parameter
method yes GET or POST
scriptKey yes The scriptKey of a type: 'script' resource in dcupl.lc.json
description no Shown as the operation summary in the per-instance OpenAPI document and in the Console tester

Path rules

  • Allowed characters per segment: letters, digits, _, ., ~, -; a segment may start with : to become a parameter (orders/:id/summary). No leading or trailing slash, no empty segment, no query string. The schema pattern is ^(:?[A-Za-z0-9_.~-]+)(/:?[A-Za-z0-9_.~-]+)*$.
  • method + path must be unique in a template. The Console form rejects duplicates; the runner reports EndpointDuplicate and lets the first entry answer.
  • Matching is exact: same method, same number of segments, every literal segment equal. orders/42/ (trailing slash) does not match orders/:id, and orders/ does not match orders. There are no prefix matches.
  • A fully literal path wins over a parameterised one regardless of order — orders/latest beats orders/:id. Among parameterised candidates the first declared wins.
  • Path parameters are URL-decoded per segment, so an encoded slash (%2F) stays inside its segment.
  • Only listed paths are callable. A script that is registered but not referenced by an endpoint is not reachable over HTTP.

Step 4: deploy and check the status

Deploy the template as described in REST APIs. After the loader has run, the runner checks every endpoint against the scripts it registered. Problems show up in the status response (GET /instance/{instanceKey}/status) under report.quality.errors, in front of the loader's own errors, and the instance still comes up ready for its other routes:

errorType Meaning
EndpointScriptMissing scriptKey is not a registered script (title = the script key)
EndpointDuplicate method + path is listed twice (title = GET orders); the first entry answers

Step 5: call the endpoint

The route is {runner-url}/instance/{instanceKey}/endpoints/{path}, guarded like every other instance route: the API key goes in the api-key header or the api-key query parameter, and v is required when v-Guard is enabled. {instanceKey} is the template key prefixed with your project ID (<projectId>-orders-api), as the runner tab's Instance column shows it — see Access your API.

curl 'https://runner.example.com/instance/your-project-id-orders-api/endpoints/orders-summary?customer=acme' \
  -H 'api-key: your-secure-api-key'

The query string reaches the script as request.query ({ customer: 'acme' }), a repeated key as an array. A path parameter reaches it as request.params: GET …/endpoints/orders/42 matched by orders/:id gives { id: '42' }.

curl -X POST 'https://runner.example.com/instance/your-project-id-orders-api/endpoints/orders-summary' \
  -H 'api-key: your-secure-api-key' \
  -H 'content-type: application/json' \
  -d '{ "customers": ["acme", "globex"] }'

This needs its own entry { "path": "orders-summary", "method": "POST", "scriptKey": "orders-summary" } in the template — the GET entry from Step 3 does not answer a POST (404 endpoint not found). The parsed JSON body reaches the script as request.body. A POST must send application/json (or a +json type); any other content type is refused with 415 before the script runs. A POST with neither a body nor a content-type header passes undefined; an empty body sent as application/json is refused with 400 by the JSON parser.

Response
{
  "count": 2,
  "items": [
    { "key": "o-1", "customer": "acme", "amount": 120 },
    { "key": "o-2", "customer": "acme", "amount": 80 }
  ]
}

The response is the script's return value serialised as JSON, with content-type: application/json and status 200 for both methods. Like the query endpoints it carries cache-control: private, max-age=3600 (instance with an API key) or public, max-age=3600 (open instance), and no-store on errors — a client or browser may answer a repeated GET from its cache for up to an hour. The script never sees the API key: the api-key, apikey, x-api-key, authorization, cookie and v headers and the api-key, apiKey, Api-Key and v query keys are removed before the call.

On an instance with MCP turned on, every endpoint is also an MCP tool named endpoint_<method>_<path> (GET orders/:id → endpoint_get_orders_id) that an AI agent calls with the path parameters plus query or body — see MCP endpoint.

Status codes

Status When Body
200 The script returned (or resolved) a value The value as JSON (undefined → null)
400 The JSON body could not be parsed, the instance is not ready yet, or v is missing or outdated while v-Guard is on A JSON parse error, instance not ready, or Invalid v parameter
403 Missing or wrong API key, or unknown instance key Forbidden resource
404 No endpoint matches method + path { "statusCode": 404, "message": "endpoint not found", "error": "Not Found" }
415 POST with a content type other than JSON endpoint bodies must be application/json
500 The script threw or returned a rejected Promise { "name": "<error name>", "message": "<message>" } — never a stack
504 The script did not settle within the timeout { "name": "EndpointTimeout", "message": "endpoint script did not settle within 5000ms" }

Timeout

One endpoint call has a budget of 5000 ms on a dcupl-hosted runner. A self-hosted runner reads it from its ENDPOINT_TIMEOUT_MS environment variable (default 5000). When it runs out the request is answered with 504; the instance keeps serving. The budget can only interrupt a script that is waiting on a Promise — a synchronous loop runs to its end and blocks the instance meanwhile, so keep endpoint scripts short and let the query engine do the work.

What a script can and cannot do

Inside the instance the script sees a read-only dcupl (query.*, fn.*, models, views, scripts.keys) and runs in a sandbox without process, require, fetch or timers. The details are in Scripts and script modes.

OpenAPI document

Every instance serves its own OpenAPI document at GET /instance/{instanceKey}/swagger-json (no API key required — it describes routes and contains no secrets). It is the static document of the built-in routes plus one operation per scripted endpoint:

  • path /instance/{instanceKey}/endpoints/<path> with :name segments as {name} path parameters
  • summary = the endpoint's description (or METHOD path)
  • api-key security, a free-form JSON requestBody for POST
  • responses 200, 404, 500, 504

The Swagger UI at /instance/{instanceKey}/swagger loads this document, and the Console tester reads it to list the endpoints. The scriptKey is not part of the document.

In the Console

The Endpoints section of the template form

Open an API template (api/*.api.json) in the file editor; the form view has an Endpoints accordion between Dcupl Instance Config and Loader Fetch Options. Each row has:

  • Method — GET or POST
  • Path — validated inline: required, no leading or trailing slash, the pattern above
  • Script — a picker fed from dcupl.lc.json, listing the type: 'script' resources. Scripts the template's application does not schedule (tags) stay in the list greyed out with a not in this app chip; picking one warns that the runner will report EndpointScriptMissing. Type a key switches the row to free text for a script you add to the loader later
  • Description — the operation summary in Swagger and the tester

Rows that share method + path are flagged. The form writes the file on every change, but the endpoints array is written only while every row is valid; a yellow line under the section says so while a row has an error. With no rows the endpoints key is left out of the file.

Under the Api Key select, a yellow hint appears when the template has endpoints and the selected key is a scoped key whose files.read globs do not cover the script files the selected application schedules (or scripts/** while it schedules none). It names the unreadable files and suggests adding a files.read pattern like scripts/** to the key or picking another one.

Instance table and tester

A deployed instance whose template declares endpoints shows an N endpoints chip under its key; hover it for the list, click it to open the tester. The tester lists the scripted endpoints next to the built-in ones, read from the instance's swagger-json. Endpoints that the runner's document does not list yet (an older runner) are taken from the deployed template and marked with a template chip. Pick an endpoint, fill the path parameters, query or body, send the request, and copy it as cURL or re-run it from the history.

Troubleshooting

Symptom Cause Fix
404 endpoint not found The path is not in the template, the method differs, or a trailing slash was sent Compare with the endpoints array; check swagger-json
EndpointScriptMissing in the status The loader did not register the scriptKey: wrong key, resource not in the application's tags, or a scoped key without files.read on scripts/** Fix the key or the tags; use a key that can read the script files
500 { name: 'TypeError', message: "Cannot read properties of undefined …" } or "… is not a function" The script used dcupl.data, dcupl.lists or a host global (process, require, fetch, timers) that the sandbox does not provide Use the read-only facade; move side effects out of the script
504 EndpointTimeout The script awaited something that never settled, or took longer than the budget Return a value; on a self-hosted runner, raise ENDPOINT_TIMEOUT_MS only if the work really needs it
415 POST without content-type: application/json Send JSON