# Endpoint extensions

An endpoint extension adds custom HTTP routes to the CairnCMS API. Reach for one when you need behavior that does not map to a collection's CRUD endpoints, for example, a complex aggregation, a third-party integration, a webhook receiver that doesn't fit the flow trigger model, or a custom business operation that should be exposed as a single REST call.

An endpoint extension is a single npm package created by the [extensions toolchain](/docs/develop/extensions/creating-extensions/). By default it runs server-side in the same Node process as the rest of the API, with full access to the platform's services and database connection. It can also run sandboxed in the confined runtime, covered in the [Confined variant](#confined-variant) section below.

## Anatomy

An endpoint extension exports either a function or an object. Both forms register one or more routes on an Express router.

### Function form

The simplest form is a function that takes the router and a context object:

```js
export default (router) => {
  router.get('/', (req, res) => res.send('Hello, World!'));
};
```

When you use the function form, the extension's package name becomes the mount path. A package named `cairncms-extension-greet` mounts at `/greet`.

### Object form

To set the mount path explicitly, export an object with `id` and `handler`:

```js
import { defineEndpoint } from '@cairncms/extensions-sdk';

export default defineEndpoint({
  id: 'greet',
  handler: (router) => {
    router.get('/', (req, res) => res.send('Hello, World!'));
    router.get('/intro', (req, res) => res.send('Nice to meet you.'));
    router.get('/goodbye', (req, res) => res.send('Goodbye!'));
  },
});
```

These routes are accessible at `/greet`, `/greet/intro`, and `/greet/goodbye`.

`defineEndpoint` is a no-op type wrapper; it returns the config unchanged but gives you full TypeScript inference on the shape.

## The router

The first argument is an Express router scoped to the extension's mount path. Use the standard Express verbs (`get`, `post`, `patch`, `put`, `delete`) and middleware patterns:

```js
export default (router) => {
  router.use((req, res, next) => {
    // middleware applied to all routes in this extension
    next();
  });

  router.get('/items', (req, res, next) => {
    res.json({ items: [] });
  });

  router.post('/items', express.json(), (req, res, next) => {
    res.status(201).json({ created: true });
  });
};
```

The router is mounted at the app root underneath the API's main middleware stack. Authentication, rate limiting, and the schema middleware all run before your handler does, so `req.accountability` and `req.schema` are populated by the time your code runs.

## The context

The second argument is a context object with everything an endpoint typically needs:

- **`services`** — all built-in services (`ItemsService`, `FilesService`, `UsersService`, `MailService`, and so on). Use these for any data work you want to respect permissions and validation.
- **`exceptions`** — the platform's exception classes. Throw these to surface proper HTTP error responses; pass to `next(err)` from inside async route handlers.
- **`database`** — a [Knex](https://knexjs.org/) instance connected to the configured database. Use this only when a service does not cover what you need.
- **`getSchema`** — async function that returns the current schema overview. Used when constructing a service outside a request context.
- **`env`** — the parsed environment variables.
- **`logger`** — a Pino logger instance. Use this rather than `console.log` so messages flow through the platform's logging pipeline.
- **`emitter`** — the platform's event emitter. Use this to fire custom events that other extensions can subscribe to with hooks.

```js
export default (router, { services, exceptions }) => {
  const { ItemsService } = services;
  const { ServiceUnavailableException } = exceptions;

  router.get('/popular-recipes', async (req, res, next) => {
    try {
      const recipes = new ItemsService('recipes', {
        schema: req.schema,
        accountability: req.accountability,
      });

      const results = await recipes.readByQuery({
        sort: ['-views'],
        limit: 10,
        fields: ['id', 'title', 'views'],
      });

      res.json(results);
    } catch (error) {
      next(new ServiceUnavailableException(error.message));
    }
  });
};
```

When you use the `emitter`, never emit an event that you (or another extension) are listening to from this same path. That creates an infinite loop with the same operational outcome as `while (true)`.

## Permissions and accountability

Each request carries an `accountability` object that describes the caller, including the user, role, IP, admin status, app status, and so on. Pass it through to any service you instantiate:

```js
const items = new ItemsService('articles', {
  schema: req.schema,
  accountability: req.accountability,
});
```

The service then applies the caller's permissions to every read and write. A reader limited by a custom-permissions filter rule sees only the items they are allowed to see, automatically.

To bypass permissions for an admin-only operation, omit `accountability` entirely:

```js
const items = new ItemsService('articles', {
  schema: req.schema,
});
```

This is equivalent to running as an admin. Reserve it for trusted endpoints; never use it on a route that takes user input that influences which records are touched.

## Errors

The platform's exception classes turn into proper HTTP status codes when passed to `next()`:

- `InvalidPayloadException` → 400
- `ForbiddenException` → 403
- `RouteNotFoundException` → 404
- `MethodNotAllowedException` → 405
- `ServiceUnavailableException` → 503

```js
const { InvalidPayloadException } = exceptions;

router.post('/work', async (req, res, next) => {
  if (!req.body.payload) {
    return next(new InvalidPayloadException('payload is required'));
  }
  // ...
});
```

Wrap async handler bodies in `try/catch` and pass errors to `next(err)` so they reach the error middleware consistently. Express 4's behavior around unhandled async throws in raw route handlers is uneven without a wrapper, so making the path through `next()` explicit is the most reliable pattern here.

## A complete minimal example

An endpoint that exposes a count of published articles per author. Useful illustration of services + accountability + exceptions in one place.

`src/index.js`:

```js
import { defineEndpoint } from '@cairncms/extensions-sdk';

export default defineEndpoint({
  id: 'article-stats',
  handler: (router, { services, exceptions }) => {
    const { ItemsService } = services;
    const { ServiceUnavailableException } = exceptions;

    router.get('/by-author', async (req, res, next) => {
      try {
        const articles = new ItemsService('articles', {
          schema: req.schema,
          accountability: req.accountability,
        });

        const results = await articles.readByQuery({
          aggregate: { count: '*' },
          groupBy: ['author'],
          filter: { status: { _eq: 'published' } },
        });

        res.json(results);
      } catch (error) {
        next(new ServiceUnavailableException(error.message));
      }
    });
  },
});
```

After build and install, the endpoint is reachable at `GET /article-stats/by-author`. Permissions on the `articles` collection are applied automatically because the service was constructed with the request's `accountability`.

## Confined variant

An endpoint runs full-authority by default. To run it in the sandbox, declare `runtime: confined-server` in the manifest and author it with `defineJsonEndpoint` from `@cairncms/extensions-server-api` instead of an Express router.

A confined endpoint has no router. The handler is the whole endpoint. It receives the request as plain data, `{ method, path, query, body }`, and returns `{ status?, body }`, where `status` is the HTTP status code and `body` is the response body:

```ts
import { defineJsonEndpoint } from '@cairncms/extensions-server-api';

export default defineJsonEndpoint({
  id: 'greet',
  async handler(request, { host }) {
    if (request.method !== 'GET') {
      return { status: 405, body: { error: 'method not allowed' } };
    }

    await host.log.info('greeting a caller');

    return { status: 200, body: { message: 'Hello from the sandbox' } };
  },
});
```

The manifest declares the runtime, the endpoint's access level, and any other capabilities:

```json
{
  "cairncms:extension": {
    "type": "endpoint",
    "path": "dist/index.js",
    "source": "src/index.js",
    "host": "^1.0.0",
    "runtime": "confined-server",
    "capabilities": {
      "endpoint": { "access": "authenticated" },
      "log": true
    }
  }
}
```

The `endpoint.access` value sets the auth gate. `authenticated` returns 401 to an anonymous caller, while `public` admits anyone. There is no app-only access level. An outbound call from the handler uses `host.request.send` and reaches only the origins declared in the `request` capability.

See the [Sandbox](/docs/develop/extensions/server-extensions/sandbox/) page for the full host API and the capability vocabulary.

## Where to go next

- [Hooks](/docs/develop/extensions/server-extensions/hooks/) cover server-side reactions to platform events. Use a hook when you need to react to a built-in operation rather than expose a new HTTP route.
- [Operations](/docs/develop/extensions/server-extensions/operations/) cover custom flow operations. Use an operation when the work belongs inside a flow.
- [Creating extensions](/docs/develop/extensions/creating-extensions/) covers the toolchain in full.