# 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](/console/applications)
- An app runner available for deployment — see [REST APIs](/console/rapi)
- 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](/sdk/guides/scripts#the-endpoint-contract).

```javascript filename="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](/console/scripts) 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](/cli/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](/console/loader#tags-and-applications). 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.

```json filename="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](/console/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.

```json filename="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](/console/rapi#step-2-deploy-to-runner). After the loader has run, the runner checks every endpoint against the scripts it registered. Problems show up in the [status response](/console/rapi#status-endpoint) (`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](/console/rapi#step-3-access-your-api).

### GET

```bash
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' }`.

### POST

```bash
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.

---

```json title="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](/console/rapi/mcp#one-tool-per-scripted-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](/sdk/guides/scripts#the-endpoint-contract).

## 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                                                                                             |

## Next steps

- [Scripts and script modes](/sdk/guides/scripts) — the script contract in full
- [Scripts in the Console](/console/scripts) — the Run panel
- [REST APIs](/console/rapi) — templates, deployment and the built-in routes
- [MCP endpoint](/console/rapi/mcp) — the same endpoints as tools for an AI agent
- [Script types](/sdk/api/types#script-types) — `EndpointScriptRequest`, `ReadOnlyDcupl`, `RestApi.TemplateEndpoint`
