Investigation REST API and MCP
Connect your own programs and agents to Investigation without a meeting: create a key, sign in, then follow the REST quickstart or the MCP setup.
Overview
This reference covers every response profile this service can return. GET /api/v1/sources reports this server's profile (profileVersion) and the only source IDs it accepts.
Use OpenAPI 3.1 reference for the exact methods and request fields. The six grants are search, records:read, cases:read, cases:write, usage:read and history:read. Keys cannot change history policy, create keys or list agency members.
Privacy and access. Usage lists everyone with access and the invitations not yet accepted, in the Web UI only.
Keys and grants
Open the authenticated Investigation Web shell, choose API & MCP, select only the required grants and create a key. Copy its secret once. Use Authorization: Bearer <key>. Revoke the key in the same panel when finished; the next request is denied.
| Grant | Allows |
|---|---|
search | Price checks, searches, continuation, execution status and the source list |
records:read | Open, search within and download stored records |
cases:read | List and read cases, and export saved findings |
cases:write | Create and update cases, and save and update findings |
usage:read | Read the agency's query allowance and use |
history:read | Read retained search history while the agency keeps it |
Calling this address from a program
Every REST and API-key MCP request needs only your personal Investigation key, sent as Authorization: Bearer <key>. Every use of the key still needs your current agency membership and the key's grants, and the key stops working when it is revoked or your access is withdrawn. Your query allowance is separate from key authentication. Use only your own key: do not use service tokens, management tokens or browser cookies. Keep the key in your client's local secret configuration or a secure store, never in chat, tickets or logs.
REST quickstart
These steps use fictional values. Replace each <placeholder> with your own value, send the bearer header from Calling this address from a program on every request, and send JSON bodies with Content-Type: application/json. Never put a key or token in a URL.
1. Create a key
In Investigation choose API & MCP, select only the grants you need (this walk-through uses
search,records:read,cases:readandcases:write), create the key and copy it once.2. Check the profile and sources
GET /api/v1/sourcesreturns this server'sprofileVersionand the only source IDs it accepts insourceIds. OmitsourceIdsto search all of them.3. Check the price (free)
POST /api/v1/searches/cost {} -> {"queryCost":1,"startAt":"<12 months ago>","endAt":"<now>"}Without dates the quote covers the most recent 12 months. To search an older period, send the same
startAtandendAtyou will search with. The quote does not run, record or charge a search.4. Search
POST /api/v1/searches {"query":"analyst@example.test","caseMode":"exact","maxQueryCost":1}Set
maxQueryCostto the price you checked. A 200 response is one page: itssearchReference,searchState, thetotalandsourceCounts, thefindings, thecoverageSegments, and acontinuationTokenwhen more may follow. A count with relationgteis a lower bound.- 202 with
accountingState: "pending": acceptance is not yet known. CheckGET /api/v1/executions/<searchReference>. It answersaccepted(the search was charged),declined(not charged) or stillpending; it never returns results or reruns the query. Do not resend the search automatically. A 202 means the results were not delivered; it does not by itself say whether the search was charged, so check the status. While the outcome is unknown, the price is held asreservedinGET /api/v1/usage. An accepted search whose results were not delivered cannot return them; a new search is a new charged search. - 409
query_cost_changed: the price at admission was higher thanmaxQueryCost. Nothing was reserved or charged; check the price again and decide. - 413
input_too_large: the query is above 4,096 UTF-8 bytes; it was refused before any charge. - 429: wait for the time in
Retry-After.
Record handles in a result. Each entry in
findingsis a group of one or moremembers; each member is one stored record. Use the member's own handles:- Whole-record handle (the member's
recordHandle): open shows the record's first listed field, search-within covers all of its fields, and download returns a ZIP of the whole stored record. - Field handle (
fields[].recordHandle): open, search-within and download cover that one field. Saving a selection needs a field handle; a whole-record handle is refused. - Choosing:
matchingFieldsnames the fields that matched (for exampletextorhtml). Pick the member, then the entry in itsfieldswith thatfieldId, and use that entry'srecordHandle. Use only a handle that is notnull. - Access gaps: a member with
accessGapretained_inventory_gaporretained_unit_boundis a verified match whose complete stored record is not available. It hasunit: null, no fields and no record handle. The match still counts, and the page'sgapsandreasonsay why it is incomplete.accessGap: "record_expired"marks a listed record whose handle has expired: it keeps itsunitbut has no record handle. A member without a handle cannot be opened, downloaded or saved. Use another member, or decide whether a new search is worth its charge.
- 202 with
5. Get the next page (no charge)
POST /api/v1/searches/continue {"searchReference":"<searchReference>","continuationToken":"<continuationToken>","query":"analyst@example.test","caseMode":"exact"}Re-send the original query and mode with the reference and token. A further page can raise the lower-bound counts. An expired continuation returns 410; a new initial search would be a new charged search, so decide before running one.
After 429 wait
Retry-Afterand send the same continuation again; after 503 you may send the same continuation again; 410 means it can no longer be used. A continuation is never charged.6. Read the stored record (no charge)
POST /api/v1/records/open {"recordHandle":"<fieldRecordHandle>","offset":0,"windowBytes":65536} POST /api/v1/records/search-within {"recordHandle":"<memberRecordHandle>","query":"analyst@example.test","caseMode":"anycase"}Open the field you want to keep with its field handle. Open returns a bounded window of that field (
content,startByte,endByte,originalSizeBytes,hasMore) with its record meaning; sendoffsetto read further. Search-within with the member's whole-record handle covers all fields, and each match names itsfieldId.POST /api/v1/records/downloadwith a whole-record handle returns a ZIP of the stored record; with a field handle it returns that field's bytes. Record handles expire: a listed record that can no longer be opened showsaccessGap: "record_expired". Save a selection to a case to keep its bytes.7. Save a finding to a case (no charge)
POST /api/v1/cases/create {"title":"Example case","visibility":"user_only"} POST /api/v1/cases/findings/save {"caseId":"<caseId>","recordHandle":"<fieldRecordHandle>","startByte":13,"endByte":33,"note":"Why this matters","highlightSpans":[]}A selection is a byte range inside one field. Send the same field handle you opened; a whole-record handle is refused with 404
not_found.startByteandendBytecount UTF-8 bytes of that field, not characters, andendByteis exclusive. Both must fall between characters,endBytecannot exceed the field'soriginalSizeBytes, and one selection is at most 1 MiB. Compute them from the window you opened, for example in JavaScript:// opened = the JSON response of POST /api/v1/records/open for the field handle const wanted = 'analyst@example.test'; // the exact text to keep, from opened.content const at = opened.content.indexOf(wanted); if (at === -1) throw new Error('Not in this window: no selection made'); const bytes = (text) => new TextEncoder().encode(text).length; const startByte = opened.startByte + bytes(opened.content.slice(0, at)); const endByte = startByte + bytes(wanted); // exclusiveHere the opened field's text is
Kontakt für analyst@example.test. The address starts at character 12 but at byte 13, becauseütakes two bytes in UTF-8, and it ends before byte 33. If the text is not in the window, the snippet stops before computing anything and no selection is made. Read another window only when one remains (hasMoreis true): open again withoffsetset to the window'sendByte, or with a largerwindowBytes(up to 262,144), and compute from the new window'sstartByte. A stop means only that the text was not found in the inspected window, not that it is absent from the field: earlier windows were not examined, and text can cross a window boundary. To locate a match before opening or saving, use search-within with the field handle: each match names itsfieldIdwith itsstartByteandendBytein that field, and a result that is not complete continues with itsposition. Then open a window that contains that range and compute as above. The saved finding keeps the selected bytes, your note and their record meaning.visibilityisuser_onlyoragency_shared.8. Export (no charge)
POST /api/v1/cases/export {"caseId":"<caseId>"}The response is a ZIP of the saved selections with a hash manifest. Send
findingIdsto export up to 128 selected findings.
MCP setup
Standard Model Context Protocol clients using API keys send requests to POST /api/v1/mcp with the same bearer key. The supported wire revision is 2025-11-25. The server lists only granted tools. Stored-record download and saved-ZIP tools return five-minute artifact links. Fetch those links with the same key; opening them without authorization does not work.
AI-app (OAuth) connection works only after CIH enables it for this service. API & MCP shows whether it is available.
- Address
https://<this address>/api/v1/mcp- Transport
- Streamable HTTP without sessions: each JSON-RPC message is one
POST; other methods return 405. - Protocol revision
2025-11-25- Header on every request
Authorization: Bearer <key>
Native AI-app connection
- In Investigation → API & MCP, choose the grants and select Allow AI apps.
- In Claude, open Settings → Connectors → Add custom connector and enter
https://investigation.cyberintelligence.house/api/v1/mcp. - Under Advanced settings enter OAuth Client ID
. No secret is needed. - Sign in with your CIH account.
- Disconnect in Investigation stops Claude's next request.
This is one consent link for the AI apps you sign in to with your account, not separate consent per client. Downloads open in Investigation and require your own account, current grants and the original unexpired connector link. AI app connection is not available until CIH configures it.
Connect with the MCP TypeScript SDK
This recipe uses the same client package, transport and options as this service's own MCP checks. It needs Node.js 22 or later. Install the client with npm install @modelcontextprotocol/client@2.1.0, save the file below as connect.mjs, and set INVESTIGATION_KEY (your personal key) from your secret store for this session only. Replace <this address> in the file. Run node connect.mjs.
import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client';
const client = new Client({ name: 'my-investigation-client', version: '1.0.0' },
{ supportedProtocolVersions: ['2025-11-25'] });
const transport = new StreamableHTTPClientTransport(new URL('https://<this address>/api/v1/mcp'), {
requestInit: { headers: {
authorization: 'Bearer ' + process.env.INVESTIGATION_KEY,
} },
});
await client.connect(transport);
const { tools } = await client.listTools();
const price = await client.callTool({ name: 'get_query_cost', arguments: {} });
if (price.isError) throw new Error('get_query_cost was refused');
process.stdout.write(JSON.stringify({ tools: tools.map((tool) => tool.name), price: JSON.parse(price.content[0].text) }, null, 2));
await client.close();connect() performs the MCP initialization over Streamable HTTP; the server keeps no session, so every message is its own authenticated POST. listTools() shows only the tools your key's grants allow. callTool() runs one; this example asks for the free price quote (grant search), whose result is JSON text in content[0].text. A 401 or 403 from Investigation means the key, its grants or your membership.
Other clients (illustrative)
A client that supports a remote HTTP server with custom request headers needs the same address and bearer header. The configuration below is illustrative only: it is not a format every client accepts, and field names differ between clients. Follow your client's own instructions for remote servers, and keep the real values in its local secret configuration:
{"mcpServers":{"cih-investigation":{"url":"https://<this address>/api/v1/mcp","headers":{"Authorization":"Bearer <key>"}}}}Tools by grant
| Grant | Tools |
|---|---|
search | search, get_query_cost, continue_search, execution_status, sources |
records:read | open_record, search_within_record, download_record |
cases:read | list_cases, read_case, export_case |
cases:write | create_case, update_case, save_finding, update_finding |
usage:read | usage |
history:read | history |
The same query rules, price, maxQueryCost behavior, allowance, case visibility and history setting apply as for REST. The same record handles apply too: open_record, search_within_record and download_record take a whole-record or a field handle, and save_finding needs a field handle.
What a search means
Search the text retained by CIH. Exact-case matches the characters you enter, including capitals, spaces, punctuation and accents. Any-case ignores letter case only.
CIH may not hold everything that was originally collected. Content changed or omitted before storage may not be searchable. We show the search coverage and known limits with the results. No matches does not prove absence from the collected source.
Where CIH has verified an exact-search copy, this search checks that copy. Elsewhere it uses the current search method, which may miss strings made only of symbols or appearing inside a longer word. The coverage display distinguishes verified exact, current, mixed and unverified portions of the dates, sources and stored content types you selected. A mixed or unverified portion may contain matches this search did not find. “No matches” does not prove the content was never collected.
The date filter uses the record date shown with each result. A newly added record with an older record date is eligible for search only when your selected dates include it. Where a source's record-date meaning is unverified, we mark that part of the range as unverified.
Exact-search copy; Current search method; Mixed or unverified
For a current-field profile: This search may miss strings made only of symbols or appearing inside a longer word. Text split across separate records may also be missed. For a preserved profile: Text split across separate records may also be missed.
Stored record: The complete record retained by CIH. It may be one part of a larger file. Investigation does not change its content. A verified reconstruction of the source file is not available. We cannot confirm whether CIH holds every part of the collected source.
Source IDs depend on the server profile: use only the IDs GET /api/v1/sources lists. The Main profile (main-wire-v4.0) uses producer-owned source categories; the controlled test profiles use alpha, beta and unknown. On the Main profile every page is incomplete: the total and source counts are lower bounds (at least), a continuation page can raise them, and the coverage segments state which part was searched by which method. Only the text and HTML of a stored record are searched; its other stored context can be opened and downloaded. Supply both UTC date bounds or neither. Re-send the original transient query and mode with an opaque search reference and continuation token. A pending 202 means check status and do not rerun automatically.
One search returns at most 64 records in total across all its pages. It can stop earlier at its size or time limits. reason says why it stopped; when it stops, no further continuation is issued. snapshot_state_bound at 64 records means the cumulative record ceiling; below 64 it means the combined result size reached its limit. snapshot_byte_bound limits how much stored content one page can check; snapshot_time_bound is the time limit. A new initial search is a new charged search (1 query, or 2 for an older period); check get_query_cost first.
Records, cases and export
Cases and retained history return bounded pages with opaque continuation tokens. History returns no queries when the agency setting is off. A case read can return a page of metadata or one saved selection's text. Export uses saved bytes and a hash manifest, even if the search source later disappears. Each finding carries its stored-record meaning or null for older saves. The complete stored-record hash differs from the selected exported-byte hash.
Price, limits and retries
An accepted initial search costs 1 query when the selected dates are within the most recent 12 months and 2 queries when they include older data; there is no other price. Check the price with the free POST /api/v1/searches/cost (MCP get_query_cost) and send it as maxQueryCost, which defaults to 1. A recent search sent with maxQueryCost 2 is still charged 1. If the price at admission is higher than maxQueryCost, the response is 409 query_cost_changed with the current queryCost and nothing is reserved or charged; check the price again before deciding, and never retry automatically. Continuation, stored-record reads, saved content and export add no query charge. A 429 includes Retry-After; machine requests are limited to 60 per key and 180 per agency per rolling minute, and only two stored-record/ZIP streams may be active per agency. Do not automatically retry an uncertain initial search.
Endpoint reference
Every operation below is under https://<this address>. Exact request fields and response shapes are in the OpenAPI 3.1 specification, the same definitions this page is built from.
| Method | Path | Grant | Required fields | Charge |
|---|---|---|---|---|
POST | /api/v1/searches | search | query | Charged when accepted: 1 query, or 2 when the dates include data older than 12 months |
POST | /api/v1/searches/cost | search | None | No query charge |
POST | /api/v1/searches/continue | search | searchReference, continuationToken, query | No query charge |
GET | /api/v1/executions/{reference} | search | None | No query charge |
POST | /api/v1/records/open | records:read | recordHandle | No query charge |
POST | /api/v1/records/search-within | records:read | recordHandle, query | No query charge |
POST | /api/v1/records/download | records:read | recordHandle | No query charge |
GET | /api/v1/sources | search | None | No query charge |
GET | /api/v1/usage | usage:read | None | No query charge |
GET | /api/v1/cases | cases:read | None | No query charge |
GET | /api/v1/history | history:read | None | No query charge |
POST | /api/v1/cases/create | cases:write | title, visibility | No query charge |
POST | /api/v1/cases/read | cases:read | caseId | No query charge |
POST | /api/v1/cases/update | cases:write | caseId, title, visibility | No query charge |
POST | /api/v1/cases/findings/save | cases:write | caseId, recordHandle, startByte, endByte, note, highlightSpans | No query charge |
POST | /api/v1/cases/findings/update | cases:write | caseId, findingId, revision, note, highlightSpans | No query charge |
POST | /api/v1/cases/export | cases:read | caseId | No query charge |
GET | /api/v1/artifacts/{handle} | The grant of the tool that issued the link | None | No query charge |
POST | /api/v1/mcp | Each tool checks its own grant | One JSON-RPC message | As the operation each tool performs |
Examples
GET /api/v1/sources (on a Main-profile server)
-> {"profileVersion":"main-wire-v4.0","sources":[{"sourceId":"darkweb"},{"sourceId":"databreach"},{"sourceId":"deepweb"},{"sourceId":"malware"},{"sourceId":"social"},{"sourceId":"unknown"}]}
POST /api/v1/searches/cost
{"startAt":"2023-01-01T00:00:00Z","endAt":"2024-01-01T00:00:00Z"}
-> {"queryCost":2,"startAt":"2023-01-01T00:00:00Z","endAt":"2024-01-01T00:00:00Z"}
POST /api/v1/searches
{"query":"example.test","caseMode":"exact","sourceIds":["malware"],"startAt":"2023-01-01T00:00:00Z","endAt":"2024-01-01T00:00:00Z","maxQueryCost":2}Migrating from the previous Investigation API
This compares the previous Investigation API with /api/v1 so that people and agents do not translate old requests silently. Compared: the previous search route GET /api/investigation/search (previous product source cih-dashboard 0f372136, which forwards each search to an internal search service) and /api/v1 as described on this page. The deployed version of the previous API, and real-data behavior of the new one, are not verified here. This note qualifies no workload and promises no migration, parallel operation or transfer.
Status terms: Supported equivalent, Different behavior, No direct equivalent, Not verified.
| Previous | New /api/v1 | Status |
|---|---|---|
Authorization access token from POST /api/auth, or X-Api-Key, scoped to a partner or account (partner_id/account_id selectors) | Authorization: Bearer <key> that the investigator creates in the Web shell with explicit grants; agency membership is checked on every request | Different behavior: previous tokens and keys do not work here. |
One string in q (or query) | query: one string, at most 4096 UTF-8 bytes, matched literally with caseMode exact (default) or anycase | Different behavior: both accept one string, but the input mapping and the matching differ; no result equivalence is claimed. |
Boolean operators, field:value terms, * wildcards or regular expressions written inside q | No query syntax: the same characters typed into query | No direct equivalent: if the search is accepted, those characters are searched literally and the search is charged; their previous meaning is not reproduced. |
Previous parameters has_password (which the previous service expanded into a Boolean query), source_url, source_domain, title, leak_id, leak_company, leak_industry, cve, nick, channel, actors, subtype | No such field: the request schema refuses unknown fields with 400 invalid_input before any search | No direct equivalent: an unknown field is refused and nothing is charged. |
sources: deepweb, darkweb, databreach, malware, socialchannels | sourceIds listed by GET /api/v1/sources for the server profile | Not verified: matching names do not prove the same source content. |
after, before | startAt inclusive and endAt exclusive, both or neither, UTC; neither means the most recent 12 months | Different behavior. |
page from 1, size 0–100 (default 10) | pageSize 1–50 (default 25); the next page through POST /api/v1/searches/continue with the reference, the token and the same query and caseMode | Different behavior: a gte count or an incomplete page is not a total, and no result does not prove absence. |
Result fields inline (doc_id, doc_data, text, html, hits) | An opaque recordHandle to open, search within or download the stored record; handles expire (record_expired) | Different behavior: no lookup by a previous document ID; save a selection to a case to keep its bytes. |
| Charging | 1 query per accepted initial search, 2 when the dates include data older than 12 months; the price check is free; maxQueryCost caps it; continuation and stored-record reads are free | Not verified for the previous API: its search route does not itself record a charge, and MSSP partners had a separate investigation-slot allowance. |
| Retries | 202 with accountingState: "pending": check GET /api/v1/executions/{reference} and never rerun the initial search automatically; 429 with Retry-After | Different behavior. |
| Previous case files, API keys, access tokens and allowances | Cases, keys and allowance of the new agency | No direct equivalent: no transfer is implemented or verified; nothing moves automatically. |
Examples use fictional data. Previous: GET /api/investigation/search?q=analyst@example.test&after=2025-01-01&before=2026-01-01 with X-Api-Key, then page=2. New: check the price, search with an explicit case mode, continue, and open a result:
POST /api/v1/searches/cost
{"startAt":"2025-01-01T00:00:00Z","endAt":"2026-01-01T00:00:00Z"}
POST /api/v1/searches
{"query":"analyst@example.test","caseMode":"anycase","startAt":"2025-01-01T00:00:00Z","endAt":"2026-01-01T00:00:00Z","pageSize":50,"maxQueryCost":2}
POST /api/v1/searches/continue
{"searchReference":"<searchReference from the page>","continuationToken":"<continuationToken from the page>","query":"analyst@example.test","caseMode":"anycase"}
POST /api/v1/records/open
{"recordHandle":"<recordHandle from a result>"}No direct equivalent, two different outcomes. Previous parameters such as has_password=true or cve=CVE-0000-0000 have no field in the new request: sent as JSON fields they are refused with 400 invalid_input before any search, and nothing is charged. Previous query syntax such as email:*@example.test or alpha AND beta, typed into query, is searched as literal characters and charged if the search is accepted; it does not reproduce field, wildcard or Boolean meaning, and separate literal searches are each charged.
Agents using MCP get the same literal rules, price and limits, and must not rewrite a previous query into a new one they cannot verify.