Casework API

Analyst-owned records: investigations, conversations, watchlists and saved searches.

Every other ThreatWinds API reads from a shared corpus. This one is different — the records belong to you. A case can hold an unpublished hypothesis about a live incident, so there is no sharing model and no anonymous access: every route requires authentication, and every read and write is scoped to the authenticated account.

Base URL: https://apis.threatwinds.com/api/casework/v1

Authentication

Unlike search and analytics, authentication is required — an unauthenticated caller has no casework to return.

Header Type Required Description
Authorization string Yes* Bearer token from an active session.
api-key string Yes* API key.
api-secret string Yes* API secret.

* Either a bearer token, or an api-key/api-secret pair.

A request for a record belonging to another account returns 404, not 403 — a 403 would confirm that the record exists.


Cases

A case is one investigation, with notes and pinned entities.

Field Type Description
id string UUID.
title string Required on create. Truncated at 200 bytes — a multi-byte title is cut by byte length, not character count.
summary string Truncated at 4000 bytes.
status string open, in_progress or closed. Defaults to open.
severity int 0 info, 1 low, 2 medium, 3 high, 4 critical. Defaults to 0.
notes array Only present on the single-case endpoint.
entities array Only present on the single-case endpoint.

List cases

GET /cases

Parameter Type Required Default Description
status string No Filter by open, in_progress or closed.
limit int No 50 Maximum cases (1–200).
curl -H "Authorization: Bearer $TOKEN" \
  "https://apis.threatwinds.com/api/casework/v1/cases?status=open"

Notes and entities are omitted from the list response; fetch a single case for those.

Get a case

GET /cases/{id} — returns the case with its notes and pinned entities.

{
  "id": "be518ce5-641c-4d06-8440-fa9ffd415e54",
  "title": "Phishing campaign against finance",
  "summary": "",
  "status": "open",
  "severity": 3,
  "created_at": "2026-07-27T21:24:26Z",
  "updated_at": "2026-07-27T21:24:26Z",
  "notes": [
    { "id": "…", "case_id": "…", "body": "Observed beaconing.", "created_at": "…" }
  ],
  "entities": [
    {
      "id": "…", "case_id": "…",
      "entity_id": "ip-af1cff82d0d12d9b…",
      "entity_type": "ip", "entity_value": "198.51.100.77",
      "note": "", "created_at": "…"
    }
  ]
}

Create, update, delete

POST /cases · PATCH /cases/{id} · DELETE /cases/{id}

curl -X POST -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"title":"Phishing campaign against finance","severity":3}' \
  "https://apis.threatwinds.com/api/casework/v1/cases"

PATCH is partial — omitted fields are left alone. Deleting a case also removes its notes and pinned entities. Its conversations are detached, not deleted — see Conversations.

Notes and entities

POST /cases/{id}/notes — body {"body":"…"}, truncated at 20000 characters.

POST /cases/{id}/entities — pins an entity:

{
  "entity_id": "ip-af1cff82d0d12d9b…",
  "entity_type": "ip",
  "entity_value": "198.51.100.77",
  "note": "optional"
}

Type and value are stored alongside the id so the case still reads sensibly if the entity later becomes unresolvable. Pinning the same entity twice is a no-op, not an error. A case holds at most 200 pinned entities.

DELETE /cases/{id}/entities/{entityId} unpins.


Conversations

A conversation is one line of inquiry with the analyst agent, kept server-side so that it outlives the browser tab it started in.

Only the metadata is a casework record. The turns — the questions, the answers, the tools the agent called along the way — are documents in the message index, which is why they are read through their own endpoints and come back in a different case convention: the conversation row is snake_case, its turns are camelCase. That is a storage boundary showing through, not an inconsistency to work around.

A case owns conversations, but owning one is only a foreign key. A conversation with no case_id is unfiled — a thread nobody has attached to an investigation yet, not a lesser kind of record. Every conversation is durable either way, and filing one later moves no data.

Field Type Description
id string UUID.
title string Required on create. Truncated at 200 bytes — a multi-byte title is cut by byte length, not character count.
case_id string The case it is filed in. Absent from the response when the conversation is unfiled.
message_count int Turns recorded so far — and the seq the next turn will be given.
last_message_at string When a turn was last appended. Set to the creation time on a new conversation.
created_at string  
updated_at string  

List conversations

GET /conversations — most recently active first.

Parameter Type Required Default Description
case_id string No A case UUID, or unfiled for threads attached to no case. Omit for every conversation.
limit int No 50 Maximum conversations (1–200).
curl -H "Authorization: Bearer $TOKEN" \
  "https://apis.threatwinds.com/api/casework/v1/conversations?case_id=unfiled&limit=20"
[
  {
    "id": "3f2b8c1e-9d47-4a2f-b0d5-6c1e7a3f9b21",
    "title": "Initial triage of 198.51.100.77",
    "message_count": 4,
    "last_message_at": "2026-07-28T09:14:02Z",
    "created_at": "2026-07-28T08:51:19Z",
    "updated_at": "2026-07-28T09:14:02Z"
  }
]

The response is a bare array, and case_id is missing from the object above because that conversation is unfiled.

Ordering is by last_message_at, then by creation time — a conversation rises when a turn is appended to it, not when it is renamed or filed. A case_id that is neither a UUID nor the literal unfiled is a 400. A limit outside 1–200 falls back to 50 rather than being clamped to the maximum.

Start a conversation

POST /conversations201

Field Type Required Description
title string Yes Truncated at 200 bytes — a multi-byte title is cut by byte length, not character count. Blank or whitespace-only is a 400.
case_id string No Files the conversation into a case as it is created. Omit to start unfiled.
curl -X POST -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"title":"Initial triage of 198.51.100.77"}' \
  "https://apis.threatwinds.com/api/casework/v1/conversations"
{
  "id": "3f2b8c1e-9d47-4a2f-b0d5-6c1e7a3f9b21",
  "title": "Initial triage of 198.51.100.77",
  "message_count": 0,
  "last_message_at": "2026-07-28T08:51:19Z",
  "created_at": "2026-07-28T08:51:19Z",
  "updated_at": "2026-07-28T08:51:19Z"
}

An account holds at most 500 conversations; beyond that, creating one is a 400. Starting a conversation inside a case you do not own returns 404.

Get a conversation

GET /conversations/{id} — returns the conversation row only. Its messages are read from the turns endpoint.

Rename or file a conversation

PATCH /conversations/{id}

Partial, and between them the two fields express four intents:

Body Effect
{"title":"Beaconing from finance subnet"} Renames it. Filing untouched.
{"case_id":"be518ce5-641c-4d06-8440-fa9ffd415e54"} Files it into that case. Title untouched.
{"case_id":"00000000-0000-0000-0000-000000000000"} Unfiles it.
{} Nothing changes; the current record is returned.
curl -X PATCH -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"case_id":"be518ce5-641c-4d06-8440-fa9ffd415e54"}' \
  "https://apis.threatwinds.com/api/casework/v1/conversations/$CONVERSATION_ID"
{
  "id": "3f2b8c1e-9d47-4a2f-b0d5-6c1e7a3f9b21",
  "title": "Initial triage of 198.51.100.77",
  "case_id": "be518ce5-641c-4d06-8440-fa9ffd415e54",
  "message_count": 4,
  "last_message_at": "2026-07-28T09:14:02Z",
  "created_at": "2026-07-28T08:51:19Z",
  "updated_at": "2026-07-28T09:19:37Z"
}

Unfiling is the nil UUID, 00000000-0000-0000-0000-000000000000. An omitted case_id and a JSON null are indistinguishable once decoded, so absence has to mean “leave it alone” and a sentinel has to mean “clear it”. A blank title is read the same way — as not supplied — so a title can be replaced but never erased.

Filing moves no data: the turns already recorded stay exactly where they are, and the conversation simply starts appearing under the case. Filing into a case belonging to another account returns 404.

Delete a conversation

DELETE /conversations/{id}204

Removes the conversation and its turns. The messages are deleted first — a row deleted with its documents left behind is an orphan nothing will ever clean up, whereas the reverse is retryable.

Deleting the case a conversation is filed in does not delete the conversation. The case’s notes and pinned entities cascade away with it; its conversations are detached, becoming unfiled with their turns intact. The reasoning outlives the folder it was filed in.

Turns

A turn is one question and the answer it produced, together with any tools the agent called on the way. Turn documents are camelCase.

Field Type Description
id string {conversationID}-{seq}, the sequence zero-padded to six digits. Derived, not random — see below.
conversationID string The conversation this turn belongs to.
caseID string The case the conversation was filed in when the turn was written. Absent if it was unfiled then.
userID string The owner.
@timestamp string When the turn was recorded.
question string Required. Truncated at 20000 bytes.
answer string Stored as given.
tools array {"name":"…","args":"…"} per call; args is a string, normally JSON-encoded. Omitted when the turn called none.
seq int Ordinal within the conversation, starting at 0. The sort key.

Read a conversation’s history

GET /conversations/{id}/turns

Parameter Type Required Default Description
limit int No 200 Maximum turns (1–200).
curl -H "Authorization: Bearer $TOKEN" \
  "https://apis.threatwinds.com/api/casework/v1/conversations/$CONVERSATION_ID/turns?limit=50"
[
  {
    "id": "3f2b8c1e-9d47-4a2f-b0d5-6c1e7a3f9b21-000000",
    "conversationID": "3f2b8c1e-9d47-4a2f-b0d5-6c1e7a3f9b21",
    "caseID": "be518ce5-641c-4d06-8440-fa9ffd415e54",
    "userID": "0f4c2a77-1b58-4d9a-9f0e-2a6d5b8c3e41",
    "@timestamp": "2026-07-28T08:51:44.912Z",
    "question": "What do we know about 198.51.100.77?",
    "answer": "Three feeds reported it in the last 24 hours, all tagging it as a scanner.",
    "tools": [
      { "name": "lookup_indicator", "args": "{\"value\":\"198.51.100.77\"}" }
    ],
    "seq": 0
  }
]

Turns come back ordered by seq ascending, not by @timestamp. seq is the conversation’s own counter, so two turns written inside the same millisecond still sort in the order they happened — do not re-sort by time on the client.

A conversation with nothing written yet returns [], not a 404. A conversation returns at most 200 turns and there is no pagination — From(0) is fixed server-side. A conversation may run longer than that; you simply cannot read past the first 200 turns through this endpoint.

Record a turn

POST /conversations/{id}/turns201

Field Type Required Description
question string Yes Truncated at 20000 bytes. Blank or whitespace-only is a 400.
answer string No Stored as given.
tools array No {"name":"…","args":"…"} per tool the agent called.
curl -X POST -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{
        "question": "What do we know about 198.51.100.77?",
        "answer": "Three feeds reported it in the last 24 hours, all tagging it as a scanner.",
        "tools": [{"name":"lookup_indicator","args":"{\"value\":\"198.51.100.77\"}"}]
      }' \
  "https://apis.threatwinds.com/api/casework/v1/conversations/$CONVERSATION_ID/turns"

The turn’s seq is taken from the conversation’s message_count, and its caseID is copied from the conversation as it stands at that moment — so refiling a conversation later does not rewrite the turns already recorded under the old case.

Writes are append-only, and the document id is derived from the conversation and the sequence rather than generated, so a retry overwrites rather than duplicating. A reconnect after a failed response cannot leave the same exchange in the thread twice. The corollary is that turns must be appended one at a time: two writes issued concurrently against the same conversation read the same message_count, take the same seq, and one lands on top of the other.

Two further behaviours worth designing around. The id is empty in the 201 response — it is stamped as the document is written, and appears when the turn is read back; it is also fully derivable from the conversation id and seq. And the message index refreshes asynchronously, so a turn is not guaranteed to appear in a GET .../turns issued immediately afterwards. Render the turn you posted rather than re-reading to confirm it.

Search your history

GET /conversations/search

Parameter Type Required Default Description
q string No Free text, matched against questions and answers.
limit int No 25 Maximum turns (1–50).
curl -H "Authorization: Bearer $TOKEN" \
  "https://apis.threatwinds.com/api/casework/v1/conversations/search?q=198.51.100.77&limit=10"
[
  {
    "id": "3f2b8c1e-9d47-4a2f-b0d5-6c1e7a3f9b21-000000",
    "conversationID": "3f2b8c1e-9d47-4a2f-b0d5-6c1e7a3f9b21",
    "caseID": "be518ce5-641c-4d06-8440-fa9ffd415e54",
    "userID": "0f4c2a77-1b58-4d9a-9f0e-2a6d5b8c3e41",
    "@timestamp": "2026-07-28T08:51:44.912Z",
    "question": "What do we know about 198.51.100.77?",
    "answer": "Three feeds reported it in the last 24 hours, all tagging it as a scanner.",
    "seq": 0
  }
]

Search covers only your own turns, across every conversation you own, filed or unfiled. It is the “which investigation did I see this hash in?” query — the thing that is not answerable once a transcript lives in a browser tab.

Results are ordered newest first by @timestamp, not by seq: hits come from different conversations, so a per-conversation ordinal would not order them. The response is a flat list of turns rather than of conversations — follow each hit’s conversationID back into the thread it came from.

A blank or missing q returns [] rather than an error. search is matched ahead of /conversations/{id}, so a conversation id can never shadow this route.


Watchlists

A watchlist is a named set of match rules.

GET /watchlists · POST /watchlists · GET|PATCH|DELETE /watchlists/{id} POST /watchlists/{id}/items · DELETE /watchlists/{id}/items/{itemId}

Rule field Type Description
kind string entity (one entity id), value (an exact indicator), tag, or type.
value string What to match. Truncated at 512 bytes.
max_reputation int Optional. Only match entities at or below this reputation. Omit for any.
curl -X POST -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"kind":"value","value":"198.51.100.77","max_reputation":-1}' \
  "https://apis.threatwinds.com/api/casework/v1/watchlists/$LIST_ID/items"

kind is validated against the list above — an unrecognised value is rejected. A watchlist holds at most 200 rules.


Alerts

Alerts are written by the alerting worker when a watchlist rule matches an entity as it is ingested. Clients only ever read them.

The worker runs server-side and continuously, so alerts accumulate while nobody is signed in — that is the difference between this and the live feed, which only delivers to a connected browser.

List alerts

GET /alerts

Parameter Type Required Default Description
limit int No 50 Maximum alerts (1–200).
unread bool No false Return only alerts newer than your read watermark.
{
  "items": [
    {
      "id": 41,
      "created_at": "2026-07-28T01:26:03Z",
      "watchlist_id": "…",
      "item_id": "…",
      "entity_id": "domain-6f1c…",
      "entity_type": "domain",
      "entity_value": "alert-check.example",
      "reputation": -3,
      "matched_kind": "tag",
      "matched_on": "ui-alert-check"
    }
  ],
  "unread": 1
}

unread is computed from your read watermark and is independent of items, so it stays correct however the list is filtered or truncated.

matched_kind and matched_on record why the alert fired, so a blanket type rule is distinguishable at a glance from an exact-value hit.

Mark all read

POST /alerts/read204

Advances your read watermark to now. Alerts are not deleted — they remain listable, they simply stop counting as unread.

What generates an alert

A rule fires when all of the following hold:

  1. its key matches the entity — the entity id, the indicator value, one of its tags, or its type
  2. the entity’s reputation is at or below the rule’s max_reputation, when one is set
  3. your security groups intersect the entity’s — you are never alerted about an entity you could not otherwise read

Matching is case-insensitive, and a rule fires at most once per entity per hour however many times that entity is re-reported in the window. An indicator seen by six feeds in an hour is one alert, not six.

Alerts are only generated for entities ingested after the rule was created. Creating a rule does not retroactively search the corpus — use search for that.


Saved searches

GET /searches · POST /searches · DELETE /searches/{id}

curl -X POST -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"name":"Malicious IPs","query":"reputation:<0 AND type:ip"}' \
  "https://apis.threatwinds.com/api/casework/v1/searches"

Route Summary

Every route is relative to https://apis.threatwinds.com/api/casework/v1 and requires authentication.

Method Path Description
GET /cases List the analyst’s cases
POST /cases Open a case
GET /cases/{id} Get a case with its notes and pinned entities
PATCH /cases/{id} Update a case
DELETE /cases/{id} Delete a case (detaches its conversations)
POST /cases/{id}/notes Add a note to a case
POST /cases/{id}/entities Pin an entity to a case
DELETE /cases/{id}/entities/{entityId} Unpin an entity from a case
GET /conversations List conversations, most recently active first
POST /conversations Start a conversation
GET /conversations/search Search across your own conversation history
GET /conversations/{id} Get a conversation
PATCH /conversations/{id} Rename or file a conversation
DELETE /conversations/{id} Delete a conversation and its messages
GET /conversations/{id}/turns Read a conversation’s turns, ordered by seq
POST /conversations/{id}/turns Record a question and its answer
GET /watchlists List watchlists
POST /watchlists Create a watchlist
GET /watchlists/{id} Get a watchlist
PATCH /watchlists/{id} Update a watchlist
DELETE /watchlists/{id} Delete a watchlist
POST /watchlists/{id}/items Add a rule to a watchlist
DELETE /watchlists/{id}/items/{itemId} Remove a rule from a watchlist
GET /alerts List alerts with the unread count
POST /alerts/read Mark all alerts read
GET /searches List saved searches
POST /searches Save a search
DELETE /searches/{id} Delete a saved search

A note on limit

An out-of-range limit falls back to the default, it does not clamp to the maximum. limit=1000 on /conversations returns 50, not 200; limit=100 on /conversations/search returns 25, not 50. Ask for a value inside the stated range.

Rate Limits

Tier Reads / min Writes / min
Free 120 30
Pro 300 90
Business 600 180
Business Max 1200 360
Enterprise 1200 360
Enterprise Max 2400 720

There is no Public tier — casework requires authentication.

Error Codes

Status Description Possible cause
400 Bad Request Missing title/name/value/question, unknown status or match kind, malformed UUID, per-account limit reached
401 Unauthorized Missing or invalid credentials
404 Not Found No such record, or it belongs to another account — including the case a conversation is being filed into
429 Too Many Requests Rate limit exceeded
500 Internal Server Error Server-side error