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 /conversations → 201
| 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}/turns → 201
| 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/read → 204
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:
- its key matches the entity — the entity id, the indicator value, one of its tags, or its type
- the entity’s reputation is at or below the rule’s
max_reputation, when one is set - 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 |