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

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](/modules/curiaos/) is a real example: it computes deadlines, adds early warnings and proposes deadlines it finds in court letters.

## When a module needs a service

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.

## How a call works

<Flow
  steps={[
    { icon: 'zap', title: 'Something happens', text: 'A record changes, the installation is installed, updated or activated, or a person runs a service action.' },
    { icon: 'send', title: 'nexxus calls the service', text: 'POST to the service with a signed invocation token, valid for 60 seconds.', tone: 'cyan' },
    { icon: 'shield-check', title: 'The service checks', text: 'It verifies the token against the public keys of nexxus.', tone: 'lavender' },
    { icon: 'key-round', title: 'Token exchange', text: 'On its first callback it trades the token once for an access token, valid for 10 minutes.', tone: 'lavender' },
    { icon: 'database', title: 'The service calls back', text: 'It reads and writes records through the record API. nexxus checks the rights on every request.' },
  ]}
  caption="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:

<Sequence
  actors={[
    { name: 'nexxus', icon: 'server' },
    { name: 'Module service', icon: 'server-cog' },
  ]}
  steps={[
    { from: 0, to: 1, label: 'POST /events or /actions/<operation>, Bearer invocation token (60 s)' },
    { from: 1, to: 0, label: 'GET /api/module-runtime/.well-known/jwks.json (cached)' },
    { from: 1, to: 1, label: 'Check signature, typ, iss, aud, exp and that trigger fits the body' },
    { from: 1, to: 0, label: 'POST /api/module-runtime/token: exchange the invocation token, once' },
    { from: 0, to: 1, label: 'Access token for the service account (10 min)' },
    { from: 1, to: 0, label: 'GET, POST, PATCH /api/x/<module>/<entity> with the access token' },
    { from: 0, to: 0, label: 'Check the service account, the consented scopes and, if set, the person' },
    { from: 1, to: 0, label: 'Answer 2xx: the event counts as delivered, an action gets its result' },
  ]}
  caption="The service holds no credentials of its own. Every token it uses comes from this call and expires within minutes."
/>

## Declaring a service

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

```json
"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"]
  }
]
```

| Field | Type | Required | Meaning |
|---|---|---|---|
| `key` | string | yes | Name of the service within the package, e.g. `fristendienst` |
| `label` | `{de, en, pl?}` | yes | Shown in the catalogue and in error messages |
| `description` | `{de, en, pl?}` | no | What the service does |
| `url` | string, `http(s)://` | yes | Default address. An instance can override it (see below) |
| `events` | string list | no | Record events the service receives: `<entity>.created`, `.updated`, `.deleted` of the package's own entities. A service hears no other package |
| `operations` | string list | no | Operations that `type: "service"` actions may call |

An action calls an operation like this:

```json
{ "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](/modules/structure/).

<Screenshot src={shot_nxm_41} url="/modules/pakete/curiaos.kanzlei" alt={"A package that declares a module service, as the catalogue shows it"} caption={"Before installing, the admin sees the service, its host and the events it receives."} />

## What nexxus sends

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.

### Record events

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

```json
{
  "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.

### Lifecycle events

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

```json
{
  "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.

### Actions

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

```json
{
  "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 „&lt;Label&gt;“ hat die Aktion abgelehnt:“ in front of it, or “The service “&lt;Label&gt;” refused the action:” or „Usługa „&lt;Label&gt;” odrzuciła akcję:“. Texts arrive as plain text, at most 300 characters each.

### Public endpoints

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

```json
{
  "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.

## The tokens

### Invocation token

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.

| Claim | Meaning |
|---|---|
| `iss` | `nexxus` |
| `aud` | The module key, e.g. `curiaos.kanzlei` |
| `sub` | `module:<module key>` |
| `tenant` | Id of the tenant |
| `inst` | Id of the installation |
| `ver` | Installed version of the module |
| `trigger` | What caused the call: `event:<type>`, `lifecycle:<type>` or `action:<key>` |
| `principal` | `{userId, name}` of the person who ran the action; absent for events |
| `jti`, `iat` | Token id and issue time |
| `exp` | `iat` + 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.

### Exchanging it for an access token

On its first callback the service trades the invocation token for an access token ([RFC 8693](https://www.rfc-editor.org/rfc/rfc8693)):

```http
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
```

```json
{
  "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 claim | Meaning |
|---|---|
| `typ` (header) | `nexxus-module-at` |
| `aud` | `nexxus` |
| `sub` | Id of the installation's service account |
| `act` | `{sub: "module:<module key>"}` |
| `tenant`, `inst` | Tenant and installation |
| `principal` | Copied from the invocation token, if a person acted |
| `scope` | The consented scopes, separated by spaces |
| `exp` | At 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).

## What a service may do

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.

| Data | Access |
|---|---|
| Records of its own module | Read 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 service kit

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`:

| Member | Meaning |
|---|---|
| `name` | Folder name and path prefix: `rueckgabe` serves `POST /rueckgabe/events`, `POST /rueckgabe/actions/<operation>` and `POST /rueckgabe/portal/<key>` |
| `moduleKey` | The module the service belongs to; the token's `aud` must name it |
| `events` | Handler for record events |
| `lifecycle` | Handler for `installation.installed`, `.upgraded` and `.activated` |
| `actions` | Handlers by operation, e.g. `{ zaehlen }` |
| `portal` | Handlers 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:

| Method | Does |
|---|---|
| `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.

### A small service

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

```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:

```json
"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.

### Run it

The kit needs one environment variable and holds no secrets:

| Variable | Default | Meaning |
|---|---|---|
| `PLATFORM_URL` | required | Base URL of the nexxus backend as the service reaches it, without a trailing slash. JWKS, token exchange and record API are read from it |
| `PORT` | `8390` | Port the service listens on |

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

## Connecting a service on an instance

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

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

## Delivery, retries and the deliveries card

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 × 2<sup>n−1</sup> (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 „&lt;key&gt;“ 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](/modules/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 <MenuPath to="Administration > Operations > Modules" />, tab <Ui>Packages</Ui>, in the card <Ui>Event deliveries</Ui>: the last 100 events with <Ui>Event</Ui>, <Ui>State</Ui> (<Ui>Waiting</Ui>, <Ui>Delivered</Ui> or <Ui>Failed</Ui>), the number of attempts, the last error and <Ui>Created</Ui>. <Ui>Refresh</Ui> reloads the list, and the card also refreshes itself every 5 seconds.

## When something goes wrong

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

| Message | Cause | Fix |
|---|---|---|
| „Der Dienst „&lt;Label&gt;“ 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 host | An 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 „&lt;Label&gt;“ hat die Aktion abgelehnt: &lt;message&gt;“, in English or Polish for people who work in those languages, or the service's own text from `messages` | The service answered 4xx with a `message` or `messages` | Read the message; it is the service's own reason |
| „Der Dienst „&lt;Label&gt;“ hat die Aktion abgelehnt (&lt;code&gt;).“ | The service answered 4xx with an `errorCode` and no text | Send a `message` or `messages` with the code, so the person learns why |
| „Der Dienst „&lt;Label&gt;“ hat die Aktion abgelehnt (HTTP &lt;status&gt;).“ | The service answered 5xx, or 4xx with neither a code nor a text | Fix the error in the service, or answer a refusal with JSON `{"errorCode": "...", "message": "..."}` |
| „Der Dienst „&lt;Label&gt;“ ist nicht erreichbar.“ or „Der Dienst „&lt;Label&gt;“ wurde nicht erreicht.“ | No connection, or no answer within 30 seconds | Check that the service runs and that the effective URL reaches it |
| „Der Dienst „&lt;Label&gt;“ hat keine lesbare Antwort geliefert.“ | The answer was not JSON | Answer with JSON, or with an empty body |

For events, look at the deliveries card: the last error of each event is in the <Ui>State</Ui> 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.

<Screenshot src={shot_nxm_42} url="/modules/pakete/curiaos.kanzlei" alt={"Event deliveries that wait for a retry after the service refused the token"} caption={"The last error says why: here the service checks tokens against another platform's keys."} />

## Configuration

| Key | Default | Meaning |
|---|---|---|
| `nexxus.modules.service-urls."[<module>.<service>]"` | none | Where a module service runs |
| `nexxus.modules.local-service-base-url` | none | Replaces `localhost` and `127.0.0.1` in the manifests' `url`, for example with `http://module-services:8390` |
| `nexxus.modules.tokens.invocation-ttl-seconds` | `60` | Lifetime of an invocation token |
| `nexxus.modules.tokens.access-ttl-seconds` | `600` | Lifetime of an access token |
| `nexxus.modules.tokens.clock-skew-seconds` | `5` | Tolerated clock difference |
| `nexxus.modules.events.dispatcher-enabled` | `true` | Switch off only on instances that must not deliver |
| `nexxus.modules.events.dispatch-interval-ms` | `2000` | How often due events go out |
| `nexxus.modules.events.batch-size` | `20` | Events per round |
| `nexxus.modules.events.max-attempts` | `8` | Attempts before an event fails |
| `nexxus.modules.events.backoff-base-seconds` | `10` | Delay after the n-th failure: base × 2<sup>n−1</sup> |
| `nexxus.modules.events.timeout-seconds` | `10` | Timeout per delivery |
| `nexxus.modules.events.retention-days` | `30` | How long delivered and failed events are kept |
| `nexxus.modules.actions.timeout-seconds` | `30` | Timeout 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.

## Known limits

- **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.

## Next steps

<CardGrid>
  <LinkCard title="Case study: CuriaOS" description="The Fristendienst: deadlines, early warnings and proposals from court letters." href="/modules/curiaos/" />
  <LinkCard title="Data and upgrades" description="Versions, migration steps and the events an update sends." href="/modules/data-and-upgrades/" />
  <LinkCard title="Package, install and update" description="Upload a package, give consent and assign roles." href="/modules/publish/" />
</CardGrid>