Threatera API
Threatera exposes everything the web UI does as a documented REST API. Upload and analyze files, detonate samples in an isolated sandbox, scan URLs, and pivot across the indicator graph — all over HTTP with JSON.
Base URL
All endpoints are served from the Threatera API host, under the /api prefix:
https://api.threatera.eu/api
Examples on this page use https://api.threatera.eu and the environment variable $THREATERA_KEY for your API key. Requests and responses are JSON unless noted (file upload is multipart; downloads are binary).
Authentication
Every endpoint (except /health) requires authentication. There are three accepted credentials, in order of precedence:
| Method | Header | Use for |
|---|---|---|
| Service key | x-api-key: <key> | Server-to-server automation and integrations |
| Personal API key | x-api-key: <key> | Per-user scripting (create under API keys) |
| Bearer JWT | Authorization: Bearer <token> | The web UI (from /auth/login) |
For API clients, send your key in the x-api-key header:
curl https://api.threatera.eu/api/stats \ -H "x-api-key: $THREATERA_KEY"
A missing or invalid credential returns 401 Unauthorized. Admin-only endpoints return 403 Forbidden for non-admins.
Roles, and what each one may do
Membership of an organisation carries a role, ranked read-only < analyst < admin < owner.
| Role | May |
|---|---|
read-only | read everything in the organisation. Every endpoint that changes data returns 403 — uploads, analyses, detonations, cases, comments, indicators, rules, watchlists and API keys. Changing one's own password and enrolling MFA are always permitted. |
analyst | everything above, plus every write. |
admin | everything above, plus the organisation's own administration: members, invites, the audit log. |
owner | as admin. Distinguished for member management — nobody may grant or remove a role at or above their own. |
read-only was enforced nowhere until 2026-08-18. The rank existed and the comparison function was correct, but every caller asked whether the actor was an admin, so the only line drawn was admin/not-admin: a read-only member could upload samples, run detonations and mint API keys. If you issued that role expecting it to restrict anything, it did not. tests/test_write_permission.py now walks the live route table and fails the build for any endpoint that changes data without saying who may call it.An API key inherits the role of the member who created it, so a key minted by an analyst can write and one minted under a read-only membership cannot. The shared service key writes by default — an ingestion integration that cannot submit a file has no purpose — and is separately barred from the admin surface unless SERVICE_KEY_ALLOWS_ADMIN is set.
Two-factor authentication
User accounts can enrol an authenticator app. Once enrolled, POST /api/auth/login needs the 6-digit code alongside the password; if it is missing the response is 200 with { "mfa_required": true } and no token, so a client can prompt for it and retry. See Two-factor auth.
x-api-key clients are unaffected by a user's MFA state — which is what makes them the right credential for automation.Liveness
| Method & path | Description |
|---|---|
GET /health | The one unauthenticated endpoint. Returns { "status": "ok" }. |
Note it is not under the /api prefix. This is a process liveness probe for load balancers and container orchestration — for the state of the backing services (Postgres, Elasticsearch, ClamAV…) see System health.
Conventions & errors
- Identifiers. Files are identified by their SHA-256 hash (lowercase hex). Indicators are identified by their value (an IP or domain).
- Deduplication. Files and indicators are deduplicated. Re-submitting an existing one bumps a sighting
counterand refresheslast_seenrather than creating a duplicate. - Async work. Analysis, detonation and URL scans are queued. The submit call returns
202 Acceptedwith apendingrecord; poll the matchingGETuntilstatusbecomescompleted/failed. - Pagination. List endpoints take
limit(1–200) andoffset, and return{ "total": n, "results": [...] }. - Timestamps. ISO-8601 UTC.
Status codes
| Code | Meaning |
|---|---|
200 / 201 | Success |
202 | Accepted — async job queued |
204 | Success, no content (deletes) |
400 | Bad request (e.g. invalid hash / indicator) |
401 / 403 | Unauthorized / forbidden |
404 | Not found |
413 | Upload too large |
422 | Validation error (malformed body/params) |
Universal search
One query across files, indicators and URL scans — the endpoint to reach for when you have a string and don't yet know what it is.
The query is classified first, and only the stores that could plausibly match it are queried — a SHA-256 doesn't trigger a pointless URL scan. The classification is returned as detected so you can see how your input was read.
Query parameters
| Name | Type | Description |
|---|---|---|
| q required | string | A SHA-256 or a prefix of one, an IP, a domain, a URL, or free text matched against filenames. 1–512 chars. |
| limit | integer | Max results per category. 1–50, default 10. |
What each query type searches
detected | Searches |
|---|---|
sha256 / hash_prefix | Files only (exact, or by hash prefix) |
ip | Indicators |
domain | Indicators, and URL scans whose URL contains the domain |
url | URL scans (by URL prefix) |
text | All three — filenames, indicator values, scanned URLs |
curl -G https://api.threatera.eu/api/search \ -H "x-api-key: $THREATERA_KEY" \ --data-urlencode "q=invoice"
{
"query": "invoice",
"detected": "text",
"files": {"total": 2, "results": [ … ]},
"indicators": {"total": 0, "results": []},
"urlscans": {"total": 1, "results": [{"id":"…","url":"…","status":"completed","created_at":"…"}]},
"total": 3
}URL scans come back in a reduced shape here; fetch GET /urlscans/{id} for the full record.
Files
Ingest, look up, analyze, detonate and pivot on file samples. Files are deduplicated by SHA-256.
Upload a file. Stores the raw bytes, records metadata, and indexes it for search.
Form fields (multipart/form-data)
| Field | Type | Description |
|---|---|---|
| file required | binary | The file to upload. |
| scan_type optional | string | quick (ingest only, default) or deep (also queues analysis + a sandboxed detonation). |
| tags optional | string | Comma-separated list, max 5. |
curl -X POST https://api.threatera.eu/api/files/upload \ -H "x-api-key: $THREATERA_KEY" \ -F "file=@sample.bin" \ -F "scan_type=deep" \ -F "tags=malware,emotet"
{
"message": "File uploaded successfully",
"filename": "sample.bin",
"queued": ["static_analysis", "detonation"]
}413. The SHA-256 of the bytes is the file's identifier — compute it locally to reference the file afterwards.Look up a file's intel record by hash. 404 if never seen.
{
"hash": "8d0a…a255", "filename": "invoice.sh",
"content_type": "application/octet-stream", "size": 200440,
"tags": ["email"], "verdict": "clean", "yara_matches": [],
"ingested_at": "2026-07-20T08:49:24Z", "last_seen": "2026-07-20T08:49:24Z",
"counter": 1,
"detonation": { "supported": true, "reason": "" },
"provenance": {
"extracted_from": "bebc…8ac9",
"extracted": []
}
}detonation answers "can the sandbox run these bytes?" from the stored object itself, so a client can disable the action before any analysis exists. reason is the server's own wording for why not.
provenance is both directions of the extraction relationship, scoped to your organisation:
| Field | Meaning |
|---|---|
extracted_from | The archive or email these bytes came out of, or null for a direct upload. Set once, on first sighting — a file you later upload yourself keeps the provenance it arrived with. |
extracted | What came out of this file: a zip's members, an email's attachments. Each with hash and filename. |
/api/files/{sha256} lookups. An archive another organisation extracted is not part of the history you see here.Search ingested files, most recently seen first. All filters are optional and combine with AND.
Query parameters
| Param | Type | Description |
|---|---|---|
| hash | string | Full or partial SHA-256; comma-separated for several. |
| tag | string | Exact tag match. |
| content_type | string | Exact content-type match. |
| filename | string | Full-text filename match. |
| verdict | string | One of malicious, suspicious, clean, unknown. unknown also matches files that have never been analysed and so carry no verdict at all — "not malicious, not suspicious, not clean" is one question however the gap arose, and the two are told apart on the row itself (unknown against unscanned). |
| decided | string | Whether an analyst has looked — a separate axis from verdict,
which is the machinery's answer. undecided is the queue's own
question: files nobody has judged. decided is any conclusion, and a
named one (confirmed, false_positive,
benign, needs_work) narrows to it — which makes
?decided=false_positive the false-positive corpus.
?verdict=malicious&decided=undecided is the work queue. |
| seen_since | datetime | Only files last seen at/after this ISO time. |
| sort | string | field:asc or field:desc — one of last_seen, ingested_at, filename, size, content_type, verdict, counter. Anything unrecognised falls back to newest first rather than erroring, so a stale bookmark still returns results. size is held in the index so it can be ordered, while the value shown still comes from Postgres — a document indexed before that field existed therefore reports the right number and sorts last until scripts/rebuild_search_index.py runs. |
| limit / offset | int | Pagination (limit 1–200, default 50). |
curl "https://api.threatera.eu/api/files?tag=malware&seen_since=2026-06-01&limit=20" \ -H "x-api-key: $THREATERA_KEY"
Returns { "total": n, "results": [ FileRecord, … ] }.
The distinct content types actually present in the store, most common first — the valid values for the content_type filter above, so a client can offer a dropdown instead of asking the user to guess a MIME type.
Returns { "content_types": ["application/octet-stream", "text/plain", … ] }.
Download the raw stored bytes. Always served as application/octet-stream with an attachment disposition — a stored sample is never rendered or executed inline.
curl https://api.threatera.eu/api/files/<sha256>/raw \ -H "x-api-key: $THREATERA_KEY" -o sample.bin
Record what an analyst concluded about this file. This is a second verdict and it does not touch the first.
verdict on the file record is the machinery's answer — ClamAV,
YARA, capa, the sandbox — and re-analysis is free to change it. This is a
named person's judgement at a moment, and it has to survive exactly that.
The two are stored and returned separately so a disagreement between them
stays visible instead of one silently overwriting the other.
Body (application/json)
| Field | Type | Description |
|---|---|---|
| verdict | string | null | confirmed · false_positive · benign ·
needs_work, or null to withdraw the decision and
return the file to "nobody has looked". |
| note | string | Why. Required when clearing a file the platform called
malicious — "a person disagreed" is not a record of anything
without the reason, and this is the one direction where being wrong means
a real detection was dismissed. 400 without it. |
curl -X PUT https://api.threatera.eu/api/files/<sha>/verdict \ -H "x-api-key: $THREATERA_KEY" -H "content-type: application/json" \ -d '{"verdict":"false_positive","note":"signed internal build, ships with the VPN client"}'
Which verdict should a client use? Where a person is being shown a
conclusion, prefer analyst_verdict when it is set and fall back to
verdict. Anything leaving the platform — a STIX bundle, a
regulatory filing — uses verdict, because it is the reproducible
one and its reader cannot see the note behind a human's.
analyst_verdict: null means nobody has looked. It is not a
verdict of unknown, and it is the triage state the Files queue is worked down.
404 if your organisation does not hold the file. Holding it is the question — a hash another customer has analysed is not yours to judge.
The same analyst conclusion about several files. Triage produces this shape of work: a rule fires on forty files from one campaign and every one of them gets the same answer.
| Field | Type | Description |
|---|---|---|
| hashes | string[] | Up to 500 SHA-256 digests. Beyond that this is a filter, not a selection. Any value that is not a hex digest fails the whole request — a typo should not look like a deleted file. |
| verdict | string | null | As the single-file route. |
| note | string | Checked once against the batch. A reason good enough for forty files is a reason; asking for forty is how somebody types "fp" forty times. |
{ "decided": 37, "missing": ["<sha>", "<sha>", "<sha>"] }
It reports what it did, not that it succeeded. decided is
the number of rows actually written and missing names hashes your
organisation does not hold. A caller that selected forty and changed
thirty-seven has to be able to tell — reporting blanket success is how a bulk
control loses somebody's work without either of you noticing.
Queue static analysis (hashes, entropy, type, PE/ELF internals, archive members, Office macros, string & IOC extraction, ClamAV + YARA verdict). Returns 202 with a pending analysis; poll the analysis endpoint.
Body (application/json, optional)
| Field | Type | Description |
|---|---|---|
| archive_password | string | Password to decrypt a protected archive (zip/7z/rar) before analysis. |
curl -X POST https://api.threatera.eu/api/files/<sha>/analyze \ -H "x-api-key: $THREATERA_KEY" -H "content-type: application/json" \ -d '{"archive_password":"infected"}'
The most recent analysis for a file. 404 if none has run.
{
"status": "completed",
"result": {
"file_type": "pe", "size": 51200, "entropy": 7.91,
"md5": "…", "sha256": "…", "imphash": "…", "tlsh": "…",
"verdict": "malicious",
"detections": { "clamav": {"status":"infected","signature":"Win.Trojan.Emotet"},
"yara": {"status":"ok","matches":["c2_beacon"]} },
"iocs": [{"type":"domain","value":"evil-c2.example.com"}],
"pe": { /* sections, imports, exports, signed, packing… */ }
}
}A PE file gets a pe block; an ELF an elf block; an archive an archive block (with members); a macro-enabled Office doc a macros block.
Queue dynamic detonation — actually execute the sample in a locked-down, throwaway container and capture behavior. Returns 202. If the file type isn't supported, the record comes back status: "unsupported" with an error and nothing is queued.
Two backends, chosen from the file's own bytes and reported on the record as backend:
| Backend | Runs | How |
|---|---|---|
linux_container | ELF binaries, shell and shebang scripts | Executed directly in the container. |
powershell_core | PowerShell scripts (.ps1) | Executed under PowerShell 7 on Linux — see the note below for what that does and does not observe. |
windows_wine | Windows PE executables (32- and 64-bit) | Executed under Wine. Reports Windows paths, and diffs the registry value by value so a Run key or a service install shows up as a finding rather than as "user.reg was modified". |
calc.exe, cmd.exe and whoami are absent; and no WMI or COM. A script depending on those reports errors for them and carries on, and the persistence section names the ground that could not be covered rather than reporting its absence as a finding. A quiet run is not a clean verdict.This backend exists because the alternative was silence: a
.ps1 has no magic number and no shebang, so it sniffed as plain text, and the shell-script heuristic matched on if, for, function and $( — all of which PowerShell shares with sh. The script was handed to /bin/sh, which died on its first line, and the run was reported as completed with an empty process tree. Nothing on screen distinguished "this sample does nothing" from "this sample was never executed".windows_wine run carries a fidelity object saying exactly this, so the caveat travels with the report rather than living only here. HTTPS request bodies are also not decrypted for Windows samples (Wine has its own certificate store, which the sinkhole's throwaway CA is not in); the hostname is still captured from the DNS query and the TLS handshake.Query parameters
| Param | Values | Description |
|---|---|---|
| network | sinkhole · none · egress | Network containment. sinkhole (default) — fake internet, records what the sample tried to reach, nothing leaves the host. none — no network. egress — real internet access (opt-in; the sample can reach the outside world). Note that a sinkholed sandbox has no route off its isolated bridge: hostnames resolve to the fake server and are recorded, but traffic addressed straight to an IP (ping 8.8.8.8) fails with Network is unreachable and never reaches the wire, so it cannot be reported. Use egress to observe that. |
| trace | true · false | Windows samples only. Records the API calls the sample makes — what it asked the system to do, rather than only what it left behind. This is what catches the sample that injects into another process, or resolves its functions by name at run time, and never touches disk: both leave an empty filesystem and registry diff. Off by default, and ignored on a Linux run rather than promising a trace the report cannot contain. Two costs. The run is slower, so raise the timeout for a sample that was already close to it. And Wine's relay layer is observable from inside the sandbox — a sample that looks can tell it is being traced, and may behave differently for that reason alone. |
| args | string | Command line passed to the sample, shell-quoted — --verbose -c /tmp/cfg. Parsed like a shell, so an argument containing spaces has to be quoted. Many droppers do nothing at all without the flag their loader passes them, and detonating one bare produces a clean report about a sample that never ran. The arguments used are echoed back on the detonation record. |
curl -X POST "https://api.threatera.eu/api/files/<sha>/detonate?network=sinkhole" \ -H "x-api-key: $THREATERA_KEY"
What an API trace reports
Not the raw firehose. A full relay trace is millions of lines and a slowdown of one to two orders of magnitude, so the trace is scoped at the source to a curated set of functions — file, registry, process, network, crypto, service, loader and anti-analysis. The result carries three things: counts per category, the named behaviours that no single call demonstrates (process injection is WriteProcessMemory and CreateRemoteThread; either alone is ordinary), and the calls themselves with their string arguments — the path written, the URL fetched, the value set.
api_trace.status is ok, empty or unsupported, with a reason — because "the harness failed" and "the sample was quiet" look identical otherwise, and only one of them is a finding.The run's packet capture, as a pcap file — open it in Wireshark, replay it through Suricata or Zeek, keep it as evidence on a case.
These are the bytes tcpdump wrote, unmodified. The network summary on the detonation report is read back out of this same file rather than captured separately, so the download and the report cannot disagree about what crossed the wire.
curl "https://api.threatera.eu/api/detonations/42/pcap" \ -H "x-api-key: $THREATERA_KEY" -o run-42.pcap
404 when the run captured nothing: network=none, a run that failed before the capture started, or any detonation from before captures were kept. Very chatty samples stop at 100,000 packets — the file stays a valid capture, and the report says when the limit was reached.
What is inside a stored archive, and which members could be detonated. Use it to choose an entry_point for POST /files/{hash}/detonate.
One file per run misses the samples that need their siblings — a signed executable beside the DLL it side‑loads does nothing interesting on its own, and a script that reads a config from its own folder fails without it. Naming a member runs that member with the whole archive laid out around it.
{
"hash": "9f2b…",
"members": [
{ "name": "app.exe", "size": 184320,
"detonable": true, "backend": "windows_wine", "reason": "" },
{ "name": "version.dll", "size": 51200,
"detonable": false, "backend": null,
"reason": "The sandbox cannot execute 'unknown' files…" },
{ "name": "data/config.ini", "size": 82,
"detonable": false, "backend": null, "reason": "This is a text file, but…" }
],
"truncated": false
}detonable is answered per member by the same check the detonation itself uses, so the list cannot offer something the run would then refuse. backend follows the member, not the archive: the same zip runs under Wine when you pick a PE and on Linux when you pick a shell script.
Names are normalised the way the sandbox will normalise them, and an archive with an absolute or traversing path (../) is rejected outright with 400 — it is refused for the whole archive rather than skipped, because a member trying to write outside its own directory is a property of the archive worth knowing.
400 if the file is not an archive, or expands past the sandbox’s limits (512 members, 256 MB uncompressed). 404 if the file is unknown. Listing stops at 200 members with truncated: true.
The most recent detonation run for a file. 404 if none has run.
{
"status": "completed", "backend": "linux_container", "network_mode": "sinkhole",
"result": {
"exit_code": 0, "timed_out": false, "duration_seconds": 0.42,
"stdout": "…", "dropped_files": [{"path":"/tmp/x","kind":"added"}],
"network": { "mode":"sinkhole", "dns_queries":["evil-c2.example.com"], "ports":[80] },
"iocs": [{"type":"domain","value":"evil-c2.example.com"}]
}
}A windows_wine run carries two more keys in result:
{
"status": "completed", "backend": "windows_wine",
"result": {
"dropped_files": [{"path":"C:\\users\\nobody\\Temp\\evil.exe","kind":"added"}],
"registry_changes": [{
"key": "HKEY_CURRENT_USER\\Software\\Microsoft\\Windows\\CurrentVersion\\Run\\Updater",
"kind": "added", "value": "C:\\users\\nobody\\Temp\\evil.exe"
}],
"persistence": [{"mechanism":"registry-run", "description":"a Run key, which starts …"}],
"fidelity": { "emulated": true, "note": "Run under Wine, not Windows. …" }
}
}registry_changes is a value-level diff against a pristine copy of the hives baked into the sandbox image — Wine rewrites all three hive files on every shutdown regardless of what happened, so a file-level comparison would report the whole registry as modified on every run. Per-machine identity that Wine rebuilds each start (computer name, Tcpip\Parameters\Hostname, volatile environment, driver timestamps) is excluded: it is the harness's, not the sample's.
Indicators extracted from this file (the file → IOC pivot). 404 if the file is unknown; an empty list if it has no indicators.
Files related to this one by TLSH fuzzy-hash distance and imphash. Optional query params: threshold (max TLSH distance, lower = stricter) and limit (corpus files to compare).
A single shareable report for a file — the intel record, detections, static analysis, extracted indicators and the sandbox detonation bundled into one document. This is the thing you attach to a ticket instead of screenshotting five panels.
Query parameters
| Name | Type | Description |
|---|---|---|
| format | string | md (default) for Markdown, pdf for a laid-out PDF. |
Responds with text/markdown or application/pdf as a download (Content-Disposition: attachment), not JSON. 404 if the file is unknown.
curl -O -J "https://api.threatera.eu/api/files/8d0a…a255/report?format=pdf" \ -H "x-api-key: $THREATERA_KEY"
/analysis first if you need them populated.Indicators (IOCs)
IPs and domains, deduplicated with a sightings model and linked to the files and scans that touched them. Single-indicator lookups are enriched with offline GeoIP/ASN data.
Submit an indicator (type auto-detected). Re-submitting bumps the counter and refreshes last_seen. Non-routable / internal values are rejected with 400.
Body (application/json)
| Field | Type | Description |
|---|---|---|
| value required | string | A valid IP address or domain. |
| tags | string[] | Up to 5 tags. |
curl -X POST https://api.threatera.eu/api/iocs \ -H "x-api-key: $THREATERA_KEY" -H "content-type: application/json" \ -d '{"value":"185.220.101.1","tags":["c2","tor"]}'
Search indicators, most recently seen first. Filters combine with AND.
Query parameters
| Param | Type | Description |
|---|---|---|
| type | string | ip or domain. |
| tag | string | Exact tag match. |
| value | string | Value prefix match. |
| seen_since | datetime | Only indicators last seen at/after this. |
| sort | string | field:asc or field:desc — one of last_seen, first_seen, value, counter, type, tags. An indicator carries several tags, so tags orders by the alphabetically first one — in both directions, which is what makes descending the exact reverse of ascending. Case is ignored, so Spam sits with spam rather than ahead of every lowercase tag. |
| limit / offset | int | Pagination. |
Look up a single indicator (type inferred). 404 if unseen.
{
"type": "ip", "value": "185.220.101.1", "tags": ["c2","tor"],
"first_seen": "…", "last_seen": "…", "counter": 5,
"enrichment": {
"network": {
"asn": 60729, "as_org": "Stiftung Erneuerbare Freiheit",
"country": "DE", "country_name": "Germany" },
"feeds": [{
"feed": "tor_exit", "name": "Tor exit nodes", "label": "tor exit node",
"confidence": null, "reference": "https://…", "first_seen": "…" }],
"intel": [{
"source": "CERT-AT feed", "kind": "taxii", "label": "Emotet C2",
"confidence": 80, "tlp": "amber", "reference": "indicator--…",
"first_seen": "…" }]
}
}The enrichment blocks
Every one of them is answered locally. GeoIP reads a bundled database, feed and intel matches read tables this deployment already downloaded on a schedule. Nothing here asks a third party anything at lookup time, which is the whole point: opening an indicator is not audible to whoever runs the source. Absent blocks are omitted rather than sent empty, and enrichment itself is absent when there is nothing to add.
| Key | Present for | What it is |
|---|---|---|
network | IPs | Offline GeoIP/ASN — asn, as_org, country, country_name. |
feeds | either | The public threat feeds that list this value. Identical for every organisation on the installation. |
intel | either | Your organisation's own TAXII/MISP subscriptions. Tenant-scoped, and each entry carries the tlp it arrived under. |
feeds and intel are deliberately not merged. Both answer "is this known bad?", but only one answers it with a source the customer chose and is accountable to, and only one carries a marking that limits what may be done with the answer. Flattening them would lose both facts at the moment an analyst is deciding whether to act. Neither is a sighting: counter, first_seen and last_seen stay what your organisation encountered.Files that reference this indicator (the IOC → file pivot). Each carries
how:
strings | Found in the file's plaintext. Static extraction only ever finds plaintext, so this is evidence the value is present — it may equally be a C2, a comment, or a library's vendor URL. |
sandbox network | The sample reached it while running. Code that executed went there, which is evidence of intent. |
strings + sandbox network | Both, and strictly stronger than either — written into the file and contacted. |
null | Not recorded. The link predates this field. It is not a claim that the indicator was found some other way, and it is deliberately not defaulted — the whole point of the column is to distinguish a guess from an observation. |
Blocking decisions turn on which of these you have, and before this field existed the two were indistinguishable in the same row.
URL sandbox runs that contacted this indicator (the IOC → URL-scan pivot).
How often your organisation has met this indicator, day by day.
?days= sets the window (1–90, default 14).
The running counter on the indicator itself answers
how significant is this and cannot answer is it getting worse.
Four hundred sightings spread over a year is infrastructure somebody should
have blocked long ago; four hundred that all arrived on Tuesday is an
incident. The counter renders both as 400.
{
"value": "c2.example.test",
"days": [ {"day": "2026-08-16", "count": 0}, … {"day": "2026-08-29", "count": 37} ],
"total_in_window": 91,
"total": 412,
"recording_since": "2026-08-20"
}
Three things worth reading carefully:
- The series is dense. Every day in the window is present, zeroes included, so a client can draw it without re-densifying a sparse list — which is where an off-by-one puts Tuesday's spike on Wednesday.
recording_sinceis where the record begins. Per-day counting started with a migration and was deliberately not backfilled: the platform never recorded when a repeat sighting happened, only that the count went up, so any earlier history would be a distribution nobody observed. A zero before this date means "not counting yet", which is the opposite of what a zero after it means. Draw them differently.total_in_windowis nottotal. They differ for any indicator older than the window. Reporting one as the other makes the chart look broken.
404 if the indicator is unknown to the platform.
DNS history. The direction depends on what you ask about, and the reverse case is the interesting one:
| Indicator | direction | Returns |
|---|---|---|
| A domain | domain_to_ip | The addresses it has been seen resolving to. |
| An IP | ip_to_domain | Every domain observed resolving to it — how you find shared infrastructure. |
{
"value": "93.184.216.34", "type": "ip", "direction": "ip_to_domain",
"active_dns": { "status": "not_applicable" },
"results": [{
"domain": "example.com", "ip": "93.184.216.34", "count": 4,
"source": "urlscan",
"first_seen": "2026-07-14T10:02:11Z", "last_seen": "2026-07-26T18:44:03Z"
}]
}urlscan is passive — the address a scan's traffic actually reached, learned at no extra cost — while active is a live lookup this endpoint performed.Live lookups
For a domain, the endpoint may resolve the name itself, and reports what it did in active_dns.status: disabled, cached (something was already observed within ACTIVE_DNS_CACHE_MINUTES), resolved, error, or not_applicable for an IP. A failed lookup is never recorded — "we could not ask" is not an observation.
| Name | Type | Description |
|---|---|---|
| refresh | bool | Resolve again even if a recent observation exists. Default false. |
ACTIVE_DNS_ENABLED when timeliness is worth being seen.400 if the value is not a valid IP or domain.
A risk score for an indicator, computed entirely from your own instance's data — the files that reference it and their verdicts, sandbox runs that contacted it, analyst tags, and registration age.
Response
{
"value": "evil-c2.com", "type": "domain",
"score": 75, "band": "malicious", "confidence": "high",
"signals": [
{"label":"extracted from malicious files","weight":45,"detail":"2 file(s) …"},
{"label":"labelled by an analyst","weight":30,"detail":"tagged c2"}
],
"evidence": {"linked_files":2,"malicious_files":2,"sandbox_runs":0, …},
"basis": "Computed only from this instance's own data …"
}Bands
| Band | Meaning |
|---|---|
malicious | score ≥ 70 |
suspicious | score ≥ 40 |
unknown | score ≥ 15, or no evidence at all |
likely-benign | the platform has looked and found nothing bad |
An indicator with no evidence returns unknown, never benign. That distinction is the point: a confident "clean" on something the platform has never encountered would be inventing reassurance out of an empty database. likely-benign means it was examined; unknown means it wasn't.
The score is exactly the sum of the signals weights (clamped to 0–100), and every signal carries the evidence behind it — an analyst has to be able to disagree with the number, which means seeing what produced it. confidence reflects how much evidence exists, separately from what it concluded.
Registration data via RDAP — the structured successor to WHOIS. For a domain: registrar, registration/expiry/last-changed dates, nameservers and EPP status codes. For an IP: the allocated network range, organisation, country and abuse contact.
RDAP_ENABLED once that trade is acceptable for your deployment.While disabled the endpoint still returns 200, with "enabled": false and an explanatory error — a turned-off feature is a state, not a failure. No network call is made.
Response
{
"value": "example.com", "type": "domain",
"enabled": true, "cached": true, "error": null,
"registrar": "Example Registrar, Inc.",
"registered_at": "1995-08-14T04:00:00+00:00",
"expires_at": "2027-08-13T04:00:00+00:00",
"nameservers": ["a.iana-servers.net", "b.iana-servers.net"],
"statuses": ["client transfer prohibited"],
"raw": "{…the registry's full RDAP response…}"
}raw carries the complete registry response as a JSON string. The parsed fields above cover the common case, but registries put useful detail in fields nobody standardised — reseller, DNSSEC state, registrant hints — so this is where an analyst looks when the summary is not enough. Typically a few KB; hard-capped by RDAP_MAX_RESPONSE_BYTES. The UI shows it collapsed under the summary table.
Queries go to the authoritative registry, discovered through the IANA bootstrap — never through a public redirector such as rdap.org, so no aggregator learns what you look up.
Results are cached for RDAP_CACHE_DAYS (default 7). Failures are cached too: a TLD that publishes no RDAP service, or a domain that isn't registered, would otherwise be re-asked on every page view. cached: true tells you the answer came from that cache.
Coverage — read this before concluding it is broken
RDAP is mandatory for gTLDs (.com, .net, .org, .io, .dev, …) and those return full detail. It is voluntary for country-code TLDs, and many European registries either publish nothing or redact almost everything:
| TLD | Result |
|---|---|
.com .net .org .io and ~1200 more | Full detail — registrar, dates, nameservers, status |
.de, .ch | Reachable, but heavily redacted under GDPR — often nameservers only, no registrar or dates |
.eu, .at, .it, .es, .se | No reachable RDAP service — returns an explanatory error |
An empty or sparse panel for a European domain is therefore usually the registry's disclosure policy, not a failure here. Threatera does not fall back to port-43 WHOIS.
A slow, rate-limiting or malformed registry never fails the request — the reason arrives in error and the rest of the indicator page is unaffected.
URL sandbox
Render a URL in an isolated headless browser and capture the screenshot, final URL, page content, every network request, contacted indicators, and the TLS certificate. Guarded against SSRF on every request.
Queue a sandboxed render. Returns 202 with a pending scan; poll the scan endpoint. A URL that resolves to internal/private space is rejected with 400.
Body (application/json)
| Field | Type | Description |
|---|---|---|
| url required | string | The URL to render. |
curl -X POST https://api.threatera.eu/api/urlscans \ -H "x-api-key: $THREATERA_KEY" -H "content-type: application/json" \ -d '{"url":"https://example.com"}'
A URL sandbox run by id.
{
"id": 31, "url": "https://example.com", "status": "completed",
"result": {
"final_url": "https://example.com/", "title": "Example Domain",
"screenshot_object": "urlscans/31.png",
"requests": [{"url":"…","method":"GET","status":200,"remote_ip":"…"}],
"certificate": {"subject_cn":"example.com","issuer_cn":"…","not_after":"…",
"tls_version":"TLSv1.3","sha256":"…","sans":["example.com"]},
"iocs": [{"type":"domain","value":"example.com"}]
}
}List URL scans, most recently submitted first. Params: url (prefix match), limit, offset, include_dismissed.
include_dismissed is true here, unlike GET /detonations: this endpoint also answers “has this URL been scanned”, and a scan somebody cleared out of the sandbox queue is still a scan of that URL. The sandbox list passes false.
The rendered page's screenshot (PNG). 404 if unavailable.
curl https://api.threatera.eu/api/urlscans/31/screenshot \ -H "x-api-key: $THREATERA_KEY" -o shot.png
Download the captured TLS certificate as PEM. 404 if none was captured (not https, or the handshake failed).
curl https://api.threatera.eu/api/urlscans/31/certificate \ -H "x-api-key: $THREATERA_KEY" -o cert.pem
Indicators (hosts/IPs) contacted during the scan.
Detonations
File detonations are queued via POST /files/{hash}/detonate; these endpoints browse the results. See Get detonation for the result shape.
Recent detonation runs, newest first — each with the file's hash and first-seen filename. Params: limit, offset, include_dismissed. Returns { "total": n, "results": [...] }.
include_dismissed is false by default: runs an analyst has cleared out of the sandbox queue are left out, and total counts the same set the page is drawn from. Pass true to see them. Note that GET /urlscans defaults the other way — it also answers “has this URL been scanned”, and a scan somebody set aside is still a scan of that URL.
Clear several sandbox runs out of the queue, or put them back. The sandbox list is the union of two endpoints, so each run is named by both halves of its identity:
{ "runs": [ { "kind": "file", "id": 41 }, { "kind": "url", "id": 9 } ], "dismissed": true }kind is file (a detonation) or url (a URL scan); anything else is a 400 rather than a silent no-op, because this is the one place a client composes the pair by hand. dismissed defaults to true; send false to put runs back. Capped at 500 ids per kind.
include_dismissed=true. That distinction is the point — a long failed band is worth reading precisely because it is long, since forty failures sharing one error is a broken backend, and deleting them would clear the queue by destroying the only evidence of it.Reports what it did rather than that it succeeded: dismissed counts the rows actually changed and missing names the ones that were not yours to touch, so a caller that selected forty and changed thirty-seven can tell.
{ "dismissed": 2, "missing": [] }Submit several sandbox runs again. Same runs shape as above; returns 202.
Each detonation is repeated with its own network_mode, args and trace rather than with today's defaults — a run reproduced under different containment has not been reproduced. A re-run is a new run, never an edit of the old one, so “this failed on Tuesday and passed on Friday” stays readable.
{ "queued": ["file:112", "url:78"], "failed": [ { "run": "file:41", "reason": "the file is no longer stored" } ] }failed names anything that could not be queued — an unknown run, a file this organisation no longer holds, or a sample that is no longer eligible.
A single detonation run by id (DetonationResponse).
The result carries a persistence list — the filesystem changes that would outlive the process, each with the mechanism and what it means. This is the answer to "is the host still compromised", as opposed to dropped_files, which is everything the sample wrote.
mechanism | where |
|---|---|
cron | /etc/cron.d, /etc/cron.*, /etc/crontab, /var/spool/cron |
systemd | /etc/systemd/system, /usr/lib/systemd/system |
init | /etc/init.d, /etc/rc.local |
preload | /etc/ld.so.preload — a library in every process on the host |
shell | /etc/profile.d, /etc/profile, ~/.bashrc, ~/.zshrc |
ssh | authorized_keys |
udev | /etc/udev/rules.d |
package-manager | /etc/apt/apt.conf.d hooks |
ld.so.preload or truncating a shell profile is tampering. The scan runs over the whole filesystem diff rather than the capped dropped_files list, so a sample that writes three hundred files cannot hide its one cron entry behind a display limit.Each finding is also emitted as a Sigma event under the persistence category, so a rule can select on Mechanism instead of on a path list that drifts out of step with the sandbox.
Cases
Every other endpoint here analyses one artefact. A case is where you say which artefacts were the same incident — files, indicators, sandbox runs and reported email, with a status, a severity, an assignee and a note against each item explaining why it belongs.
Cases in your organisation, newest first. Params: status (open|monitoring|closed), assignee, q (matches reference, title or summary), sort, limit, offset. Each result carries item_count.
sort is field:asc or field:desc over created_at, updated_at, reference, title, status, assignee, item_count or severity. Severity orders by rank, not alphabetically — sorted as text, critical comes before low and high before medium, which produces a list that looks ordered and is wrong at the moment somebody is scanning it for the worst thing.
| Method & path | Description |
|---|---|
POST /api/cases | Open one. Body: { "title", "summary", "severity", "assignee" }. The reference (CASE-2026-014) is allocated per organisation, per year — sequential within your tenant, so it never discloses how many cases anyone else has opened. |
PATCH /api/cases/{id} | Retitle, reassign, reprioritise or change status. Body: { "title", "summary", "status", "severity", "assignee" }, all optional. Send "assignee": null to unassign — omitting the field leaves it alone. Setting status to closed stamps closed_at; reopening clears it. |
DELETE /api/cases/{id} | Delete the case and its links. The artefacts are untouched. |
One case with its contents. Each item is resolved back to the live artefact at read time and returned with a label, a detail line and an href into the UI.
found: false, its ref intact and no href. Links are stored as (kind, ref) with no foreign key — which is what lets a new artefact type be added without a schema migration, and costs the guarantee that every link still points somewhere. Hiding a dangling item would silently shrink the case; rendering it as a link would send an analyst to a 404. Reporting it is both honest and useful: it is evidence something was removed.Apply the same change to several cases — closing a week's work, or handing a queue to somebody. Body: { "ids": [...], "status", "severity", "assignee" }, each field optional and applied only when sent.
{ "ids": [41, 42, 43], "status": "closed" }"assignee": null unassigns, which is a different instruction from leaving the field out. The two cannot be told apart by value, so they are told apart by presence — the same distinction PATCH /cases/{id} makes. A client that omits the key to mean "nobody" will find nothing changed.Already in that state is not counted as a change. Closing four of twelve selected, because eight were closed already, reports four. closed_at is stamped on close and cleared on reopen rather than derived later: "how long was this open" is a number customers ask for, and reconstructing it from the audit log is fragile once events are ever pruned.
One audit entry for the batch, one event per case. The two want opposite things from the same action — a log read by a person is buried by twelve rows written in the same second, while an event sink is a machine tracking state, and a single summarising message would leave eleven cases stale downstream with nothing to reconcile against. Only status changes emit case.status_changed: a SOC routes on "this closed" and would mute a channel that also fired on a reassignment.
missing names ids that were not yours to touch. Capped at 500.
{ "changed": 4, "unchanged": 8, "closed": 4, "missing": [] }Attach an artefact. Body: { "kind", "ref", "note" }.
kind | ref is |
|---|---|
file | the SHA-256 |
ioc | the indicator value |
urlscan | the scan id |
email | the message id |
409 if it is already attached — a repeat is a mis-click, and a silent no-op would look like the button did nothing. note is optional and is the most useful field here: why this artefact is part of this incident.
Attach several artefacts at once. Body: { "items": [ { "kind", "ref", "note" } ], "note" }, with the same kind vocabulary as above. The outer note applies to every link; a per-item note overrides it for that item.
{ "items": [ { "kind": "email", "ref": "41" }, { "kind": "email", "ref": "42" } ],
"note": "same lure, same sender domain, reported within the hour" }already instead.The same artefact named twice in one request is attached once. Capped at 500 items, and one audit entry is written for the batch rather than one per link.
{ "added": 37, "already": 3, "items": [ ... ] }| Method & path | Description |
|---|---|
DELETE /api/cases/{id}/items/{item_id} | Detach it. The artefact itself is untouched. |
The case as a STIX 2.1 bundle — application/stix+json;version=2.1, downloaded as CASE-2026-001.stix.json. For a customer's TIP, a partner's MISP, or a CERT intake.
file, domain-name, ipv4-addr, url, email-message. An indicator carries a detection pattern a receiving SIEM will alert on, so one is emitted only where this platform holds an actual assertion. Mapping every artefact to an indicator is the easy shape and the wrong one: a phishing case legitimately contains login.microsoftonline.com, and telling a partner to alert on it is worse than sending nothing.| An indicator is emitted when… | and says |
|---|---|
A file's analysis returned malicious or suspicious | the verdict, and any YARA rules that matched |
| A public threat feed lists the value | which feeds |
An analyst tagged the indicator malicious or suspicious | that it was marked by this organisation |
The analyst tag is the promotion path that matters operationally: a C2 domain registered this morning is in no public corpus on the day you need to tell somebody about it.
Identifiers are deterministic. Observables take a UUIDv5 over their ID-contributing properties (STIX 2.1 § 2.9), so the same file exported from two cases is one object on arrival. The report and indicators are keyed on the case reference, so re-exporting updates the receiver's copy instead of adding a report per run.
Per-item notes are not exported. The free-text "why this belongs" is internal commentary and routinely names staff, customers and working hypotheses. The report's description carries the case summary, which is written to be read.
TLP markings
If any artefact in the case arrived through one of your intel subscriptions carrying a TLP marking, the bundle gains a marking-definition object and every other object in it gets an object_marking_refs pointing at that marking. The level applied is the most restrictive any contributing indicator arrived under — mixing an AMBER indicator into a GREEN bundle downgrades the AMBER one, which is the failure worth avoiding.
{"type":"marking-definition","spec_version":"2.1",
"id":"marking-definition--55d920b0-5e8b-4f79-9ee9-91f868d9b421",
"name":"TLP:AMBER","created":"2022-10-01T00:00:00.000Z"}definition block — the id and name are the whole of it, and adding a 1.0-style one would make the object invalid under both versions.This is what stops the export button breaching a sharing agreement. An indicator a CERT sent you as TLP:AMBER may not be passed outside your organisation; it lands in a case like anything else, and the case has a one-click export aimed at exactly the third parties the marking excludes. A case with nothing marked in it exports unmarked, as before.
Which cases already reference this artefact — the reverse lookup, and the reason it has its own index. It is what lets a file or indicator page say already part of CASE-2026-014 instead of an analyst opening a second investigation into something already being worked.
Regulatory reporting
A case rendered into what NIS2 (Directive (EU) 2022/2555, Art. 23) and DORA (Regulation (EU) 2022/2554, Art. 19) ask for, with the deadline for each stage.
Every deadline this organisation is carrying, across every open case, worst first. Overdue stages sort above the ones still running. Optional ?within_hours=24 narrows it to a horizon — overdue stages are always included, because a deadline that has passed does not stop being the next thing to do.
Each entry carries the case (case_id, reference, title, severity) alongside the stage (framework, label, citation, due_at, hours_remaining, missing). hours_remaining goes negative once a deadline has passed rather than clamping at zero: "3 hours late" and "due now" call for different actions.
summary.unanchored is the number to look at first, and it is not a deadline. It counts open cases that have a framework selected and no awareness time recorded — so no clock is running on them at all. Both regulations run every deadline from the moment the entity became aware, and a case that never recorded it reports every stage as waiting rather than late. It looks calm because nobody started it, which is the quietest way to miss a statutory deadline.curl "http://localhost:8000/api/regulatory/due?within_hours=24" -H "x-api-key: API"
The answers recorded for this case, and every stage they imply.
| Method & path | Description |
|---|---|
PUT /api/cases/{id}/regulatory | Record the answers. Only the fields you send are changed. Body: { "frameworks", "aware_at", "began_at", "classified_at", "suspected_malicious", "cross_border", "impact", "root_cause", "mitigation", "affected_clients" }. The two booleans are tri-state — null means nobody has decided, which is different from "no" and blocks the stages that require an answer. |
aware_at, not from when the case was opened. Somebody notices at 09:14, tells three people, and a case exists at 11:40 — the 24-hour early warning was due at 09:14 the next day. Anchoring on the case would report two and a half hours of margin that were never there, silently. So awareness is a field a human fills in, and nothing is due until they do.state | meaning |
|---|---|
due | the clock is running; hours_remaining goes negative once it has passed, rather than clamping to zero — "3 hours late" and "due now" call for different actions |
waiting | an earlier stage has to be filed first, so the deadline cannot be computed yet. Not the same as optional |
on-request | the regulation sets no deadline (the NIS2 intermediate report). Inventing one would be a deadline the law does not impose, shown in red |
submitted | somebody recorded that they filed it |
Later stages chain off the previous submission, not off awareness. A NIS2 final report is one month from the notification; for an incident notified on day three, anchoring it on awareness would make it three days early, every time. DORA's initial notification carries two clocks — 4 hours from classifying it as major, 24 from awareness — and the deadline returned is whichever comes first.
missing names the fields that still block a draft, in the words the form uses. An explicitly answered false counts as answered: "no, this is not suspected to be malicious" is what the authority asked for.
The stage as a Markdown draft, downloaded as CASE-2026-014-nis2-early_warning.md. Markdown because it pastes into any authority portal's textarea and needs no rendering dependency.
409 when a mandatory field is still empty, with the list in detail.missing. A draft is refused rather than rendered with gaps — a submission reading "Impact: —" where the authority expects an assessment is worse than none: it looks complete, it goes in the file, and nobody revisits it.
Record that you filed it. Body: { "authority_reference": "CSIRT-AT-2026-1187" }.
The draft is frozen at this point. The case keeps moving; a final report filed in March must show what was said in March, not today's edited summary, or the record of what the authority was actually told is lost. The deadline is copied too, so a later correction to aware_at cannot retroactively turn a timely filing into a late one.
Two digests are frozen with it: content_sha256 identifies the document, and evidence_digest identifies the artefacts it cited. Both are readable through the evidence endpoint.
Everything about one incident, in a zip an auditor can check without trusting you. Downloads as CASE-2026-014-audit-pack.zip.
README.txt what this proves, and what it does not
manifest.json every file with its SHA-256, the filed digests, the chain head
case.json the case, its regulatory answers, its timeline
evidence.md the artefacts behind the filings, as they stand now
audit-trail.json the audit events naming this case, and the chain's verdict
filings/nis2-notification.md the frozen text, byte for byte
The filings are the bytes stored at submission, not re-rendered. That is the point of the pack: content_sha256 verifies against the file inside it, so an auditor can run sha256sum and compare. A pack that re-rendered would produce a document failing its own digest, which looks exactly like tampering.
The archive is reproducible — entries sorted, timestamps fixed — so two exports of an unchanged case are byte-identical and "is this the same pack you sent in March?" has an answer.
GET /api/audit is: the pack contains the audit trail, which is a record of what everybody in the organisation did.Does a filed report still rest on the evidence it was filed against? Two independent checks that answer different questions:
content_sha256identifies the document. Somebody holding a printout can hash it and compare, without trusting anything this platform says about it.evidence_digestidentifies the artefacts it cited — each one's kind, reference and content hash.matchesre-derives that today.
A mismatch is usually not tampering. The common cause is a retention policy removing a sample whose period ran out — legitimate, and still something a supervisor asking about the filing would want to know. So missing names the artefacts that have gone rather than only reporting that the set moved.
matches: null means the report predates these digests. "We cannot tell" and "it has changed" are different answers, and reporting the first as the second would be an accusation produced by a migration. 404 if the stage has not been filed — a draft is re-rendered from the case every time it is asked for, so there is nothing to check it against.
curl "http://localhost:8000/api/cases/14/regulatory/nis2/notification/evidence" -H "x-api-key: API"
Intel subscriptions
The receiving half. A case exports as a STIX 2.1 bundle; this is where intelligence arrives — a TAXII 2.1 collection from a national CERT, a MISP instance run by an ISAC. What comes in surfaces as enrichment on the indicator page, is swept by the watchlist, and carries its TLP marking through to any export.
<timestamp>". It names no indicator, no hash and no file, so the publisher learns that this installation is subscribed and when it last polled — which they already know, because the customer signed up. That is categorically different from a per-lookup query, which would tell a third party which indicator an analyst is investigating right now. It is why RDAP is off by default and this is not.Which outside parties this organisation receives from. Readable by any member — knowing who your platform talks to is an assurance mechanism, and hiding it from analysts only means the person who notices something odd is the one who cannot look. The stored credential is never returned, to anybody; has_secret says whether one is set.
| Method & path | Description |
|---|---|
POST /api/intel/sources | Subscribe. Body: { "name", "kind", "url", "collection", "username", "secret", "default_tlp", "enabled" }. kind is taxii or misp; enabled defaults to true. Admin only. Nothing is fetched here — the first poll is on the schedule, or via Sync now. |
PATCH /api/intel/sources/{id} | Rename, re-point, re-credential or pause: { "name", "url", "collection", "username", "secret", "default_tlp", "enabled" }, all optional. Omitting secret leaves the stored one alone; sending "" clears it. Changing the URL or collection resets the cursor, because the old one means nothing to a new corpus. "enabled": false stops polling and keeps everything already imported. kind is immutable — the cursor and every stored indicator belong to one protocol's idea of identity; delete and re-add instead. |
DELETE /api/intel/sources/{id} | Unsubscribe. The indicators go with it — they were shared under an agreement with that publisher, and some carry a marking that limits holding them. |
TLP is carried through and honoured. default_tlp applies to anything the publisher sends unmarked, and defaults to amber because a collection whose onboarding email says "this is AMBER" and whose objects carry no marking is the ordinary case. Exporting a case that contains such an indicator stamps the bundle with a TLP marking-definition rather than dropping it — without that, the export button would be a way to breach a sharing agreement by accident.
Credentials are stored encrypted with the same key as mailbox passwords. TAXII uses HTTP Basic (or a bearer token when no username is set); MISP uses its API key in a bare Authorization header.
Poll immediately — the same code path the hourly scheduler runs, so a manual poll cannot succeed in a way the scheduled one would not. Returns { "status", "imported", "new", "skipped", "error" }.
It reports failure in the body rather than raising. "It failed, and here is why" is more useful to whoever configured it than a 500, and the reason is also written to the source row so a subscription that quietly stopped delivering is visible instead of inferred from a count that stopped moving.
skipped counts what could not be read. STIX patterning is a whole language; this imports simple equality on one property and refuses the rest rather than guessing — an indicator imported from the first clause of a compound pattern would match something real and wrong, which is worse than one that was never imported. A collection of four hundred compound patterns therefore reports "3 imported, 397 skipped" instead of looking like a collection with three indicators in it.
{ "sources", "enabled", "indicators", "healthy", "problems" } — one boolean a caller can act on rather than four fields each caller re-derives slightly differently.
Single sign-on
OpenID Connect, per organisation. A multi-tenant product cannot have "the" identity provider — each customer brings their own Entra ID, Okta, Google Workspace or Keycloak, and the point of SSO for them is that their directory decides who gets in and their policy decides what counts as authentication. Configuration is admin-only; the two sign-in endpoints are necessarily unauthenticated.
The redirect URI to register at the provider. Its own endpoint because it is the single most common thing to get wrong, and a mismatch produces a provider-side error that says nothing useful.
This organisation's providers. The client_secret is never returned — it authenticates this platform to the customer's IdP, and a value that can be read back is one that leaks.
Add a provider. The discovery document is fetched and checked before anything is stored, so a wrong issuer is found now rather than by the first person who tries to sign in.
curl -X POST "https://api.threatera.eu/api/sso/providers" \ -H "x-api-key: $THREATERA_KEY" -H "content-type: application/json" \ -d '{"name":"Acme Entra ID", "issuer":"https://login.microsoftonline.com/<tenant>/v2.0", "client_id":"…","client_secret":"…", "email_domains":["acme.example"]}'
| Field | Meaning |
|---|---|
email_domains | Required. How somebody typing their address at sign-in is routed here — sign-in happens before anyone is authenticated, so the address is the only thing available to route on. It is also re-checked against whatever the provider asserts: without that, an IdP configured for one customer could assert an address belonging to another and be believed. |
jit_enabled | Whether somebody the IdP authenticates, but who has no account here, gets one created. Off by default — on means every identity in the customer's directory can reach that organisation's intelligence. With it off, SSO authenticates people who were already invited. |
jit_role | What a provisioned account gets. read-only by default: an account created without a human deciding anything should not be able to change what the platform does. |
Update a provider. Omitting client_secret keeps the stored one — an admin editing the domain list should not have to re-enter a value they cannot read back.
Remove a provider. 204. Anyone who only ever signed in through it can no longer sign in at all: an account provisioned by the IdP has no password by construction, so invite them or re-add the provider.
Unauthenticated. Given {"email": "…"}, answers either {"sso": false} or an authorize_url — and nothing else. A chattier answer would let anybody enumerate which organisations use the product and which provider each one runs. false means "use the password form", not "that address does not exist".
The pending flow — state, nonce, PKCE verifier — is held server-side for ten minutes. Nothing about it is carried by, or forgeable from, the browser.
Where the provider sends the browser back. Redirects to the app with a session token in the URL fragment — a query string is written to every access log and proxy along the way and lands in the next request's Referer; a fragment is sent to no server at all. On failure it redirects to the sign-in screen with a readable reason.
Before any of that, the identity token must pass every check OpenID Connect asks for: signature against the provider's published JWKS, iss, aud, exp/iat, and the nonce from this specific sign-in. email_verified must be true — an IdP that lets somebody set an address without proving it would otherwise be an account-takeover primitive.
Passwords still work. Enabling SSO does not disable them. An organisation can have SSO and members who predate it, and a sign-in screen that removes the only other way in is one that locks people out on the day their IdP breaks.
A provisioned account has no password at all — not a weak one and not a random one, which could be reset into existence through the forgot-password flow. It also carries no platform MFA: the IdP performed authentication, including whatever second factor the customer's policy requires.
SAML is not implemented. Every provider worth integrating with speaks OIDC, and SAML is a second implementation with an XML signature stack behind it rather than a variation on this one. It is additive if procurement ever demands it.
ATT&CK coverage
Which MITRE ATT&CK techniques this organisation has actually seen, and which subsystem reported each one. Three parts of the platform already produce technique identifiers and none of them met: capa infers them from a binary's capabilities, Sigma rules carry them as tags, and the sandbox names them for behaviours no single API call demonstrates.
Techniques in the window, grouped by tactic. days defaults to 90.
| Field | Meaning |
|---|---|
sources | Which subsystems reported this technique — capa, sigma, sandbox. More than one is the interesting case: a technique both inferred from the file and observed running is a materially stronger claim than either alone, and it is why this is one view rather than three. |
name | The technique's name, or empty. capa ships the ATT&CK mapping with its rules so its names are used verbatim; a Sigma tag carries only an identifier. An id with no known name is returned bare rather than given an approximate one — a technique named wrongly is one somebody acts on. |
analysed / detonated | The denominator. "Four techniques" without it reads as a statement about the threat landscape rather than about how much has been looked at. |
truncated | The window held more rows than one call reads. The counts are a sample, not a total. |
curl "https://api.threatera.eu/api/attack/coverage?days=365" \ -H "x-api-key: $THREATERA_KEY"
Hunts
A retrohunt answers "does my corpus contain this?" once. A hunt asks the same question of everything that arrives from here on, and tells you when the answer changes. Any authenticated analyst can save one — writing a detection is the job, not an administrative act.
This organisation's saved hunts. Each carries last_run_at, how many files that run read, the running match_count, and last_error — a rule that has stopped compiling says so here rather than quietly matching nothing.
Save a hunt. The rule is compiled before it is stored, so a syntax error is a 400 now rather than a hunt that silently never fires.
curl -X POST "https://api.threatera.eu/api/hunts" \ -H "x-api-key: $THREATERA_KEY" -H "content-type: application/json" \ -d '{"name":"acme-loader","enabled":true, "source":"rule acme_loader { strings: $a = \"acme-c2\" condition: $a }", "include_existing":true}'
include_existing starts the watermark at the beginning rather than at now, so the hunt's first run also walks files you already hold. Off by default — that first run is the expensive one — but without it a newly saved hunt matches nothing until fresh samples arrive, which reads as a feature that does not work.
Hunts are created disabled unless you say otherwise: a rule saved half-written should not start alerting people because the next hour came round.
Run this hunt immediately instead of waiting for the hourly job. Returns { "scanned", "matched", "error" }.
It does exactly what the scheduled run does — same watermark, same one-alert-per-file rule — so triggering it by hand cannot produce a different answer from letting it happen. It exists because a rule you have just written is one you want to see the effect of, and "come back in an hour" is how somebody concludes the feature is broken.
A rule that no longer compiles returns 400 with YARA's own message, naming the line and the token.
What this hunt has caught, newest first — the file's SHA-256, which of the rule's names matched, and when.
A file is reported once per hunt, ever. A re-run over an overlapping window, a job retried after a crash, or the same bytes submitted again by another user cannot produce a second alert.
hunt.match event to whatever webhooks or syslog sinks the organisation has configured — see Event forwarding.Edit the name, the rule or the enabled flag. Editing does not rewind the watermark — re-reading everything the previous version already passed over is expensive and would re-alert on files it already reported. Delete and recreate to start clean.
Removes the hunt and its match history.
Data retention
How long each kind of data is kept. Every period is unset by default, and unset means indefinitely — nothing is ever removed until somebody chooses a number. A nightly job applies the policy; these endpoints only record the intention, and none of them deletes anything. Admin only.
scripts/apply_retention.py acts on it overnight on a connection the API does not have — so an authentication bug here cannot become data destruction, only a number a human sees on a screen before the next run.The current policy, every category with its own bounds and explanation, and pending — how many items each period would remove if the job ran now. A number of days means nothing until it is “and that is 4,182 files”.
| Category | Covers |
|---|---|
files_days | Samples, their static analysis and sandbox reports. The bulk of the disk. Bytes another organisation also holds are kept until that organisation's own policy releases them too. |
emails_days | Reported phishing, headers and bodies — usually the most personal data held here. The stored raw message goes with the row; attachments do not, since they are malware samples with their own analytical life, governed by files_days. |
urlscans_days | Recorded page fetches. Their screenshots are removed from object storage with them. |
watch_hits_days | Individual watchlist matches. The rules themselves are never removed. |
audit_days | Who did what. Cannot be set below 365 days — see the warning below. |
Set the periods. Send null for a category to keep it indefinitely; 0 is rejected rather than guessed at, because it is equally readable as “forever” and as “delete everything”.
curl -X PUT "https://api.threatera.eu/api/retention" \ -H "x-api-key: $THREATERA_KEY" -H "content-type: application/json" \ -d '{"files_days":180,"emails_days":90,"audit_days":730}'
Usage
What your organisation used, by month — the figures behind your invoice. Admin only: it is commercial information about the organisation rather than threat data, and the people working cases have no reason to see what the platform costs.
One month, defaulting to the current one. Pass ?month=2026-07 for a closed month. The end of the range is capped at today, so a month in progress reports the days that have actually happened.
| Field | What it is |
|---|---|
counters | detonations, urlscans, files_added, bytes_added — summed over the month. Recorded as each event happens, so retention deleting the underlying rows later does not change what the month says. A detonation counts when the sandbox slot is committed, since a run that failed still consumed one; a file that another team already submitted is not counted twice. |
gauges | bytes_stored and seats, each with peak, average, days_measured and days_in_range. These are levels rather than events — nobody uses 40 GB, they hold it — so they are sampled once a night. |
series | The raw (day, metric, value) rows, for charting. |
curl "https://api.threatera.eu/api/usage?month=2026-07" \ -H "x-api-key: $THREATERA_KEY"
average divides by days_measured, not by the length of the month. If the nightly sampler missed two nights, the month holds 29 measurements rather than 31, and averaging over 31 would report a storage figure lower than anything you ever actually held. Both counts are returned so the basis of the number is visible instead of assumed — an average over an unstated basis is the one figure nobody can check once the month is closed.Event forwarding
Everything else in this API flows inwards — feeds, TAXII, MISP, a reported-mail mailbox. This is the way out: findings pushed to a webhook, or to a SIEM over syslog in CEF. A SOC does not want a fourteenth console; it wants the finding in the queue it already watches. Admin only, and scoped to one organisation.
Every sink configured for this organisation, each carrying the result of its last delivery — last_status, last_error, failure_count. That is the answer to "is my SIEM actually getting these", which is otherwise indistinguishable from silence. failure_count resets on success, so it says is this broken now rather than has this ever broken.
The webhook signing secret is never returned here. It is shown once, at creation, for the same reason an API key is.
The events a sink may subscribe to. Deliberately a short, closed list — forwarding everything the platform does produces a firehose nobody subscribes to.
| Event | Fires when |
|---|---|
watchlist.hit | A watched value is seen. New hits only — a repeat sighting bumps a counter and is not an interruption, so re-analysing the same archive does not re-alert. |
case.created | An investigation is opened. |
case.status_changed | An investigation changes status. |
detonation.completed | A sandbox run finishes. Carries the persistence and indicator counts. |
file.malicious | A file is judged malicious. |
Add a sink. events may be omitted or empty, which means every event — what wiring up a SIEM usually means, and the alternative is ticking every box today and silently missing each event type added later.
curl -X POST "https://api.threatera.eu/api/event-sinks" \ -H "x-api-key: $THREATERA_KEY" -H "content-type: application/json" \ -d '{"kind":"webhook","name":"SOAR","url":"https://soar.example.com/hooks/threatera"}'
{ "id": 3, "kind": "webhook", "secret": "kQ8…", "events": [] }curl -X POST "https://api.threatera.eu/api/event-sinks" \ -H "x-api-key: $THREATERA_KEY" -H "content-type: application/json" \ -d '{"kind":"syslog","name":"Splunk","host":"siem.internal","port":514,"protocol":"udp","format":"cef"}'
Replace a sink's configuration. The signing secret is not rotated — changing the endpoint should not silently break every receiver that already verifies it.
Remove a sink. 204.
Queue a test event now. Without this, the first confirmation a sink works is the first real alert — the worst possible moment to find a typo'd port, and one that fails silently. The result lands on the sink; re-read it a few seconds later.
A webhook body is JSON, signed with the secret from creation:
x-threatera-event: watchlist.hit x-threatera-signature: t=1786368000,v1=9f86d081884c7d65…
Verify it as you would a Stripe signature. The timestamp is inside the signed material, not merely alongside it — signing the body alone leaves a captured request valid forever, and a replayed "case closed" is worth worrying about. Check the age first, then compare digests in constant time:
import hmac, hashlib, time
def verify(secret, body, header, tolerance=300):
parts = dict(p.split("=", 1) for p in header.split(","))
ts = int(parts["t"])
if abs(time.time() - ts) > tolerance: # reject replays first
return False
expected = hmac.new(secret.encode(), f"{ts}.".encode() + body,
hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, parts["v1"])The body is serialised with sorted keys and no whitespace, so a receiver that re-serialises to verify can reproduce the exact bytes that were signed.
CEF. A syslog sink emits RFC 3164 lines carrying a CEF payload, using CEF dictionary names where one exists (src, fname, fileHash, msg) so a rule written against src works across products. Values are escaped for both CEF layers — including newlines, since a raw one would end the syslog line and let a crafted indicator inject a second event into your SIEM.
CEF:0|Threatera|Threatera|1.0|watchlist.hit|A watched value was seen|8|msg=Watched value seen: evil.example cs1=evil.example cs1Label=value cs2=lookalike domain cs2Label=reason
Endpoint protection admin
Connect an organisation's own Microsoft Defender, CrowdStrike Falcon or Cortex XDR, so that what their endpoints blocked arrives here without anybody deciding to send it. Those files are exactly what cannot be uploaded to a shared service.
EDR_ENABLED is set. The API root passes the same SSRF guard intel subscriptions use, at poll time as well as at save time: a hostname that resolved publicly when it was configured can be repointed at a metadata service afterwards.| provider | status |
|---|---|
defender | Verified. A free Microsoft 365 developer tenant issues real credentials, so this connector is driven end to end against the real API. |
falcon | Written against documentation. CrowdStrike has no public sandbox; API access needs a customer or partner agreement. |
cortex | Written against documentation, for the same reason. |
Each source carries verified in its response, so nobody has to read that table to find out. "We support Falcon" is a claim that should carry its confidence with it rather than be discovered to be a guess by whoever tries it first.
| Method & path | Description |
|---|---|
GET /api/edr/sources | The EDR tenancies connected for this organisation. Readable by an organisation admin — whether their own endpoint protection is answering is their question about their own estate. last_error is the point of the row: "it has not worked since Tuesday" should be answerable here rather than from a worker's log. |
POST /api/edr/sources operator | Connect a tenancy. Body: { "name", "provider", "base_url", "client_id", "secret", "config", "enabled" }. Verified against the vendor before it is stored, and created disabled until it authenticates — an enabled source that has never worked is a job that fails every hour and teaches people to ignore the failure. config carries the non-secret provider-specific part: directory_id for Defender, api_key_id for Cortex. |
POST /api/edr/sources/{id}/check | Is it answering right now? Reports rather than fails: { "reachable", "detail" }, with the reason in plain words. Readable by an organisation admin. |
POST /api/edr/sources/{id}/poll | Poll now instead of waiting for the hourly job, and report what came back. check proves the credential authenticates, which is a smaller claim than "detections arrive" — somebody who has just connected a tenancy should not wait an hour to learn which of those they have. Reports rather than fails, and a paused source still polls when asked: pausing stops the schedule, not the person standing in front of it. |
PATCH /api/edr/sources/{id} | Pause or resume. Body: { "enabled" }. The two directions are not the same power. An organisation admin may pause — their EDR is their production estate and "stop calling it, now" cannot wait for a support ticket — but only an operator may resume, because enabling is the act that makes this platform call a customer's endpoints on a schedule. The cursor is untouched either way, so a paused source resumes where it stopped rather than skipping the gap. |
DELETE /api/edr/sources/{id} operator | Disconnect and forget the credential. The detections it produced are kept — they are a record of what the customer's endpoints blocked, and they did not stop being true because the connection was removed. |
GET /api/edr/detections | What this organisation's endpoints blocked, newest first: hash, filename, the device, the vendor's verdict, when. |
fetch_state says which happened — unsupported means the vendor will not return bytes, not that anything failed — and held says whether you actually have the sample here, which is the difference between knowing about something and having it. When bytes do arrive they go through the ordinary upload path, so deduplication, encryption, analysis, hunts and campaign clustering all happen exactly as for a file somebody dragged in.Trojan:Win32/Wacatac.B!ml is a claim by Microsoft; restating it as this platform's malicious would launder somebody else's finding into one of ours. The cursor also moves to the newest detection actually seen, never to now — the same rule the hunt watermark follows, because moving it to now skips whatever the vendor had not finished indexing when the poll ran.Campaign clusters
/similar answers "what looks like this file?" pairwise, on demand — a question somebody has to already be asking, about a file they have already opened. Clusters are the standing version: your corpus grouped nightly, kept, and reachable from a sample's own page.
7 samples, first seen 3 March until a person writes one.| signal | what it means | what it does |
|---|---|---|
imphash | identical PE import table — almost always the same toolchain | forms a cluster; the cluster's identity is the imphash |
tlsh | byte-level similarity within a conservative distance | attaches a sample; never merges two clusters |
infrastructure | a shared indicator that is rare in your corpus | attaches a sample; never merges two clusters |
seed | the earliest sample — what the others are linked to | not a link, and does not claim to be one |
Only imphash forms clusters, and that restriction is what makes the result usable. Transitive closure over a fuzzy threshold walks the corpus — A near B, B near C, C near D, and A and D end up three times the threshold apart in one group. On any real collection that becomes a single cluster of everything, which reads like a finding and is an artefact of the algorithm.
| Method & path | Description |
|---|---|
GET /api/clusters | Your campaigns, largest first. ?ref=<sha256> narrows to the clusters containing one artefact — the lookup a sample's own page makes. ?include_broad=true adds the ones flagged as too large to be a campaign; they are off by default because listing real-but-useless groupings alongside the rest teaches people to ignore the list. |
GET /api/clusters/{id} | One cluster and the argument for every membership: signal, linked_to, reason. confirmed is null until somebody looks, true if an analyst agreed, false if they threw it out. |
PATCH /api/clusters/{id} | Name it, or write down what it is. Body: { "name", "notes" }. Naming it also keeps it — a rebuild drops clusters it no longer produces, but never one somebody named. |
POST /api/clusters/{id}/members/{member_id}/decision | Agree or disagree with one membership. Body: { "confirmed": true | false | null }. false is not a delete: the membership stays, marked, so the nightly rebuild will not draw the link again and "somebody considered this and said no" survives. |
google.com are not related, and rarity is measured within your own corpus.Watchlist
Every other endpoint here answers a question you asked. A watch rule is a standing question the platform keeps asking on your behalf, and a hit is it interrupting you. Rules are checked on every ingest path — an indicator submitted, a file uploaded, a static analysis, a sandbox detonation, a URL scan, a reported email — and again against the public threat feeds after each nightly sync.
corp.local, so the rule would sit in the list looking like coverage and report nothing forever. Creation is the only moment anyone is paying attention.Everything your organisation is watching, noisiest first. Each rule carries hit_count, sighting_count and last_hit_at.
hit_count is distinct alerts — the same value turning up again in the same place refreshes the existing alert rather than opening another, so re-analysing one archive forty times does not report forty hits. sighting_count is every match, summed from each alert's times. The first answers “how many things matched”; the second answers “how noisy is this”.| Method & path | Description |
|---|---|
POST /api/watchlist | Watch a value. Body: { "kind", "pattern", "match_type", "label", "severity" }. match_type is optional and inferred from how the pattern is written. The response carries backfilled — how many matches were already in your data. |
PATCH /api/watchlist/{id} | Body: { "label", "severity", "enabled" }, all optional — relabel, reprioritise, or mute with "enabled": false. The kind and pattern are immutable — editing one would re-point every hit already recorded against the rule at a value that never produced them. |
DELETE /api/watchlist/{id} | Stop watching and discard the hits. Muting is what "this is too noisy" usually wants: it keeps the alerts and the record that somebody once cared about this value. |
GET /api/watchlist/kinds | The closed vocabularies, so a client never invents one. |
kind | pattern | Matches |
|---|---|---|
domain | acme.com | exactly that name |
domain | *.acme.com | that name and anything under it. Matching is on label boundaries, so evil-acme.com does not fire — which is the point, because that is exactly how a typosquat is built. |
ip | 50.16.16.211 | that address |
ip | 10.0.0.0/8 | any address in the range. 10.4.19.7/24 is accepted as the /24 it obviously means. |
hash | 32, 40 or 64 hex | MD5, SHA-1 or SHA-256. Files are stored by SHA-256, but static analysis computes all three — so a hash copied out of a vendor report works. |
email | ceo@acme.com | that sender address, for the impersonation a domain rule would miss. Watching the domain is a domain rule. |
New rules are checked against what you already hold. Bounded to the most recent 2,000 indicators or files, and the count comes back as backfilled. Without it the first thing anyone does — test the rule against something they know they have — answers with silence, which reads as "it does not work".
Watch several values in one request — the shape a feed import produces, where fifty indicators land at once and all of them want watching.
{ "values": ["cdn-node.example.com", "203.0.113.42", "d41d8cd98f00b204e9800998ecf8427e"],
"severity": "high",
"label": "from the 14 Sep loader feed" }Values, not rules. The kind of each one is inferred, because a caller selecting rows in the indicator list has it already and should not restate it per value; send kind to override for the whole batch. Anything that cannot be placed confidently is refused by name rather than guessed at — storing a rule under a kind that can never match is the silence this feature exists to avoid. severity and label apply to every rule created.
rejected names each refusal with its reason — a URL, for instance, since only domain, ip, hash and email can be watched. That list is the part worth reading: a rule that looks like coverage and reports nothing forever is the failure the whole feature is built to prevent, and a bulk path that folded refusals into a count would be the fastest way yet to create fifty of them.Already watched is not an error here, unlike POST /watchlist's 409. In a batch it is the ordinary case — you select fifty and six are already watched — and refusing would mean working out which six before the other forty-four can be added. The same value sent twice in one request is added once. Capped at 500 values.
The backfill is one pass, not one per rule: bounded by the number of distinct kinds of thing to read rather than by the size of the selection, so hash reads files, ip reads IP indicators, and domain and email share the domain scan — three at the very most. Fifty bounded scans inside one POST is the timeout the bound was there to prevent.
{ "added": 44, "already": 5, "backfilled": 11,
"rejected": [ { "value": "http://example.com/gate.php",
"reason": "only domain, ip, hash, email can be watched — …" } ] }What the watchlist has seen, most recent first. Params: acknowledged (omit for everything, false for the open alerts), rule_id, severity, sort, limit, offset. Returns { "total": n, "results": [...] }.
sort is field:asc or field:desc over last_seen, first_seen, value, times, why (the rule's label), source_kind or severity — ranked, for the same reason as cases.
Each hit carries the value that actually matched — for a suffix or CIDR rule that is not the pattern, and 10.0.0.0/8 matched is not something anyone can act on — plus the rule's rule_label and severity, and an href into the UI.
source_kind | source_ref is |
|---|---|
file | the SHA-256 — from an upload, a static analysis, or a sandbox detonation |
ioc | the indicator value |
urlscan | the scan id |
email | the message id |
feed | the feed key — or intel:<source name> for one of your own subscriptions. See below |
feed is the alert that could not have been a search. Every other hit fires because somebody did something. This one fires because the answer moved while nobody was looking: the nightly sync ran and a value you watch is now on a public blocklist. Finding that by searching requires knowing to search on the morning it became true.Your own TAXII/MISP subscriptions are swept by the same pass, and land in the same list with source_ref prefixed intel:. A CERT naming an address you watch is the same event as abuse.ch doing so — arguably a more urgent one, since it was sent to you specifically — and putting it in a second place to check is how it gets checked less. Only domain and ip are swept: a hash rule is matched at ingest, and no sweep can tell you a hash became bad.
Repeats bump rather than duplicate. The same (rule, source, value) updates last_seen and increments times. Re-analysing one archive full of watched domains produces one alert per value, not forty — which is the difference between a list people read and a list people mute.
Mark a hit as dealt with. Body: { "acknowledged": true }, or false to put it back — reversible on purpose. An alert cleared in a hurry and recognised an hour later as the start of something is ordinary, and a one-way control makes people hesitate before clearing anything, which is how a list stops being read at all.
Acknowledge — or reopen — several alerts at once. Clearing forty was forty round trips, which is the shape of work this list produces: a noisy rule fires on one afternoon's traffic and every hit needs the same answer.
Body: { "ids": [1, 2, 3], "acknowledged": true }. Capped at 500 ids.
{ "acknowledged": 37, "unchanged": 1, "missing": [41, 42] }
It reports what it did. acknowledged counts the rows
actually changed, unchanged those already in that state (a
repeat request is safe and says so), and missing names ids that
were not your organisation's to touch.
{ "open_hits": n, "active_rules": n } — what the badge in the navigation shows. Cheap enough to call on every page load: it is answered by a partial index over unacknowledged hits only.
Mail. A tenant owner can set an alert address under Tenant settings (PATCH /api/tenants/{id}, field alert_email). A daily job then sends one digest per organisation of the hits nobody has acknowledged. It is NULL by default, so an installation with a mail server configured still sends nothing to anyone who did not ask; and it is one address per organisation rather than one per user, because an alert belongs to a team list that outlives whoever wrote the rule.
Reported email
Phishing reports — from a mailbox the platform polls, or uploaded one at a time. An email is analysed as an archive of one: the raw .eml is stored as an ordinary file and each attachment is stored with extracted_from set to the message's hash, exactly as a zip's members are. Attachments therefore appear in /api/files with the same dedup, retention, detonation and deletion as any upload.
unknown, never as a pass.Reported messages, newest first. Params: verdict, q (matches subject, sender or Reply-To), sort, limit, offset. Returns { "total": n, "results": [...] }.
sort is field:asc or field:desc over received_at, sent_at, subject, from_address or verdict — ranked, worst first: as plain text clean sorts before malicious, which is the exact inverse of what somebody working a phishing queue is looking for.
Each row also carries analyst_verdict, analyst_note, decided_by and decided_at — what a person concluded, beside the derived verdict. Null means nobody has looked. Written by PUT /email/messages/verdict.
One message with everything the analysis produced:
| Field | Meaning |
|---|---|
auth | { "spf", "dkim", "dmarc" } as the receiving server recorded them. A key is absent when the header did not state it. |
findings | Why the verdict was reached — each with id, severity, title and detail. A mismatched Reply-To, a display name claiming a brand its address does not, a link whose text and target disagree, a lookalike sender domain. |
attachments | Stored files, by hash — open them under /api/files/{hash}, where provenance.extracted_from points back at this message. |
urls | Every link found. They are not fetched: a phishing message is mostly tracking pixels, and opening one confirms delivery. Scanning one is a deliberate POST /api/urlscan. |
indicators | The hosts recorded as indicators — the sender's domain, the reply path, and every link host. Each has a page under /api/iocs/{value} with its sightings and feed matches, which is what makes a report a starting point rather than a dead end. A host whose TLD is unrecognised never became an indicator and is absent, so a client can link exactly what exists. |
eml_hash | The raw message, downloadable as a file. |
Record the same conclusion about several reported messages at once. Body: { "ids": [...], "verdict", "note" }, where verdict is one of confirmed, false_positive, benign, needs_work — or null to withdraw the decision and return the messages to “nobody has looked”.
verdict on the message is derived from SPF, DKIM, DMARC and the header findings: re-analysis must be free to move it, and it is left alone here. What this writes is analyst_verdict, beside it, with analyst_note, decided_by and decided_at — all four returned by the list and detail endpoints. analyst_verdict: null means nobody has looked, which is not a verdict of unknown and is never backfilled.A reporting mailbox produces the most repetitive queue in the product: forty people forward one campaign inside an hour and every one of them gets the same answer. The note is therefore checked once against the batch — a reason good enough for forty messages is a reason, and asking forty times is how somebody types “fp” forty times. It is required to clear something the headers called malicious or suspicious, exactly as PUT /files/verdict requires it.
Reports what it did rather than that it succeeded: decided counts the rows written and missing names the ids that were not yours to touch. Capped at 500 ids.
{ "decided": 37, "missing": [418, 419, 420] }Analyse a .eml directly — multipart/form-data with a file part, no mailbox involved. Returns the same shape as the detail endpoint above.
409 if this organisation has already analysed a message with the same Message-ID. Available whether or not mailbox polling is enabled: the switch governs automatic collection, not a file an analyst chose to submit.
This organisation's reporting mailbox, plus ingest_enabled — the installation-wide switch, so a client can distinguish "configured" from "configured and never polled".
has_password reports only whether one is set. Reading reports is an ordinary analyst action, but configuring the mailbox is not: it stores a credential to a system Threatera does not operate, and it starts collecting personal data about people outside your organisation. See §7 of the privacy policy.| Method & path | Description |
|---|---|
PUT /api/email/mailbox admin | Create or replace it. Body: { "host", "port", "username", "password", "use_ssl", "folder", "enabled" }. Omit password to keep the stored one; enabled: true without any password is refused rather than failing silently at the next poll. |
POST /api/email/mailbox/test admin | Connect, log in and open the folder. Ingests nothing and marks nothing seen. Returns { "ok": true, "messages_in_folder": n } or { "ok": false, "error": "…" }. |
DELETE /api/email/mailbox admin | Remove the mailbox and its stored credential. Messages already analysed are kept — disconnecting a mailbox is not a request to erase its history. |
Polling is a scheduled job (k8s/mailbox-poll-cronjob.yaml, every 15 minutes), not a request you make. Messages are marked \Seen and left in the mailbox; nothing is deleted and no reply is ever sent.
YARA rules
Manage the YARA ruleset used during analysis at runtime — no redeploy needed. Enabled rules are picked up by the next analysis.
Turn several detection rules on or off in one request, across all three engines — pushing a set live, or silencing a family that has started firing on everything. Each rule is named by its engine and the same reference its own route takes:
{ "rules": [ { "engine": "yara", "ref": "YARAForge:APT28_Downloader" },
{ "engine": "sigma", "ref": "susp_office_macro_autoopen" },
{ "engine": "hunt", "ref": "14" } ],
"enabled": false }engine is yara, sigma or hunt; anything else is a 400. ref is the rule name for yara and sigma and the id for a hunt. Capped at 500 rules.
enabled there is not yours to write: one customer silencing a noisy YARA-Forge family would silence it for all of them. The mute is a row your organisation owns, the rule is untouched, and it survives the next rule-feed sync. changed is rules actually switched; muted is platform rules silenced for you alone. Being told "40 disabled" when 31 of them were muted for your organisation would be a lie about what you changed.Already in the requested state is not counted as a change, so a repeated request is safe and says so. missing names references that were not yours to touch — a rule belonging to another customer and one that does not exist are the same non-match under row-level security.
{ "changed": 2, "muted": 31, "unchanged": 1, "missing": ["yara:no_such_rule"] }List all rules (without source). Each carries name, enabled, created_at, platform and source_feed. Related operations:
| Method & path | Description |
|---|---|
POST /api/yara-rules | Add a rule (source must compile, name unique). Body: { "name", "source", "enabled" } — enabled defaults to true. |
GET /api/yara-rules/{name} | Get one rule, including its source. |
PATCH /api/yara-rules/{name} | Turn a rule on or off. Body: { "enabled": true|false }. For a platform rule this mutes it for your organisation — see below. |
DELETE /api/yara-rules/{name} | Delete one of your own rules. 403 on a platform rule. |
Your rules and the installation's
platform: true marks a rule this installation provides — the two examples a fresh install ships, and everything imported from a rule feed (source_feed names which). One row is shared by every organisation and belongs to none of them.
| On a platform rule | What happens |
|---|---|
a PATCH of {"enabled": false} | Mutes it for your organisation only. The mute is a row you own; the shared rule is untouched and survives the next rule-feed sync. Sending true unmutes. Needs an X-Tenant header when your token could act for more than one organisation — muting happens inside one, and there is no installation-wide answer to give. |
a DELETE | 403, with an answer that says what to do instead. Deleting it would remove it for every customer on the installation. |
enabled means "in force for you", not the stored column. A platform rule you have muted comes back enabled: false even though it is still on for everyone else — reporting the raw column would tell you a rule is running when you switched it off.DELETE a platform rule and it vanished for every organisation on the installation — Postgres consults only a policy's USING clause for a DELETE, and that clause admits the shared rows so they can be read. The migration splits read from write; the 403 refuses it a layer earlier.Scan already-stored files with a YARA rule and return the matches. Validates the rule compiles, then scans up to limit (max 1000) of the most recently ingested files.
curl -X POST https://api.threatera.eu/api/retrohunt \ -H "x-api-key: $THREATERA_KEY" -H "content-type: application/json" \ -d '{"source":"rule x { strings: $a = \"evil\" condition: $a }","limit":500}'
Sigma rules
Where YARA matches what a sample is (its bytes), Sigma matches what it does. Sigma rules are evaluated against sandbox telemetry after every file detonation, and matches appear on the detonation record under sigma.
List all rules with their source, enabled state, and the title / level parsed out of the rule itself. Related operations:
| Method & path | Description |
|---|---|
POST /api/sigma-rules | Add a rule. Body: { "name", "source", "enabled"? }. 400 if the rule is invalid or unsupported, 409 if the name is taken. |
GET /api/sigma-rules/{name} | Get one rule, including its source. |
PATCH /api/sigma-rules/{name} | Turn a rule on or off. Body: { "enabled": true|false }. For a platform rule this mutes it for your organisation only. |
DELETE /api/sigma-rules/{name} | Delete one of your own rules. 403 on a platform rule. |
Rules also carry platform and source_feed, and the platform-rule rules are identical to YARA's — see there for why a shared rule is muted rather than disabled, and why enabled reports what is in force for you rather than the stored column.
curl -X POST https://api.threatera.eu/api/sigma-rules \ -H "x-api-key: $THREATERA_KEY" -H "content-type: application/json" \ -d '{"name":"beacon_over_tls","source":"title: Sample beaconed over TLS\nlevel: high\nlogsource:\n category: network_connection\ndetection:\n sel:\n DestinationPort: 443\n condition: sel"}'
unsupported field modifier 'utf16le' in 'sel' — rather than being stored as enabled and silently never firing.What a rule can match on
After a detonation, the sample's telemetry is converted into events using the standard Sysmon/Sigma field names, and every enabled rule is evaluated against them. Scope a rule to one kind of event with logsource.category. Matches are returned on the detonation record (GET /detonations/{id}) under sigma.
Event categories & fields
| Category | Fields |
|---|---|
process_creation | Image, CommandLine, ExitCode, Stdout, Stderr |
dns_query | QueryName |
network_connection | DestinationIp, DestinationPort, DestinationHostname, Initiated |
http_request | Request |
file_event | TargetFilename, Sha256, FileType, ChangeKind |
Supported rule syntax
| Feature | Supported |
|---|---|
| Value modifiers | contains, startswith, endswith, re, all, cased |
| Conditions | sel, a and not b, 1 of sel_*, all of them |
| Values | Strings (with * / ? wildcards), numbers, and lists (OR-combined) |
Matching is case-insensitive unless the field carries |cased.
{
"sigma": [{
"rule": "beacon_over_tls", "title": "Sample beaconed over TLS",
"level": "high", "matches": 2,
"events": [{"category":"network_connection","DestinationHostname":"example.com","DestinationPort":443}]
}]
}A stored rule that later fails to parse is reported per-rule as { "rule": …, "error": … } instead of failing the whole detonation.
Insights
What needs attention, then what the volumes look like. The two lists it leads with are open cases and unacknowledged watchlist hits — the question somebody opening a dashboard actually has. Totals and breakdowns follow.
{
"totals": {"files":24,"iocs":281,"urlscans":31,"detonations":93,
"yara_rules":4,"sigma_rules":11,"open_cases":3,"open_alerts":7},
"open_cases": [{"id":14,"reference":"CASE-2026-014","title":"…",
"severity":"high","status":"open", … }],
"open_alerts": [{"id":88,"value":"login.acme-sso.com","source_kind":"urlscan",
"severity":"high","why":"our domains","last_seen":"…"}],
"verdicts": {"clean":19,"malicious":3,"suspicious":2,"unknown":2},
"ioc_types": {"domain":244,"ip":36},
"new_files_daily": [{"date":"2026-07-20","count":3}],
"new_email_daily": [{"date":"2026-07-20","count":1}],
"top_iocs": [ … ],
"regulatory": {"available":true,
"summary":{"open":2,"overdue":1,"hours_to_next":-3.2,
"cases":2,"unanchored":1},
"stages":[{"case_id":14,"reference":"CASE-2026-014",
"framework":"nis2","label":"Early warning",
"hours_remaining":-3.2, … }]},
"search_available": true
}open_cases and open_alerts appear twice on purpose: as a count in totals, and as the first few rows. The lists are capped at six each — the totals are not, so a badge and a list can disagree about length without either being wrong. A case row also carries assignee, updated_at and item_count.
Cases are ordered by severity explicitly, not as a string: alphabetically critical sorts before low, so a naive sort looks right and is wrong at exactly the moment it matters. monitoring counts as open — actions taken, watching for recurrence, still somebody's work.
top_files answered "what is biggest", and the biggest thing in a corpus is almost always the benign installer somebody uploads twice a week. totals.analyses counted internal jobs — one per deep upload, so it was a second, noisier copy of the file count. Both are gone from this response; top_iocs stays, and /api/stats still reports analyses.regulatory is the statutory clock, across every open case. The worst five stages plus a summary; the full list is GET /api/regulatory/due. It is guarded the same way the search half is, and for a stronger reason: available: false means the lookup failed, and an empty stages list must not be drawn as "nothing due" when nobody actually asked. summary.unanchored counts open cases with a framework selected and no awareness time recorded — no clock running at all, which is the quietest way to miss a deadline.search_available tells you whether the empty ones are really empty. Four of these fields — verdicts, ioc_types, new_files_daily, top_iocs — are read from the search index; everything else comes from the primary database. If the index is unavailable this response still returns 200 with the database half intact, those four empty, and search_available: false. Do not plot them as zero when it is false: an empty verdict breakdown renders as a clean corpus, which is a confident wrong answer rather than a missing one. The search endpoints answer 503 in the same situation, because unlike this one they have nothing to fall back on.new_email_daily is bucketed on when the platform received the report, not on the message's own Date: header — a forwarded report of a March mail would otherwise land in March and never appear in a 14-day chart, and a header date is attacker-controlled anyway. Both daily series fill gaps with zero, so a client can plot them without reindexing.
Lightweight overview counts: { "files", "iocs", "analyses", "yara_rules", "sigma_rules" }. Every one of them is counted from the per-organisation tables, never the global ones — a count taken from the shared corpus would report every other customer's volume to you.
The nodes and edges around a file or an indicator, for a relationship view. Walks the links the platform already holds: indicators extracted from files, indicators contacted by URL scans, and observed domain → IP resolutions.
Query parameters
| Name | Type | Description |
|---|---|---|
| value required | string | The seed: a SHA-256, or an indicator value. |
| depth | integer | Hops to expand from the seed. 1 (default) or 2. |
{
"nodes": [
{"id":"file:8d0a…a255", "type":"file", "label":"8d0aa1b2c3d4…", "hash":"8d0a…a255"},
{"id":"domain:evil-c2.com","type":"domain", "label":"evil-c2.com", "value":"evil-c2.com"},
{"id":"ip:93.184.216.34", "type":"ip", "label":"93.184.216.34", "value":"93.184.216.34"}
],
"edges": [
{"source":"file:8d0a…a255", "target":"domain:evil-c2.com", "kind":"extracted"},
{"source":"domain:evil-c2.com","target":"ip:93.184.216.34", "kind":"resolved"}
]
}Node ids are type-prefixed (file:, domain:, ip:, urlscan:) so a domain and a file can never collide; label is the display form (file labels are the truncated hash). Edge kind is one of:
kind | Meaning |
|---|---|
extracted | The indicator was extracted from that file. |
contacted | A URL scan contacted that indicator. |
resolved | The domain was observed resolving to that IP. |
Edges are undirected for drawing purposes and de-duplicated, so expanding a domain and then its IP won't emit the same resolution twice. 404 if the seed matches nothing.
The public threat feeds this installation ingests — how many indicators each holds, when it last synced, and whether it is stale.
Readable by any authenticated user. A feed match is what the platform asserts about an indicator, so an analyst weighing that assertion needs to know which sources back it and how current they are. Nothing here is tenant-specific: the corpus is identical for every organisation.
{
"enabled": true, "stale_after_hours": 48,
"healthy": false,
"problems": [{"feed":"urlhaus","health":"stale",
"detail":"stale","stale_since":"2026-08-03T19:42:32Z"}],
"feeds": [{"key":"urlhaus","name":"URLhaus","health":"stale","stale":true,
"indicators":412031,"last_sync":"…","status":"ok","error":null,
"licence":"CC0","homepage":"https://urlhaus.abuse.ch/"}]
}healthy is one boolean a caller can act on rather than four fields each caller re-derives slightly differently: false whenever the subsystem is off, holds no feeds, or any feed is not answering for itself. problems is the same set, spelled out. Per feed, status is what the last sync run reported and health is the judgement about the feed now — a run that finished cleanly two weeks ago is status: "ok" and health: "stale", and the second one is the one to act on.
health | detail says |
|---|---|
ok | —; the feed is not listed in problems at all |
never-synced | has never synced — run scripts/sync_feeds.py. The failure this whole subsystem exists to avoid: a corpus that never synced answers "not in any feed" for everything, with total confidence, and reads exactly like a genuinely clean indicator |
stale | the literal string stale, with the instant in a separate stale_since field |
error | the sync error, or last sync failed |
empty | synced but holds no indicators |
stale_since is a separate field because it is a timestamp, not prose. Interpolating it into detail rendered "last synced 2026-08-03T19:42:32.452553+00:00" beside a table showing the same instant as "8/3/2026, 9:42:32 PM" — two spellings of one fact, one of them unreadable. The client formats it the way it formats every other time.Matches appear on the indicator itself, under enrichment.feeds in GET /api/iocs/{value} — each entry naming the feed, what it calls the threat, a confidence where the source publishes one, and a link back to the source's own page.
counter, first_seen, last_seen) are what your organisation encountered. A feed match is what public research says about the same value. They are kept apart deliberately — merging them would tell you that you saw something you never did.Ingestion is inbound only: the platform downloads the same files every other subscriber gets, on a schedule unrelated to anything you submitted. Nothing about your samples leaves. That is why this is on by default while per-lookup enrichment such as RDAP is not — a lookup query would reveal what you are investigating.
Sources: URLhaus, ThreatFox and Feodo Tracker (abuse.ch, CC0) and the Tor Project's exit list.
Curated detection rulesets this installation imports — how many rules each holds, when it was last refreshed, and whether it is stale. A fresh install ships two example YARA rules; the import adds roughly five thousand.
Readable by any authenticated user, for the same reason /api/feeds is: this is what the platform asserts it can detect, and an analyst reading a clean verdict deserves to know whether the ruleset behind it was refreshed on Tuesday or never.
Each package is imported as one platform-provided rule rather than one rule per detection: the file carries import "pe" / "dotnet" / "hash" and a few rules whose condition references another by name, and splitting it breaks both.
Source: YARA-Forge core package (community YARA rules, deduplicated and licence-filtered upstream). There is deliberately no Sigma feed — of SigmaHQ's 1,377 core rules, 0 use only the event fields this Linux sandbox produces, so importing it would show coverage that cannot fire.
Server-side limits a client should respect before uploading: { "max_upload_size_mb": 100 }.
Read this rather than hard-coding a number — it lets you reject an oversized file instantly instead of streaming it only to be 413'd, and it makes the effective limit visible when debugging a deployment whose config differs from the default.
Active platform announcements (maintenance windows, notices), newest first — visible to every authenticated user.
| Method & path | Description |
|---|---|
POST /api/announcements operator | Post an announcement. Body: { "message", "level": "info"|"warning"|"critical" }. Platform operators only: announcements are installation-wide, shown to every user of every organisation, so there is no such thing as one addressed to your own. |
DELETE /api/announcements/{id} operator | Remove an announcement. |
Account
Session and self-service endpoints used by the web UI.
| Method & path | Description |
|---|---|
POST /api/auth/login | Exchange email + password (plus the TOTP code, once enrolled) for a JWT access token. Body: { "username", "password", "otp"? }. |
POST /api/auth/register | Accept an invite and set a password. Body: { "token", "password" }. The token comes from the invitation mail — there is no open registration, so this is the only way an account comes into existence. A username is accepted and ignored: the address the invitation was sent to becomes the username, or an invite to one person could be redeemed as another. |
GET /api/auth/me | Return the current principal (from the JWT or the service key). |
POST /api/auth/change-password | Change your own password. Body: { "current_password", "new_password" }. The current one is required even though you are already authenticated — it is what makes a stolen session unable to lock the owner out. Clears a pending require-password-change. |
POST /api/auth/forgot-password | Email yourself a reset link. Body: { "username" }. Always 202, whether or not the account exists — anything else would let an unauthenticated caller enumerate who has an account here. |
POST /api/auth/reset-password | Set a new password using a token from that email. Body: { "token", "new_password" }. Single use, expires in an hour, and does not sign you in — you still log in normally, second factor included. |
Account recovery
A user who loses their authenticator, or forgets their password, can be helped back in without anyone touching the database.
Who may do this. A platform operator, for any account. An admin or owner of an organisation, for members of that organisation they out-rank — so an admin cannot reset an owner. Nobody but an operator may reset an operator's credentials, even inside an organisation the operator belongs to.
What an administrator learns: nothing. The password reset link is emailed to the account and is never returned by the API. Accounts are global while organisations are not, so a member of yours may belong to others — if you could read their reset link you could sign in as them and reach those other organisations too. Triggering the mail is the whole of the capability.
Clearing someone's authenticator is safe to hold for the same reason: it creates no credential. The account still needs its password, and enrollment is forced again at the next sign-in. The lost device stops working, which is the point.
| Method & path | Description |
|---|---|
POST /api/users/{id}/reset-mfa | Clear a user's enrolled authenticator so they can enroll a new one. |
POST /api/users/{id}/reset-password | Email that user a reset link. Returns email_sent and, if it failed, email_error — never the link itself. |
POST /api/users/{id}/require-password-change | Require a new password before that account may do anything else. Body: { "required": true|false }. |
require-password-change lets the account sign in and then refuses it every endpoint except /auth/change-password and /auth/me until it complies. Enforced server-side, so an API client cannot carry on with a token it already holds, and read from the account on every request — setting it takes effect immediately rather than at the user’s next login, and so does clearing it. Completing either route to a new password clears it.If mail is down, an operator with cluster access has an out-of-band fallback that prints the link:
kubectl exec deployment/threatera -- python scripts/reset_credentials.py someone@example.com --mfa
kubectl exec deployment/threatera -- python scripts/reset_credentials.py someone@example.com --password
Whoever runs that can use the link, so it is deliberately higher friction than a button: it needs the cluster, which is already more access than any account it could reset. Send the link to the user directly rather than pasting it into a ticket.
Two-factor authentication
Enrolling an authenticator app is a two-step handshake, so a lost secret can't lock an account out halfway: setup mints the secret but leaves MFA off until you prove you can generate a valid code.
| Method & path | Description |
|---|---|
POST /api/auth/mfa/setup | Begin enrollment. Returns { "secret", "otpauth_uri", "qr_svg" } — the QR is a ready-to-render SVG, so a client needs no QR library. 400 if MFA is already enabled. |
POST /api/auth/mfa/activate | Confirm enrollment with a current code. Body: { "otp" }. Turns MFA on and returns a fresh token. 400 for a wrong code, or if setup hasn't been called. |
Once enabled, a login without a valid otp returns 200 with { "mfa_required": true } and no token — a signal to prompt, not an error.
API keys
Create personal API keys for scripting. The plaintext key is shown once on creation — store it securely.
| Method & path | Description |
|---|---|
POST /api/api-keys | Create a key. Body: { "name", "expires_in_days" }. Returns the plaintext key once. |
GET /api/api-keys | List your keys (prefixes only, no plaintext). |
DELETE /api/api-keys/{id} | Revoke a key. |
GET /api/tenants/current/api-keys | Admins and owners. Every key in the organisation, whoever issued it, with the account that created each — prefixes only. |
DELETE /api/tenants/current/api-keys/{id} | Admins and owners. Revoke any key in the organisation. 404 if unknown or already revoked. |
expires_in_days defaults to 365 rather than to never. Pass null for a key that does not expire, deliberately, rather than by omission. A key cannot be created for a service principal: 403, because a key is personal and there would be no person to attribute its actions to.Tenants
A tenant is a customer organisation. Analysis data belongs to exactly one; users reach it through a membership, which carries a role in that tenant (owner, admin, analyst, read-only).
Requests act inside one tenant. Send its TID in the X-Tenant header — optional when you belong to exactly one, required when you belong to several (otherwise 400, because guessing would mean acting in the wrong organisation's data). Asking for a tenant you are not a member of returns 403, and so does asking for one that does not exist: distinguishing the two would let anyone enumerate the customer list.
t_9f4c2a71b8e03d56, from GET /api/me/tenants. It is random, so it names no customer, reveals no ordering and implies no count — safe in a header, a screenshot or a support ticket. It is not a credential: reaching an organisation still requires a membership, so a TID on its own grants nothing. Organisations also carry an internal slug, derived from their name; it is visible only to platform operators, and it is still accepted in X-Tenant so that integrations written before TIDs existed keep working.| Method & path | Description |
|---|---|
GET /api/me/tenants | Tenants you may act in, with your role in each. Drives the tenant switcher. Suspended tenants are omitted. |
GET /api/tenants/current | The tenant this request is acting in, resolved from X-Tenant or your single membership. |
POST /api/tenants/{id}/invites | Invite someone into this tenant; their account is created when they accept, along with their membership. Body: { "email", "role" }. This is how a new customer is onboarded — the first person in a new tenant has no account yet, so adding a member cannot reach them. Returns the link whether or not the email was sent. 409 if the account already exists (add them as a member instead). |
GET /api/tenants/{id}/members | Who can reach this tenant. Readable by any member, not only admins — this list is how a customer verifies no unexpected account has access. Platform operators may read it too: they can already grant and revoke membership, so withholding the read would leave them able to add someone and unable to check the result. |
POST /api/tenants/{id}/members | Grant an existing user access. Requires admin in the tenant, and you cannot grant a role above your own. Body: { "username", "role" }. |
DELETE /api/tenants/{id}/members/{user_id} | Revoke access. 404 if they are not a member; 403 if they out-rank you (an admin cannot remove an owner); 409 if they are the only owner — give someone else that role first, or the organisation would be left unable to administer itself. |
GET /api/tenants operator | Every tenant. Platform operators only — this is the customer list. |
POST /api/tenants operator | Create a tenant. Body: { "slug", "name" }. 409 if the slug is taken. |
PATCH /api/tenants/{id} | Body: { "name", "alert_email", "status", "quota_storage_mb", "quota_uploads_per_day" }, all optional. An owner may set name and alert_email (see the watchlist digest; send null to clear it, omit it to leave it alone) for their own tenant; status and both quotas are operator-only, so a customer cannot lift their own limits. status is one of active, suspended (a billing lapse, expected to be reversed) or deleted (the organisation has left). Only active grants access; neither of the others destroys anything, and both are reversible by setting it back. Erasure is a separate, manual operation. |
GET /api/tenants/{id}/key-provider | Whether this organisation's samples are encrypted under a key you hold. Readable by an admin of the organisation as well as by a platform operator — you are entitled to check your own answer. Returns { "configured", "provider", "config" }; never the credential, and never the wrapped key. |
PUT /api/tenants/{id}/key-provider operator | Point an organisation's key at a KMS the customer runs. Body: { "provider", "config", "secret" } — currently provider: "vault" with config: { "address", "key", "mount", "namespace" } for a HashiCorp Vault transit engine. Verified against the provider before it is stored, so a wrong address fails here rather than on an analyst's first unreadable file. 409 if customer-held keys are not enabled on the installation, if the organisation already uses a provider, or if it already holds samples — enabling a provider protects everything stored from that point and nothing before it, so an existing corpus needs accept_existing_corpus_stays_readable: true to confirm. |
POST /api/tenants/{id}/key-provider/check | Is the customer's KMS answering right now? Reports rather than fails: { "reachable", "detail" }, with the reason in plain words. This is the question an unreadable file turns into, and it is answerable without reading a worker's logs. Readable by an organisation admin or a platform operator. |
DELETE /api/tenants/{id}/key-provider operator | Hand the key back to the platform's own master key. This is a disclosure: afterwards the operator can read every sample the organisation holds. It requires the provider to still be reachable — the key must be unwrapped once in order to be re-wrapped — and it is recorded in the audit log saying exactly that. |
Admin admin
Member management, invites and the audit log — restricted to admins (403 otherwise). Admin means admin of an organisation: the role comes from your membership (owner or admin), and everything here is scoped to the organisation the request is acting in. Platform operators are admins everywhere.
| Method & path | Description |
|---|---|
POST /api/invites | Deprecated — use POST /api/tenants/{id}/invites. This route predates organisations and cannot name one, so it always invites into default; on an installation with more than one that is rarely what was meant. Body: { "email", "role" }. |
GET /api/invites | Pending invites for this organisation, each with its id (never the token). |
DELETE /api/invites/{id} | Revoke a pending invite — the emailed link stops working immediately. 404 if it was already accepted: the account exists, so disable the user instead. |
GET /api/users | The members of this organisation, with their role in it. ?scope=all returns every account on the installation instead and is operator only. |
PATCH /api/users/{id} | Change a member's role here, or enable/disable their account. Body: { "role"?, "active"? }, role being one of owner, admin, analyst, read-only. 404 if they are not a member of this organisation — the same answer as a user id that does not exist, so this cannot be used to enumerate accounts. 403 if they out-rank you or if you try to grant a role above your own. Disabling is refused when the account also belongs to another organisation: that would sign them out of somebody else's, so remove them from yours instead. |
DELETE /api/users/{id} | Remove a member from this organisation (you cannot remove yourself). The account survives, because the same person may work for another customer; it is deleted only when this was its last organisation. 409 if they are the only owner. |
GET /api/audit | A page of audit events, newest first (limit, offset). sort takes field:asc or field:desc over created_at, username, action or target — ordering by user or by action is most of the reason anybody opens this. q is a case-insensitive substring matched against the user, the action and the target together, because the thing you are asking about is as often a hash or a filename as it is a person; total counts what matched rather than the whole log, so the pager describes the result and not the store behind it. |
GET /api/audit/verify | Has this organisation's log been edited since it was written? Each event carries a keyed hash over its own contents and the previous event's, so an edited row, a removed one or two swapped stop reproducing. broken_at is the first event where that happens and reason says which it looks like. Three counts that must not be read as one: checked (covered), unchained (written before a key existed — never backfilled, because hashing them now would certify a history that could already have been edited) and holes (an unchained event after a chained one; nothing is proven across it). Write head down somewhere this platform cannot reach — a chain cannot detect the newest events being deleted, since what remains verifies perfectly, and two readings where head has not moved but events has fallen is that deletion. |
System health operator
Per-dependency status and resource usage. Probes Postgres, Elasticsearch, Redis, MinIO, ClamAV and the detonator concurrently, so the response takes as long as the slowest single probe rather than their sum.
Platform operators only. Nothing here is one organisation's data, but all of it is the installation's — which services exist, which are failing and with what error — which makes it an inventory of the platform to anyone renting it rather than running it.
{
"status": "degraded",
"degraded": ["clamav"],
"services": [
{"service":"postgres","status":"up", "latency_ms":2.1, "version":"16.4"},
{"service":"clamav", "status":"down","latency_ms":5002.0, "error":"probe timed out after 5s"}
]
}status is ok only when every dependency is up, otherwise degraded with the failing ones listed. A dependency that is down is reported, never raised — the whole point is to see which one broke, so a single failure can't take the response down with it.
Each entry always carries service, status and latency_ms. error appears only on failure, and healthy probes add their own detail fields (versions, signature counts, disk usage), so treat the extra keys as per-service rather than fixed.
GET /health instead.degraded can also name something that is not a dependency: job:retention when a scheduled job has stopped running, or unconsumed:analysis when a queue has no worker listening. Every service can be up while the work has quietly stopped, and those are the two ways it happens.Operator only. Whether the scheduled work actually ran, and whether anyone is consuming the task queues — a different question from the one above, and the one that has twice been answered wrongly by a page full of green.
| Field | What it is |
|---|---|
jobs | Every scheduled job with the cadence it is supposed to keep, its last start, last finish and last success, and a state: ok, overdue, failing, abandoned (started, never ended — a pod that went away) or unknown. |
workers | Which queues have a live consumer. A queue in unconsumed_queues is one whose work will never be done, reported whether or not anything is waiting in it. |
queue_depth | Backlog per queue. |
unknown, never ok. It has either never run or not run since this was added, and both are worth finding out. Defaulting an absence to healthy is exactly how a feature ships broken and stays that way — which is what happened to event delivery, discarded on arrival from the day it shipped while the API reported every event as queued./openapi.json and Swagger UI at /docs, where Authorize lets you send your API key or JWT with the requests you try.
Comments
Attributed, free-text notes on a file or an indicator — the analyst context that doesn't fit in a tag. Any authenticated user can comment; the author is recorded from the credential.
The comment thread on one artefact, newest first. Optional
limit(1–500, default 100).fileoriocfile, a SHA-256. Forioc, an IP or domain.Evil-C2.COMandevil-c2.comresolve to the same thread rather than splitting the discussion in two.Comments are addressed by value, not by foreign key — you can annotate something that hasn't been ingested yet, and the thread survives re-ingestion.
400if the target type or value is malformed.Add a comment. Body:
{ "body": "…" }— non-empty after trimming, at most 4000 characters (422otherwise). Returns201with the stored comment.{ "id": 7, "target_type": "ioc", "target_value": "evil-c2.com", "author": "alice", "body": "Seen in the Acme phishing wave, 2026-07-19.", "created_at": "2026-07-28T09:14:52Z" }Delete a comment. Allowed for its author or an admin;
403for anyone else,404if it doesn't exist. Returns204./api/commentslimit1–100, default 20./api/comments/counts{ "file:<sha256>": 3, "ioc:evil-c2.com": 1 }. Lets a list view show badges without one request per row.There is no voting or reputation scoring — that needs a trust model this deployment doesn't have.