Uptimeify Docs
Incident management

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

QueryDescription
statusComma-separated status filter, e.g. reportable,early_warning_submitted.
pageDefault 1.
perPageDefault 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

FieldTypeRequiredDescription
titlestringYes1 to 200 characters.
incidentIdsinteger[]NoIM 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.

ActionBodyRule
POST /api/im/report-cases/:id/aware-atawareAt (ISO time, required), reason (string or null)Earlier is free; later needs a reason (reportCaseAwareAtReasonRequired).
POST /api/im/report-cases/:id/criteriakey (one of the ten), decision (confirmed | rejected), value (boolean), all requiredAdds a new decision; the newest one counts.
POST /api/im/report-cases/:id/decisiondecision (reportable | not_reportable, required), reasonOnly once every criterion is decided (reportCaseCriteriaOpen). not_reportable needs a reason and is impossible after a submission.
POST /api/im/report-cases/:id/interim-requestrequestedAt, dueAt (ISO times, required)The BSI asked for an intermediate report; dueAt must be after requestedAt.
POST /api/im/report-cases/:id/closenoneOnly 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" }'

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.

FieldTypeRequiredDescription
stagestringYesearly_warning, notification, interim, progress or final. Must be the stage of the draft.
submittedAtstring (ISO time)YesWhen you submitted in the BSI portal; not in the future.
referencestringYesReference number from the BSI portal, 1 to 200 characters.
textstringYesThe draft text exactly as returned by GET .../draft, up to 100000 characters. Not a single character may differ.
draftTokenstringYesThe 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 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

On this page