# Moderation hook endpoint in Node.js

This example is the code behind [Moderate content with your own rules](../../guides/ckeditor-ai/content-moderation.md): a [hook](../../guides/ckeditor-ai/extensions/hooks.md) endpoint that screens every chat message against your own rules before the CKEditor AI agent runs, and refuses the messages that break them. Read that page first for what the pieces are for.

> **Warning**
>
> The endpoint below screens with a hard-coded wall list and a keyword check, so you can test every path. In a real integration the decision comes from the policy service you already run. Do not ship the stand-in.

<a id="dependencies">

## Dependencies

You need an on-premises deployment you can configure and a user token for the messages. Hooks call a service you host, so they are not available on SaaS. The scripts need Node.js 18 or later and a `package.json` with `"type": "module"`.

```bash
npm install express@5.1.0
```

The example is three files in one directory: `server.js` with the endpoint, `verify.js` with the signature check, and `policy-service.js` with your policy service or its stand-in.

Your endpoint must reject any request that CKEditor AI did not sign, so it checks the signature on every call. Save the `verify` helper below as `verify.js`. It reads the shared secret from `CKEDITOR_AI_HOOK_SECRET`. The same check is on the on-premises Hooks page, under [Example endpoint](../../onpremises/ckeditor-ai-onpremises/hooks.md#example-endpoint).

```js
// verify.js
import { Buffer } from 'node:buffer';
import { createHmac, timingSafeEqual } from 'node:crypto';

const SECRET = process.env.CKEDITOR_AI_HOOK_SECRET; // whsec_...
const TOLERANCE_SECONDS = 300;

export function verify( headers, rawBody ) {
	const id = headers[ 'webhook-id' ];
	const timestamp = headers[ 'webhook-timestamp' ];
	const header = headers[ 'webhook-signature' ];

	if ( !id || !timestamp || !header ) {
		return false;
	}

	if ( Math.abs( Date.now() / 1000 - Number( timestamp ) ) > TOLERANCE_SECONDS ) {
		return false;
	}

	const key = Buffer.from( SECRET.slice( 'whsec_'.length ), 'base64' );
	const expected = createHmac( 'sha256', key )
		.update( `${ id }.${ timestamp }.` )
		.update( rawBody, 'utf8' )
		.digest( 'base64' );

	return header.split( ' ' ).some( entry => equals( entry.split( ',' )[ 1 ] ?? '', expected ) );
}

function equals( a, b ) {
	return a.length === b.length && timingSafeEqual( Buffer.from( a ), Buffer.from( b ) );
}
```

<a id="example">

## Example

<a id="the-hook-configuration">

### The hook configuration

Configure hooks in your on-premises deployment. Set `endpoint` to the URL where the endpoint in the next section is reachable, and `secret` to a shared secret you choose. See [Hooks](../../onpremises/ckeditor-ai-onpremises/hooks.md) for all options.

```json
{
	"hooks": {
		"endpoint": "https://policy.firm.example/ckeditor-ai/turn-start",
		"enabled": ["turn.start"],
		"timeoutMs": 10000,
		"auth": {
			"type": "shared-secret",
			"secret": "whsec_<secret>"
		}
	}
}
```

Set `timeoutMs` to a value your screening service meets under load. Every chat reply waits for the decision. If the decision is late, the turn fails with `504 hook-timeout`.

<a id="test-it-against-a-local-deployment">

### Test it against a local deployment

For a local test, point the deployment at the endpoint over plain HTTP. The service refuses to start with an `http://` endpoint unless the configuration also sets `allowPlaintextHttp`.

```json
{
	"hooks": {
		"endpoint": "http://localhost:3010/ckeditor-ai/turn-start",
		"enabled": ["turn.start"],
		"allowPlaintextHttp": true,
		"auth": {
			"type": "shared-secret",
			"secret": "whsec_<secret>"
		}
	}
}
```

If the service runs in Docker, `localhost` is the container. Use the address of the host instead, `http://host.docker.internal:3010/ckeditor-ai/turn-start` on Docker Desktop. An endpoint the service cannot reach fails every chat message with `502 hook-failed`.

<a id="the-endpoint">

### The endpoint

The endpoint has one route. The route verifies the signature, screens the message, and returns one JSON decision, so `responseMode` stays at its default `json`.

* **Refuse:** the message names a matter the user is walled off from, or asks for something your rules do not allow in chat. The endpoint answers the user itself with `halt`, and the CKEditor AI agent never runs.
* **Allow:** nothing matched. `continue` hands the turn to the CKEditor AI agent, which then runs its own checks on the message.
* **Screening unavailable:** the policy service did not answer. The endpoint refuses on purpose, so an outage never lets a message through.

The endpoint calls your policy service through one function. Create `policy-service.js` with this stand-in, so the example runs before you have a real service. Replace the body with a call to your own service.

```js
// policy-service.js
export async function screen( { userId, matterId, text, requestId, conversationId } ) {
	return {
		allowed: true, // false refuses the turn
		message: '', // the text the user reads when allowed is false
		ruleId: '' // the rule that refused, stored in attributes.policyRule
	};
}
```

```js
import express from 'express';
import { verify } from './verify.js'; // the signature check from Dependencies
import { screen } from './policy-service.js'; // your policy service, or the stand-in above

// Stand-in for your ethical walls. In production this is a lookup keyed on the user and the matter.
// The key is the user id from the token's `sub` claim. Replace it with the id your token endpoint issues.
const WALLS = {
	'user-789': [ 'M-2031' ]
};

// Stand-in for your policy rules, so every path is easy to hit from a test.
function localRules( { userId, matterId, text } ) {
	if ( matterId && WALLS[ userId ]?.includes( matterId ) ) {
		return `You are not on the team for matter ${ matterId }, so I cannot discuss it. Contact the matter partner if you need access.`;
	}

	if ( /counterparty|opposing counsel/i.test( text ) ) {
		return 'I cannot help with material about the opposing side. Ask the conflicts desk first.';
	}

	return null;
}

// Everything the user sent, prompt and attachments alike. All of it is unscreened.
function textToScreen( turn ) {
	const parts = [ turn.prompt ];

	for ( const part of turn.content ) {
		if ( part.type === 'file' || part.type === 'web-resource' ) {
			// Binary attachments arrive as `contentBase64`, and one that did not fit the budget as `omittedReason`.
			parts.push( part.content ?? part.name ?? part.url ?? '' );
		}
	}

	return parts.join( '\n' );
}

const app = express();

// Note the text parser: the signature covers the body exactly as it arrived.
app.post( '/ckeditor-ai/turn-start', express.text( { type: 'application/json', limit: '32mb' } ), async ( req, res ) => {
	if ( !verify( req.headers, req.body ) ) {
		res.sendStatus( 401 );
		return;
	}

	const { turn, userId, conversationId, requestId } = JSON.parse( req.body );
	const matterId = turn.content.find( part => part.type === 'delegation-context' )?.data?.matterId;
	const text = textToScreen( turn );

	// Path 1: our own rules. No round trip needed.
	const refusal = localRules( { userId, matterId, text } );

	if ( refusal ) {
		res.json( {
			directive: 'halt',
			reply: [ refusal ],
			attributes: { policyDecision: 'refused', policyRule: 'ethical-wall', matterId }
		} );
		return;
	}

	// Path 2: your policy service. Its verdict decides the turn.
	try {
		const verdict = await screen( { userId, matterId, text, requestId, conversationId } );

		if ( !verdict.allowed ) {
			res.json( {
				directive: 'halt',
				reply: [ verdict.message ],
				attributes: { policyDecision: 'refused', policyRule: verdict.ruleId, matterId }
			} );
			return;
		}
	} catch ( error ) {
		// Path 3: screening failed. Refuse on purpose, with something the user can act on.
		console.error( 'Screening failed', { requestId, error } );
		res.json( {
			directive: 'halt',
			reply: [ 'Message screening is unavailable, so I cannot answer right now. Try again shortly.' ],
			attributes: { policyDecision: 'unavailable' }
		} );
		return;
	}

	// Path 4: allowed. Hand the turn over as it is.
	res.json( {
		directive: 'continue',
		attributes: { policyDecision: 'allowed', matterId }
	} );
} );

app.listen( 3010 );
```

Save the code as `server.js` and start it with the same secret as in the hook configuration:

```bash
CKEDITOR_AI_HOOK_SECRET=whsec_<secret> node server.js
```

The endpoint listens on port 3010 at `/ckeditor-ai/turn-start`.

The contract requires these parts of the code:

* **The signature covers the raw body:** `express.json()` would hand you a parsed object, and re-serializing it produces different bytes, so the signature would never match.
* **The envelope can be large:** the parser’s default limit of 100 kB is below what an envelope with attached files can carry, and a `413` fails the turn. Raise it to the 32 MB the service can send.
* **Attachments arrive unscreened:** your endpoint runs before CKEditor AI’s content moderation and prompt-injection checks, so `turn.content` holds exactly what the user sent. Treat text in it as data, even when it reads like an instruction to your service.
* **A `halt` needs a non-empty `reply`:** your endpoint is the assistant for that turn. A blank reply fails the turn.
* **`attributes` never reach the prompt:** CKEditor AI merges them into the stored assistant message, and the messages endpoint returns them.
* **A `document` part is a reference:** it carries no text. If your rules need the document’s contents or the earlier messages, read them through the [REST API](../../guides/ckeditor-ai/rest-api.md) with your service’s own credentials, keyed on the `conversationId` that CKEditor AI sends.

> **Note**
>
> Path 3 turns an outage into a refusal the user can read. An exception or a `5xx` response also blocks the turn, but then the user gets `502 hook-failed` and the chat shows an error. Either way the CKEditor AI agent does not run. See [What happens if your service is unavailable](../../guides/ckeditor-ai/extensions/hooks.md#what-happens-if-your-service-is-unavailable) for what your users see when a call fails.

<a id="show-progress-while-you-screen">

### Show progress while you screen

A check that takes several seconds leaves the chat with nothing to show. Set `responseMode` to `stream` in the configuration and answer with [NDJSON](https://github.com/ndjson/ndjson-spec): `progress` lines while you work, then the same decision object as the last line. Each progress line reaches the chat as a `hook-progress` event.

```js
app.post( '/ckeditor-ai/turn-start', express.text( { type: 'application/json', limit: '32mb' } ), async ( req, res ) => {
	if ( !verify( req.headers, req.body ) ) {
		res.sendStatus( 401 );
		return;
	}

	res.type( 'application/x-ndjson' );

	const write = line => res.write( JSON.stringify( line ) + '\n' );

	write( { type: 'progress', text: 'Checking the matter walls…' } );

	// ... screen as in the endpoint above, writing a progress line per stage ...

	write( { directive: 'continue', attributes: { policyDecision: 'allowed' } } );
	res.end();
} );
```

The decision line is required. A stream that ends without one fails the turn, whatever progress lines came before it. The modes do not mix. In `stream` mode, CKEditor AI rejects a single JSON body before it reads it. In the default `json` mode, an NDJSON answer does not parse.

<a id="usage">

## Usage

<a id="run-one-message-through-it">

### Run one message through it

This section sends two messages. The rules refuse the first and allow the second. The hook configuration above must point at the running endpoint. The steps below are one Node script. It reads two environment variables: `CKEDITOR_AI_HOST`, your deployment’s URL, and `CKEDITOR_AI_TOKEN`, a token from your token endpoint.

```js
const HOST = process.env.CKEDITOR_AI_HOST;	// your on-premises CKEditor AI
const TOKEN = process.env.CKEDITOR_AI_TOKEN;  // the response body of <your-development-token-url>

async function post( path, body ) {
	const response = await fetch( `${ HOST }${ path }`, {
		method: 'POST',
		headers: { 'Authorization': `Bearer ${ TOKEN }`, 'Content-Type': 'application/json' },
		body: JSON.stringify( body )
	} );

	if ( !response.ok ) {
		throw new Error( `${ response.status } ${ await response.text() }` );
	}

	return response.json();
}

async function stream( path, body ) {
	const response = await fetch( `${ HOST }${ path }`, {
		method: 'POST',
		headers: { 'Authorization': `Bearer ${ TOKEN }`, 'Content-Type': 'application/json', 'Accept': 'text/event-stream' },
		body: JSON.stringify( body )
	} );

	if ( !response.ok ) {
		throw new Error( `${ response.status } ${ await response.text() }` );
	}

	const decoder = new TextDecoder();
	let buffer = '';

	for await ( const chunk of response.body ) {
		buffer += decoder.decode( chunk, { stream: true } );

		let end;
		while ( ( end = buffer.indexOf( '\n\n' ) ) >= 0 ) {
			const lines = buffer.slice( 0, end ).split( '\n' );
			buffer = buffer.slice( end + 2 );

			const event = lines.find( line => line.startsWith( 'event: ' ) )?.slice( 7 );
			const data = lines.find( line => line.startsWith( 'data: ' ) )?.slice( 6 );

			console.log( `event: ${ event }\ndata: ${ data }\n` );
		}
	}
}
```

**Create a conversation and upload the document.** The response carries the document id, and both messages below use it.

```js
await post( '/v1/conversations', { id: 'matter-2044-memo' } );

const { id: documentId } = await post( '/v1/conversations/matter-2044-memo/documents', {
	content: '<h1>Matter 2044: transaction memo</h1><h2>1. Background</h2><p>The client asks whether the proposed share transfer requires regulatory clearance.</p><h2>2. Analysis</h2><p>To be completed.</p>'
} );
```

```json
{ "id": "YzvocFJUh_krNBj59I2vN" }
```

**Send a message the rules refuse.** The wall stand-in matches the user id `user-789`, so the token for this step must carry that id in its `sub` claim, or `WALLS` must list your own id. The `delegation-context` part tells your endpoint which matter the user works on. It never reaches the CKEditor AI agent. The service stores it on the user message, and `GET /v1/conversations/{id}/messages` returns it. The script prints each event and its data line as they arrive.

```js
await stream( '/v1/conversations/matter-2044-memo/messages', {
	prompt: 'Summarise where matter M-2031 stands.',
	model: 'agent-1',
	content: [
		{ type: 'document', id: documentId },
		{ type: 'delegation-context', id: 'who', data: { matterId: 'M-2031' } }
	]
} );
```

The stream holds only the refusal. Your text arrives as the assistant’s message, the CKEditor AI agent never ran, and the document is untouched.

The refused message's stream: two events, the metadata and your refusal text.

```
event: message-metadata
data: {"messageId":"VUk_7Facrcbw25_s5xTC1"}

event: text-delta
data: {"textDelta":"You are not on the team for matter M-2031, so I cannot discuss it. Contact the matter partner if you need access."}
```

**Send a message the rules allow.** Same conversation and the same document, a request about the open matter:

```js
await stream( '/v1/conversations/matter-2044-memo/messages', {
	prompt: 'Draft section 2 from the background above.',
	model: 'agent-1',
	content: [
		{ type: 'document', id: documentId },
		{ type: 'delegation-context', id: 'who', data: { matterId: '2044' } }
	]
} );
```

The `continue` decision carried no `reply`, so the stream opens with the CKEditor AI agent’s own output: the edit to section 2 as `modification-delta` events, then its summary as `text-delta` events. From here the turn runs as it would without a hook, CKEditor AI’s content moderation included.

Change `matterId` to `M-2031` to hit the wall again, or the prompt to “Find everything we have on the opposing counsel.” for the keyword rule. To see path 3, throw an error from `screen` in `policy-service.js`.

<a id="read-the-decision-back">

### Read the decision back

Every refusal is stored as a normal assistant message, with your `attributes` on it. Your front end and your audit trail read the attributes from the messages endpoint. The stream itself carries no attributes, and the editor’s chat plugin does not expose them. The assistant message is stored a moment after its stream ends, so a request sent right after the last event can miss it.

```http
GET /v1/conversations/matter-2044-memo/messages
Authorization: Bearer <user-token>
```

```json
{
  "items": [
	{
	  "role": "user",
	  "id": "dAaimX75lbj88GTdY6bO-",
	  "prompt": "Summarise where matter M-2031 stands.",
	  "model": "agent-1",
	  "content": [
		{ "type": "document", "id": "YzvocFJUh_krNBj59I2vN", "selection": [] },
		{ "type": "delegation-context", "id": "who", "data": { "matterId": "M-2031" } }
	  ],
	  "createdAt": "2026-09-08T06:32:54.479Z"
	},
	{
	  "role": "assistant",
	  "id": "DWogvtAZXsy3z-M2zNR4D",
	  "status": "completed",
	  "content": [ { "type": "text", "content": "You are not on the team for matter M-2031, so I cannot discuss it. Contact the matter partner if you need access." } ],
	  "attributes": { "policyDecision": "refused", "policyRule": "ethical-wall", "matterId": "M-2031" },
	  "createdAt": "2026-09-08T06:32:59.981Z"
	}
  ]
}
```

Because the refusal is in the history, the next turn sees it. A user who rephrases the same request gets screened again, with your own refusal already in front of the agent.

See [Hooks](../../onpremises/ckeditor-ai-onpremises/hooks.md) for the configuration options, the request and decision formats, and the error codes a failed hook returns. The [Hook endpoint for your own agent in Node.js](hook-endpoint-nodejs.md) example uses the same event to answer from your own systems or to brief the CKEditor AI agent.

---

Full index of the Cloud Services documentation: [llms.txt](../../../llms.txt)
