Report Cases (NIS-2)
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). 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.
curl "$BASE_URL/api/im/report-cases?status=reportable&perPage=20" -H "Cookie: $SESSION_COOKIE"200 OK
{
"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.
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 /api/im/report-cases/:id
200 OK
{
"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. |
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:
{ "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
{
"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.
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
{ "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 in403 Forbidden(imAccessDenied) for a customer login or a role without Incident Management access403 Forbidden(nis2NotEnabled) when report cases are switched off403 Forbidden(nis2OfficerRequired) for an API token, or a user who is neither admin nor reporting officer400 Bad Request(invalidIdentifier) when:idis neither a number nor a UUID404 Not Found(reportCaseNotFound) when the case does not exist or belongs to another organization400 Bad Request(invalidRequestBody) for a missing field, a wrong type, an unknown key, an emptyreference, an invalid query value, ordueAtnot afterrequestedAt404 Not Found(imIncidentNotFound) when an incident does not exist or belongs to another organization400 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