Skip to content

Modules (NXM)

Module services

Code of an NXM module that runs outside nexxus. How nexxus calls a module service, how the service calls back with a short-lived token, and how to build, deploy and connect one.

For
Developers
Time
20 min

Declarations and views cover most of what a module does. Some work needs real code: computing a legal deadline with the holidays of a German state, reacting when a record changes, reading the text of an incoming PDF. That code never runs inside nexxus. It runs in a module service, an HTTP service of its own that the package declares and nexxus calls.

The rule behind this is simple. A package comes from outside the platform, so its code stays outside the platform process. nexxus sends the service what it needs together with a signed token that is valid for one minute, and the service calls back through the same record API as everyone else, with exactly the rights the administrator agreed to at install. The service holds no passwords and no API keys.

The Fristendienst of the CuriaOS case study is a real example: it computes deadlines, adds early warnings and proposes deadlines it finds in court letters.

Reach for a service when:

  • an action needs logic that update, create and templates cannot express, such as a calculation with its own rules,
  • the module must react to changes that people, actions or the AI Assistant make, for example to keep a summary field up to date,
  • the work needs a library that a view cannot load, such as a PDF text extractor,
  • the work runs in the background without a person on the page.

If a view or a declarative action does the job, you need no service.

  1. Something happens

    A record changes, the installation is installed, updated or activated, or a person runs a service action.

  2. nexxus calls the service

    POST to the service with a signed invocation token, valid for 60 seconds.

  3. The service checks

    It verifies the token against the public keys of nexxus.

  4. Token exchange

    On its first callback it trades the token once for an access token, valid for 10 minutes.

  5. The service calls back

    It reads and writes records through the record API. nexxus checks the rights on every request.

The invocation token proves that nexxus sent the call. The access token lets the service act as the installation's service account, never as more.

In detail, for one call that reads and writes records:

nexxus
Module service
  1. 1POST /events or /actions/<operation>, Bearer invocation token (60 s)nexxus → Module service
  2. 2GET /api/module-runtime/.well-known/jwks.json (cached)Module service → nexxus
  3. 3Check signature, typ, iss, aud, exp and that trigger fits the bodyModule service
  4. 4POST /api/module-runtime/token: exchange the invocation token, onceModule service → nexxus
  5. 5Access token for the service account (10 min)nexxus → Module service
  6. 6GET, POST, PATCH /api/x/<module>/<entity> with the access tokenModule service → nexxus
  7. 7Check the service account, the consented scopes and, if set, the personnexxus
  8. 8Answer 2xx: the event counts as delivered, an action gets its resultModule service → nexxus
The service holds no credentials of its own. Every token it uses comes from this call and expires within minutes.

A service is part of the manifest. This is the real declaration of the Fristendienst from the package curiaos.kanzlei:

"services": [
{
"key": "fristendienst",
"label": { "de": "Fristendienst", "en": "Deadline service" },
"description": { "de": "Berechnet Fristen ...", "en": "Computes deadlines ..." },
"url": "http://localhost:8390/fristendienst",
"events": ["frist.created", "frist.updated", "frist.deleted", "aktendokument.created"],
"operations": ["berechnen", "eintragen"]
}
]
FieldTypeRequiredMeaning
keystringyesName of the service within the package, e.g. fristendienst
label{de, en, pl?}yesShown in the catalogue and in error messages
description{de, en, pl?}noWhat the service does
urlstring, http(s)://yesDefault address. An instance can override it (see below)
eventsstring listnoRecord events the service receives: <entity>.created, .updated, .deleted of the package’s own entities. A service hears no other package
operationsstring listnoOperations that type: "service" actions may call

An action calls an operation like this:

{ "key": "frist-berechnen", "entity": "frist", "type": "service",
"service": "fristendienst", "operation": "berechnen",
"label": { "de": "Frist berechnen", "en": "Compute deadline" }, "aiTool": true }

Add "async": true when the operation takes longer: nexxus then answers the person right after the service accepted the call, and the person sees "<action>" is running. The result appears once the service has finished.

The core data the service may touch outside its own module goes into scopes, for example core:dmsDocument:read. The administrator agrees to every scope and to every service with its host when installing or updating the package. The full scope list is on Package structure.

A package that declares a module service, as the catalogue shows it
Before installing, the admin sees the service, its host and the events it receives.

Every call is a POST with Authorization: Bearer <invocation token> and a JSON body. Every body carries the receiving installation with its version and its settings. settings holds only the keys the manifest declares under settings. Any 2xx answer counts as success.

POST <url>/events when a record of a subscribed entity was created, changed or deleted:

{
"id": 4711,
"type": "frist.updated",
"module": "curiaos.kanzlei",
"entity": "frist",
"recordId": 42,
"data": { "titel": "Berufungsfrist", "faellig_am": "2026-11-16", "status": "offen" },
"occurredAt": "2026-10-09T08:15:00Z",
"installation": { "id": 7, "version": "1.0.0", "settings": { "bundesland": "by", "vorfrist_werktage": 3 } }
}

module is the receiving package itself: a service hears only about records of its own package. type is always <entity>.<op> without the package prefix.

POST <url>/events when the installation was installed, updated or activated, without any subscription:

{
"id": 4712,
"type": "installation.installed",
"module": "curiaos.kanzlei",
"installation": { "id": 7, "version": "1.0.0", "settings": { "bundesland": "by", "vorfrist_werktage": 3 } },
"occurredAt": "2026-10-09T08:20:00Z"
}

The types are installation.installed, installation.upgraded and installation.activated. They let a service catch up with what it would have done had it been there from the start, for example fill in a computed field for every existing record. previousVersion is set on updates and when a removed installation is installed again.

POST <url>/actions/<operation> when a person or the AI Assistant runs a type: "service" action:

{
"action": "frist-berechnen",
"recordId": 42,
"record": { "id": 42, "module": "curiaos.kanzlei", "entity": "frist", "data": { "...": "..." }, "version": 3 },
"payload": { "ereignisdatum": "2026-10-01", "regel": "berufung" },
"installation": { "id": 7, "version": "1.0.0", "settings": { "bundesland": "by", "vorfrist_werktage": 3 } }
}

The JSON your service answers becomes the action’s result. nexxus waits up to 30 seconds. To refuse, answer with a 4xx status and a JSON object { "errorCode": "COMPOSE_STALE", "message": "...", "messages": { "de": "...", "en": "...", "pl": "..." } }; every key is optional, but send at least a code or a text. nexxus answers the person with 422 SERVICE_REJECTED and passes your code and status on beside it as serviceCode and serviceStatus, so a view can react to COMPOSE_STALE without reading the text. The text is in the person’s language: with messages your own text in that language (else English, else German) and nothing in front; with one message only, nexxus puts „Der Dienst „<Label>“ hat die Aktion abgelehnt:“ in front of it, or “The service “<Label>” refused the action:” or „Usługa „<Label>” odrzuciła akcję:“. Texts arrive as plain text, at most 300 characters each.

POST <url>/portal/<key> when a visitor of the tenant’s customer portal uses an endpoint that the package declares under portal.endpoints with this service. The visitor is anonymous: the token carries no principal, and its trigger is portal:<key>, so a token issued for one endpoint cannot call another. nexxus forwards only the declared params (record as a number, the others as the visitor sent them) and for a POST the form, which it has already checked (types, required fields, e-mail address, consent, honeypot, rate limit); a GET sends form: null. Before forwarding it also checks that record is a public record. locale is the language of the portal page, de, en or pl. A GET endpoint reaches the service as a POST too. nexxus waits up to 10 seconds.

{
"endpoint": "quote",
"method": "GET",
"params": { "record": 12, "check_in": "2026-11-12", "check_out": "2026-11-16", "guests": "4" },
"form": null,
"locale": "en",
"installation": { "id": 7, "version": "1.2.0", "settings": { "currency": "ZAR", "time_zone": "Africa/Johannesburg" } }
}

Check everything again: the call comes from the internet. The JSON of a 2xx answer (at most 64 KB) goes to the visitor as it is, so answer only with what the site may show. A refusal the visitor should read is a 400, 409 or 422 with { "errorCode": "...", "message": "...", "messages": { "de": "...", "en": "...", "pl": "..." }, "fieldErrors": { "<field>": "..." } } (pl optional), the texts in the page’s language; nexxus passes it on with the same status (plain text, at most 300 characters each). Use 422 for bad input and 409 when the current state does not allow it, for example dates that are taken by now. A record the site does not show nexxus refuses with 404 before it forwards the call. Every other status, and no answer within the time, reaches the visitor as a generic error. The Higgins-Dienst answers the booking site’s availability, quote and booking_request this way.

nexxus signs every call with ES256. The header carries kid and typ: nexxus-module-it. The public keys are at GET <nexxus>/api/module-runtime/.well-known/jwks.json, without a session.

ClaimMeaning
issnexxus
audThe module key, e.g. curiaos.kanzlei
submodule:<module key>
tenantId of the tenant
instId of the installation
verInstalled version of the module
triggerWhat caused the call: event:<type>, lifecycle:<type> or action:<key>
principal{userId, name} of the person who ran the action; absent for events
jti, iatToken id and issue time
expiat + 60 seconds

Check typ, iss, aud, sub and exp, and check that trigger fits the call: an event body with type frist.updated needs event:frist.updated. That way a token for one call cannot be replayed for another. nexxus accepts only ES256 and tolerates 5 seconds of clock difference.

On its first callback the service trades the invocation token for an access token (RFC 8693):

POST /api/module-runtime/token
Content-Type: application/x-www-form-urlencoded
grant_type=urn:ietf:params:oauth:grant-type:token-exchange
&subject_token=<invocation token>
&subject_token_type=urn:ietf:params:oauth:token-type:jwt
{
"access_token": "eyJ...",
"token_type": "Bearer",
"expires_in": 600,
"issued_token_type": "urn:ietf:params:oauth:token-type:access_token"
}

The endpoint also takes the same fields as JSON. Each invocation token can be exchanged once, and only while the installation is active. Errors follow RFC 6749: status 400 with {error, error_description} and error invalid_grant, invalid_request or unsupported_grant_type.

Access token claimMeaning
typ (header)nexxus-module-at
audnexxus
subId of the installation’s service account
act{sub: "module:<module key>"}
tenant, instTenant and installation
principalCopied from the invocation token, if a person acted
scopeThe consented scopes, separated by spaces
expAt most 10 minutes after issue

nexxus accepts the access token only on /api/x/** (records and actions), /api/module-runtime/lookup and /api/module-runtime/documents/**. Anywhere else it answers 401 with „Ein Modul-Token gilt nur für die Modul-Schnittstelle.“ (a module token is only valid for the module interface).

A service acts as the service account of its installation. nexxus creates that account at install, one per installation, with a role that holds every permission of the module. The account cannot sign in, and nexxus switches it off while the module is deactivated or uninstalled. Records the service writes name it as their creator. It is named in the tenant’s default language, the module’s name followed by „(module service)“ in English, „(Moduldienst)“ in German or „(usługa modułu)“ in Polish, and nexxus names it anew on every install, upgrade, activation and reinstall. Its role is named the same way.

DataAccess
Records of its own moduleRead and write, including readonly fields
Core data (partners, users, documents, ISMS measures)Only within the consented core: scopes
Documents (PDF bytes)With core:dmsDocument:read, and only documents that a record of its own module references

If a person ran the action, the token carries that person as principal, and nexxus checks the person’s rights as well. Both must pass. Event deliveries carry no person.

The nexxus sources contain a kit for module services under module-services/: a Bun and TypeScript process that hosts one route group per service. It verifies tokens, binds them to the call, exchanges them on the first callback and logs every request without tokens or bodies. You write the handlers.

A service is a ModuleServiceDefinition:

MemberMeaning
nameFolder name and path prefix: rueckgabe serves POST /rueckgabe/events, POST /rueckgabe/actions/<operation> and POST /rueckgabe/portal/<key>
moduleKeyThe module the service belongs to; the token’s aud must name it
eventsHandler for record events
lifecycleHandler for installation.installed, .upgraded and .activated
actionsHandlers by operation, e.g. { zaehlen }
portalHandlers of public portal endpoints by key, e.g. { quote }. A handler may answer with a Response of its own to set the status of a refusal

Each handler gets {claims, body, log, platform}. platform is a PlatformClient that calls back with the access token:

MethodDoes
search(entity, {q?, ids?, filter?, sort?, from?, size?})One page of records, {items, total}. ids keeps only these records, at most 200; more throw before any call
searchAll(entity, query?)Every matching record, page by page (200 per page, sorted by id). Takes any number of ids and asks for them 200 at a time
get(entity, id)One record
create(entity, data)Creates a record
patch(entity, id, data, version?)Changes fields; with version, a newer record answers CONFLICT
documentContent(documentId)The bytes of a referenced document

entity is one of the module’s own; the kit reaches no other package. A failed platform call throws a PlatformError with status, errorCode and message. Throw an HttpError(status, errorCode, message) to answer with an error of your own.

This service belongs to the acme.leihgeraete package from the first module tutorial. It gives every lent device without a return date one 14 days ahead, and it offers an operation that counts overdue devices.

import type { ModuleServiceDefinition } from '../kit/service.ts';
export function rueckgabedienst(): ModuleServiceDefinition {
return {
name: 'rueckgabe',
moduleKey: 'acme.leihgeraete',
async events({ body, platform, log }) {
if (body.type !== 'geraet.updated') return { ok: true, ignoriert: body.type };
// Events can arrive late or twice: read the current record instead of trusting body.data.
const geraet = await platform.get('geraet', body.recordId);
if (geraet.data.status !== 'verliehen' || geraet.data.rueckgabe_am) return { ok: true };
const due = new Date(Date.now() + 14 * 86_400_000).toISOString().slice(0, 10);
await platform.patch('geraet', geraet.id, { rueckgabe_am: due }, geraet.version);
log.info('return date set', { recordId: geraet.id });
return { ok: true };
},
actions: {
async zaehlen({ platform }) {
const today = new Date().toISOString().slice(0, 10);
const { total } = await platform.search('geraet', {
filter: { status: 'verliehen', rueckgabe_am: { to: today } }, size: 1,
});
return { faellig: total };
},
},
};
}

Add it to the list in src/main.ts next to the other services, and declare it in the manifest:

"services": [{
"key": "rueckgabe",
"label": { "de": "Rückgabedienst", "en": "Return service" },
"url": "http://localhost:8390/rueckgabe",
"events": ["geraet.updated"],
"operations": ["zaehlen"]
}],
"actions": [{
"key": "faellige-zaehlen", "entity": "geraet", "type": "service",
"service": "rueckgabe", "operation": "zaehlen",
"label": { "de": "Fällige zählen", "en": "Count due" }
}]

The service’s own patch does not come back to it as an event, so it cannot trigger itself.

The kit needs one environment variable and holds no secrets:

VariableDefaultMeaning
PLATFORM_URLrequiredBase URL of the nexxus backend as the service reaches it, without a trailing slash. JWKS, token exchange and record API are read from it
PORT8390Port the service listens on
Terminal window
bun install
PLATFORM_URL=https://nexxus.example.com bun dev # watch mode
bun test
bun run typecheck

GET /health answers {"status": "UP", "services": [...]} without a token. The folder contains a Dockerfile that runs the kit as a non-root user. Only nexxus needs to reach the service, and the service needs to reach nexxus.

You don’t have to use the kit. Any HTTP service in any language works if it verifies the invocation token as described above, exchanges it and calls the record API.

The manifest’s url is a default, and localhost rarely is where the service runs. nexxus takes the first usable address from:

  1. the operator configuration of the instance,
  2. the installation setting serviceUrls.<service>,
  3. the manifest’s url.

Only absolute http and https URLs count; anything else falls through to the next source.

When the manifest’s url points at localhost or 127.0.0.1 and the instance sets nexxus.modules.local-service-base-url, nexxus replaces scheme, host and port with that address and keeps path and query. When all services of an instance run behind one address, such as http://module-services:8390, no service needs a line of its own (environment variable NEXXUS_MODULES_LOCALSERVICEBASEURL). The consent counts the host after the replacement. The installation setting stays as it is, even when it names localhost.

nexxus:
modules:
service-urls:
"[acme.leihgeraete.rueckgabe]": http://rueckgabe:8390/rueckgabe

The key is <module key>.<service key> in brackets, because module keys contain a dot. As an environment variable the same key is NEXXUS_MODULES_SERVICEURLS_ACME_LEIHGERAETE_RUECKGABE. Through the API, an administrator can set the installation setting with PATCH /api/module-installations/<key>/settings and {"settings": {"serviceUrls": {"rueckgabe": "https://..."}}}.

The consent belongs to the host. The administrator agrees to service:<key>@<host> (with the port when the URL names one), shown in the install dialog as Call the service "<label>" at <host> and send it events. Pointing a service at another host therefore needs a new consent. When an administrator sets the URL through the installation settings, that change counts as the consent. Changing the host in the operator configuration has the same effect on the consent: until it matches, nexxus refuses the service.

nexxus writes every event to an outbox in the same transaction as the change, so no event is lost when a change is saved, and no event goes out for a change that was rolled back.

  • A dispatcher sends due events every 2 seconds, with a timeout of 10 seconds.
  • Delivery is at least once and not ordered. A retried event can arrive after a later one.
  • After the n-th failure the event waits 10 s × 2n−1 (10, 20, 40, 80, 160, 320, 640 seconds). The 8th failure marks it as failed, about 21 minutes after the first attempt.
  • If the installation is no longer active or the service is no longer consented, the event fails at once with „Installation nicht aktiv“ (installation not active) or „Dienst „<key>“ ist nicht freigegeben“ (service not released).
  • A service receives no events for its own writes. It must do any follow-up work in the same handling.
  • During an update, every migration step sends one event per record it changes, before installation.upgraded. See Data and upgrades.
  • nexxus keeps delivered and failed events for 30 days.

So write handlers that can run twice: use the event id to recognise a repeat, read the current record instead of trusting data, and only write values that differ.

Administrators see the deliveries on the package page under AdministrationOperationsModules, tab Packages, in the card Event deliveries: the last 100 events with Event, State (Waiting, Delivered or Failed), the number of attempts, the last error and Created. Refresh reloads the list, and the card also refreshes itself every 5 seconds.

These are the messages a person sees when a service action fails:

MessageCauseFix
„Der Dienst „<Label>“ ist für diesen Mandanten nicht freigegeben. Eine Administratorin oder ein Administrator muss ihm unter System › Module zustimmen.“The installation’s consent does not contain the service at its current hostAn administrator gives the consent again: setting the service URL in the installation settings counts as consent, and the update dialog of a new version asks for it
„Der Dienst „<Label>“ hat die Aktion abgelehnt: <message>“, in English or Polish for people who work in those languages, or the service’s own text from messagesThe service answered 4xx with a message or messagesRead the message; it is the service’s own reason
„Der Dienst „<Label>“ hat die Aktion abgelehnt (<code>).“The service answered 4xx with an errorCode and no textSend a message or messages with the code, so the person learns why
„Der Dienst „<Label>“ hat die Aktion abgelehnt (HTTP <status>).“The service answered 5xx, or 4xx with neither a code nor a textFix the error in the service, or answer a refusal with JSON {"errorCode": "...", "message": "..."}
„Der Dienst „<Label>“ ist nicht erreichbar.“ or „Der Dienst „<Label>“ wurde nicht erreicht.“No connection, or no answer within 30 secondsCheck that the service runs and that the effective URL reaches it
„Der Dienst „<Label>“ hat keine lesbare Antwort geliefert.“The answer was not JSONAnswer with JSON, or with an empty body

For events, look at the deliveries card: the last error of each event is in the State column. Check that the service reaches PLATFORM_URL and that its clock is correct, because both tokens are checked against their expiry time. A service that checks tokens against the keys of another nexxus instance answers 401 with kid unknown: its PLATFORM_URL points to the wrong platform.

Event deliveries that wait for a retry after the service refused the token
The last error says why: here the service checks tokens against another platform's keys.
KeyDefaultMeaning
nexxus.modules.service-urls."[<module>.<service>]"noneWhere a module service runs
nexxus.modules.local-service-base-urlnoneReplaces localhost and 127.0.0.1 in the manifests’ url, for example with http://module-services:8390
nexxus.modules.tokens.invocation-ttl-seconds60Lifetime of an invocation token
nexxus.modules.tokens.access-ttl-seconds600Lifetime of an access token
nexxus.modules.tokens.clock-skew-seconds5Tolerated clock difference
nexxus.modules.events.dispatcher-enabledtrueSwitch off only on instances that must not deliver
nexxus.modules.events.dispatch-interval-ms2000How often due events go out
nexxus.modules.events.batch-size20Events per round
nexxus.modules.events.max-attempts8Attempts before an event fails
nexxus.modules.events.backoff-base-seconds10Delay after the n-th failure: base × 2n−1
nexxus.modules.events.timeout-seconds10Timeout per delivery
nexxus.modules.events.retention-days30How long delivered and failed events are kept
nexxus.modules.actions.timeout-seconds30Timeout of a service action

nexxus creates the signing keys for the tokens on first start and stores them encrypted in its database, shared by all instances of the platform.

  • One exchange, ten minutes. An invocation token can be exchanged once, and the access token cannot be renewed. One call may work with nexxus for 10 minutes at most.
  • Long handlers count as failed. An event handler that takes longer than 10 seconds counts as a failed attempt, even if it finishes later. Keep every write idempotent so the retry finds the work done.
  • One replica of the kit. The kit remembers delivered event ids in memory. Two replicas could do the same work twice.