---
title: "Report Cases (NIS-2)"
description: "Track an NIS-2 report case: criteria, the reportable decision, awareness time, deadlines, report drafts and the log of what you submitted in the BSI portal."
---

`GET /api/im/report-cases` · `POST /api/im/report-cases` · `GET /api/im/report-cases/:id` · `PATCH /api/im/report-cases/:id`

`POST /api/im/report-cases/:id/aware-at` · `POST /api/im/report-cases/:id/criteria` · `POST /api/im/report-cases/:id/decision` · `POST /api/im/report-cases/:id/interim-request` · `POST /api/im/report-cases/:id/close`

`POST /api/im/report-cases/:id/incidents` · `DELETE /api/im/report-cases/:id/incidents/:incidentId` · `GET /api/im/report-cases/:id/draft` · `POST /api/im/report-cases/:id/submissions`

A report case collects what you need for an NIS-2 report (Art. 23 NIS-2 Directive, § 32 BSIG): the linked incidents, a decision per significance criterion, the decision whether the incident is reportable, the time you became aware, and the deadlines that follow (early warning 24 h, notification 72 h, final report one month after the notification, intermediate report on request). **You submit every report yourself in the BSI portal. Uptimeify transmits nothing and never classifies an incident on its own.** Criteria, submissions and the timeline are append-only: nothing can be changed or deleted afterwards. Closed cases are kept for three years.

`:id` is the case's `publicId` or its numeric `id`. `:incidentId` is the numeric ID of an IM incident.

## Authentication

Session only (dashboard login); API tokens are rejected with `403` (`nis2OfficerRequired`). NIS-2 report cases must be switched on ([NIS-2 Settings](/api/incident-management/nis2-settings)). Organization admins and the listed reporting officers can read and write; everyone else gets `403`. A case of another organization answers `404` (`reportCaseNotFound`), the same as a missing one.

## List report cases

`GET /api/im/report-cases`

| Query | Description |
|-------|-------------|
| `status` | Comma-separated status filter, e.g. `reportable,early_warning_submitted`. |
| `page` | Default `1`. |
| `perPage` | Default `50`, at most `200`. |

Sorted by the next open deadline; cases without one come last, newest first.

```bash
curl "$BASE_URL/api/im/report-cases?status=reportable&perPage=20" -H "Cookie: $SESSION_COOKIE"
```

`200 OK`

```json
{
  "items": [
    {
      "id": 12,
      "publicId": "0b6f9a52-7a43-4c8e-9f1e-5c2d8a1b3e77",
      "title": "Outage customer portal",
      "status": "reportable",
      "awareAt": "2026-10-21T07:00:00.000Z",
      "createdAt": "2026-10-21T07:05:00.000Z",
      "nextDeadline": { "stage": "early_warning", "dueAt": "2026-10-22T07:00:00.000Z", "met": false },
      "incidentCount": 1
    }
  ],
  "total": 1,
  "page": 1,
  "perPage": 20
}
```

Status values: `assessment`, `not_reportable`, `reportable`, `early_warning_submitted`, `notification_submitted`, `final_submitted`, `closed`.

## Open a report case

`POST /api/im/report-cases`

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `title` | string | Yes | 1 to 200 characters. |
| `incidentIds` | integer[] | No | IM incidents of your organization, at most 50, no test incidents. |

`awareAt` starts at the time of the request; correct it with `aware-at`. Any other key is a `400`.

```bash
curl -X POST "$BASE_URL/api/im/report-cases" -H "Cookie: $SESSION_COOKIE" -H "Content-Type: application/json" \
  -d '{ "title": "Outage customer portal", "incidentIds": [4812] }'
```

`201 Created`: the full case, same shape as [Get a report case](#get-a-report-case).

## Get a report case

`GET /api/im/report-cases/:id`

`200 OK`

```json
{
  "id": 12,
  "publicId": "0b6f9a52-7a43-4c8e-9f1e-5c2d8a1b3e77",
  "organizationId": 2,
  "title": "Outage customer portal",
  "status": "reportable",
  "awareAt": "2026-10-21T07:00:00.000Z",
  "decision": "reportable",
  "decidedBy": "u_8f2c",
  "decidedAt": "2026-10-21T08:10:00.000Z",
  "decisionReason": null,
  "suspectedMalicious": true,
  "crossBorder": false,
  "severityAssessment": "high",
  "impact": "Customer portal unreachable",
  "affectedUsers": null,
  "iocs": "203.0.113.7",
  "rootCause": null,
  "mitigation": null,
  "kritisFacility": null,
  "criticalService": null,
  "interimRequestedAt": null,
  "interimDueAt": null,
  "createdBy": "u_8f2c",
  "createdAt": "2026-10-21T07:05:00.000Z",
  "closedAt": null,
  "canDecide": true,
  "nextDeadline": { "stage": "early_warning", "dueAt": "2026-10-22T07:00:00.000Z", "met": false },
  "deadlines": [
    { "stage": "early_warning", "dueAt": "2026-10-22T07:00:00.000Z", "met": false },
    { "stage": "notification", "dueAt": "2026-10-24T07:00:00.000Z", "met": false }
  ],
  "incidents": [
    { "id": 4812, "title": "Portal down", "severity": "sev1", "status": "resolved", "triggeredAt": "2026-10-21T06:40:00.000Z", "resolvedAt": "2026-10-21T07:27:00.000Z" }
  ],
  "criteria": [
    { "key": "total_outage_over_30min", "suggestible": true, "suggestedValue": true, "suggestionSource": "monitoring:47min", "decision": "confirmed", "value": true, "decidedBy": "u_8f2c", "decidedAt": "2026-10-21T08:05:00.000Z" }
  ],
  "submissions": [],
  "events": [
    { "id": 301, "type": "decided", "actorUserId": "u_8f2c", "payload": { "decision": "reportable", "reason": null, "previous": null }, "createdAt": "2026-10-21T08:10:00.000Z" }
  ]
}
```

`criteria` always lists all ten criteria (shortened here): `total_outage_over_30min`, `degraded_over_1h_5pct_users`, `financial_loss_500k_or_5pct`, `trade_secret_exfiltration`, `death_or_health_damage`, `malicious_unauthorized_access`, `iva_compromised_malicious`, `iva_compromised_5pct_users`, `third_party_damage`, `planned_maintenance`. Only the first and the last get a suggestion from monitoring data; a suggestion is never a decision. `deadlines` stays empty until the case is decided `reportable`. `events` is the append-only timeline, newest first.

## Update content fields

`PATCH /api/im/report-cases/:id`

Merge patch over the content fields: `suspectedMalicious`, `crossBorder` (boolean or `null`), `severityAssessment`, `impact`, `affectedUsers`, `iocs`, `rootCause`, `mitigation`, `kritisFacility`, `criticalService` (string up to 10000 characters, or `null`). Fields you leave out stay unchanged, `null` clears, empty text becomes `null`. Any other key is a `400`. `200 OK`: the full case.

## Actions on a case

Each answers `200 OK` with the full case. Any other key in the body is a `400`.

| Action | Body | Rule |
|--------|------|------|
| `POST /api/im/report-cases/:id/aware-at` | `awareAt` (ISO time, required), `reason` (string or `null`) | Earlier is free; later needs a `reason` (`reportCaseAwareAtReasonRequired`). |
| `POST /api/im/report-cases/:id/criteria` | `key` (one of the ten), `decision` (`confirmed` \| `rejected`), `value` (boolean), all required | Adds a new decision; the newest one counts. |
| `POST /api/im/report-cases/:id/decision` | `decision` (`reportable` \| `not_reportable`, required), `reason` | Only once every criterion is decided (`reportCaseCriteriaOpen`). `not_reportable` needs a `reason` and is impossible after a submission. |
| `POST /api/im/report-cases/:id/interim-request` | `requestedAt`, `dueAt` (ISO times, required) | The BSI asked for an intermediate report; `dueAt` must be after `requestedAt`. |
| `POST /api/im/report-cases/:id/close` | none | Only after the final report or when `not_reportable` (`reportCaseCannotClose`). A closed case accepts no more changes. |

```bash
curl -X POST "$BASE_URL/api/im/report-cases/0b6f9a52-7a43-4c8e-9f1e-5c2d8a1b3e77/decision" \
  -H "Cookie: $SESSION_COOKIE" -H "Content-Type: application/json" -d '{ "decision": "reportable" }'
```

## Link and unlink incidents

`POST /api/im/report-cases/:id/incidents` with `{ "incidentId": 4813 }` answers `201 Created`:

```json
{ "reportCaseId": 12, "reportCasePublicId": "0b6f9a52-7a43-4c8e-9f1e-5c2d8a1b3e77", "incidentId": 4813, "linkedAt": "2026-10-21T09:00:00.000Z" }
```

`DELETE /api/im/report-cases/:id/incidents/:incidentId` answers `204 No Content`. A second DELETE answers `404` (`reportCaseIncidentNotLinked`). Linking and unlinking refresh the monitoring suggestions.

## Report draft

`GET /api/im/report-cases/:id/draft?stage=early_warning&locale=en`

`stage` (required): `early_warning`, `notification`, `interim`, `progress` or `final`. `locale`: `de` (default) or `en`. Missing required details appear as `[missing]` / `[fehlt]`.

`200 OK`

```json
{
  "text": "# Early warning (≤ 24 h)\n…",
  "draftToken": "v1.1761058800000.en.Jt3…"
}
```

`text` is Markdown, ready to copy into the BSI portal. `draftToken` is the server's signature over exactly this text, this case, this stage and this language. Treat it as opaque, keep it together with the text and send both back when you record the submission. It is valid for 90 days.

## Record a submission

`POST /api/im/report-cases/:id/submissions`

The flow is: copy the draft (`text` and `draftToken`), submit `text` in the BSI portal, then record the submission here with the same `text` and `draftToken`. The server freezes exactly that text. Because the token proves the server wrote it, the frozen text is both what you actually submitted and nothing you could have made up. Editing the case afterwards does not change the evidence: a later draft is a new text with a new token.

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `stage` | string | Yes | `early_warning`, `notification`, `interim`, `progress` or `final`. Must be the stage of the draft. |
| `submittedAt` | string (ISO time) | Yes | When you submitted in the BSI portal; not in the future. |
| `reference` | string | Yes | Reference number from the BSI portal, 1 to 200 characters. |
| `text` | string | Yes | The draft text exactly as returned by `GET .../draft`, up to 100000 characters. Not a single character may differ. |
| `draftToken` | string | Yes | The `draftToken` returned together with that text. The language comes from the token. |

If text and token do not belong together (another text, case or stage, or the token is older than 90 days) the answer is `400` (`reportCaseDraftInvalid`): fetch the draft again and submit that. Only for cases decided `reportable`. Stages follow the order of the law: `early_warning` once, then `notification` (which also counts as the early warning if none was recorded), then any number of `interim` and `progress`, then `final`. Any other order answers `409` (`reportCaseInvalidTransition`). Any other key is a `400`.

```bash
curl -X POST "$BASE_URL/api/im/report-cases/0b6f9a52-7a43-4c8e-9f1e-5c2d8a1b3e77/submissions" \
  -H "Cookie: $SESSION_COOKIE" -H "Content-Type: application/json" \
  -d '{ "stage": "early_warning", "submittedAt": "2026-10-21T15:00:00Z", "reference": "BSI-2026-4711", "text": "# Early warning (≤ 24 h)\n…", "draftToken": "v1.1761058800000.en.Jt3…" }'
```

`201 Created`

```json
{ "id": 41, "stage": "early_warning", "submittedAt": "2026-10-21T15:00:00.000Z", "submittedBy": "u_8f2c", "reference": "BSI-2026-4711", "frozenText": "# Early warning (≤ 24 h)\n…", "createdAt": "2026-10-21T15:02:00.000Z" }
```

## Common errors

- `401 Unauthorized` (`unauthorized`) when not signed in
- `403 Forbidden` (`imAccessDenied`) for a customer login or a role without Incident Management access
- `403 Forbidden` (`nis2NotEnabled`) when report cases are switched off
- `403 Forbidden` (`nis2OfficerRequired`) for an API token, or a user who is neither admin nor reporting officer
- `400 Bad Request` (`invalidIdentifier`) when `:id` is neither a number nor a UUID
- `404 Not Found` (`reportCaseNotFound`) when the case does not exist or belongs to another organization
- `400 Bad Request` (`invalidRequestBody`) for a missing field, a wrong type, an unknown key, an empty `reference`, an invalid query value, or `dueAt` not after `requestedAt`
- `404 Not Found` (`imIncidentNotFound`) when an incident does not exist or belongs to another organization
- `400 Bad Request` (`reportCaseTestIncident`), (`reportCaseReasonRequired`), (`reportCaseAwareAtReasonRequired`), (`reportCaseSubmittedInFuture`), (`reportCaseDraftInvalid`)
- `409 Conflict` (`reportCaseClosed`), (`reportCaseCriteriaOpen`), (`reportCaseAlreadySubmitted`), (`reportCaseNotReportable`), (`reportCaseCannotClose`), (`reportCaseIncidentAlreadyLinked`), (`reportCaseInvalidTransition`)
- `404 Not Found` (`reportCaseIncidentNotLinked`) when unlinking an incident that is not linked
