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.
Ownership, customers and quotas
Every case, watchlist, conversation and saved search belongs to the analyst who created it — not to their organisation. There is no sharing model: nothing here becomes visible to a colleague, however you are both billed, and no endpoint lets you list or read another analyst’s casework. Your organisation is recorded against every one of your records for billing and account-lifecycle purposes only — it is never checked when deciding what you can read or write, and it is not returned in any response.
That produces one genuinely surprising interaction: quota is counted per customer, but the rows stay private. The moment you create a case, watchlist, conversation or saved search, it counts against your organisation’s quota — even though none of your colleagues can see it, list it, or delete it to free up room for you. If your organisation is at its quota, creating a new one fails with 403 Forbidden until something is deleted, by whoever owns it. See Quotas below for the current per-tier values.
Account and subscription lifecycle
-
Deleting your account deletes your casework. Your own cases, watchlists, conversations, saved searches and alert read-state are removed, along with your conversation history in the message index. Nobody else’s casework on the same customer is touched.
Your organisation’s quota is then corrected to the true remaining count — with one exception. If the deletion leaves your organisation holding no cases, watchlists, conversations or saved searches at all, the correction is deliberately skipped: from inside this service that state cannot be told apart from the teardown that follows deleting a whole customer, where correcting it would recreate counters that have just been removed. An organisation left in that state may see quota still counted against records that no longer exist. Creating one record and deleting it restores an accurate count; contact support if it does not.
There is no hand-over. Because casework is private to its author, once an account is gone nobody — not even an owner of the same organisation — can reach its cases through this API. If a departing analyst’s investigations need to survive them, arrange the hand-over before the account is deleted, not after.
-
Deleting a customer removes every casework row and conversation belonging to it, across every analyst who ever created one.
-
A subscription downgrade or cancellation deletes nothing. If it leaves your organisation over quota, you cannot create new cases, watchlists, conversations or saved searches until enough are deleted to get back under it. Casework is a record, not a resource that gets reclaimed automatically — losing an investigation because a subscription lapsed is not an acceptable outcome.
See Account Deletion Cascade for how this fits into cross-service account deletion.
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.
Canvas
The arrangement of result panels an analyst has open for a conversation. It is stored server-side so a workspace follows you between devices, and so the indicators you looked up do not sit in your browser: panel titles and refs hold entity values and search terms.
GET /conversations/{id}/canvas
{
"panels": [
{
"id": "p_a1b2c3",
"kind": "entity",
"title": "8.8.8.8",
"ref": "ip-3f2a…",
"z": 2,
"x": 120,
"y": 64,
"width": 520,
"height": 400,
"minimised": false
}
]
}
A conversation with no canvas returns 200 with an empty panels array, not a 404 — every conversation starts without one.
PUT /conversations/{id}/canvas replaces the arrangement wholesale and returns 204. There is no partial update: the client always knows the full set, and a patch has no meaning for window positions.
| Field | Type | Description |
|---|---|---|
| id | string | Client-generated panel id, unique within the canvas. |
| kind | string | One of entity, graph, geo, search. |
| ref | string | What the panel renders — an entity id, or a query for search. |
| title | string | Display label. |
| z | integer | Stacking order. |
| x, y, width, height | number | Geometry in CSS pixels. |
| minimised | boolean | Optional; omitted when false. |
Limits. At most 24 panels, at most 64KB serialised, and every kind must be one of the four above. Exceeding any of these returns 400 naming the limit — the canvas is a convenience, not a general-purpose store.
The canvas has no quota of its own: it belongs to a conversation, which is already counted. Deleting the conversation deletes it.
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": "a1b2c3d4e5f6...",
"@timestamp": "2026-07-28T01:26:03Z",
"watchlistID": "…",
"itemID": "…",
"entityID": "domain-6f1c…",
"entityType": "domain",
"entityValue": "alert-check.example",
"reputation": -3,
"matchedKind": "tag",
"matchedOn": "ui-alert-check"
}
],
"unread": 1
}
id is a string, not an integer: the first 16 bytes of a SHA256 over itemID|entityID|bucket, hex-encoded to 32 characters. Deriving it that way makes the same rule firing on the same entity in the same bucket idempotent. Alert fields use camelCase, matching the turn document convention — alerts are stored in OpenSearch with the same serialization as turns.
unread is computed from your read watermark and is independent of items, so it stays correct however the list is filtered or truncated.
matchedKind and matchedOn 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 | /conversations/{id}/canvas | Get a conversation’s panel arrangement |
PUT | /conversations/{id}/canvas | Replace a conversation’s panel arrangement |
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
Casework registers two per-minute keys: casework_read_per_minute on every read endpoint and casework_write_per_minute on every create, update and delete.
Per-tier values are not published here — they change, and a number written into a page goes stale silently. Read yours live:
curl -X GET \
'https://apis.threatwinds.com/api/billing/v1/limits/casework-api' \
-H 'accept: application/json' \
-H 'Authorization: Bearer <YOUR_BEARER_TOKEN>'
There is no Public tier — casework requires authentication.
Quotas
A rate limit caps how often you call the API; a quota caps how much casework you keep. Cases, watchlists, conversations and saved searches are each capped per customer, independently of who created them — see Ownership, customers and quotas above.
Each of the four quota keys — max_cases, max_watchlists, max_conversations, max_saved_searches — is set to three times that tier’s casework_write_per_minute rate limit, and is derived from it in code rather than typed separately, so the two cannot drift out of sync. All four keys always carry the same value as each other.
There is no Public tier — casework requires authentication.
Query Quotas for the live value and your organisation’s current usage — the numbers are not published here, since a page cannot stay in step with a tier table that moves. Creating a case, watchlist, conversation or saved search while your organisation is at its quota fails with 403 Forbidden. Deleting one frees a slot immediately.
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 |
| 403 | Forbidden | Your organisation is at its quota for this resource type — see Quotas |
| 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 |