Alerts and cases API
These endpoints run the investigation workflow described in Alerts and cases and Escalation and SLAs. The web app’s Alerts and Cases screens use them. Most people never call them directly; this page is for teams that script reports or check behaviour precisely.
Before you start
Section titled “Before you start”Who can call them. These endpoints are for signed-in people, not API keys. Every action
is attributed to a named user, checked against their permissions and the four-eyes rules, and
written to the audit log. An API key gets 401 sign in required.
A person whose account setup isn’t finished (two-factor enrolment or a forced password change
still pending) gets 403 complete account setup first.
Signing in with curl. Most people work alerts and cases in the Alerts and Cases screens, which use these same endpoints. To call them yourself, sign in once and keep the session cookie in a file, exactly as described in Rules API: before you start:
export SP=https://speculapro.example.com
curl -s -c ~/.sp-cookies -H 'Content-Type: application/json' \ -d '{"workspace":"acme-pay","email":"[email protected]","password":"<your password>","totp_code":"123456"}' \ "$SP/api/v1/auth/login"Then add -b ~/.sp-cookies to every request below.
Conventions.
| IDs | UUIDs, for example 0192f3a4-5b6c-7d8e-9f01-23456789abcd |
| Times | RFC 3339 in UTC, for example 2026-10-09T14:05:00Z |
| Money | Integers in minor units of your base currency: 495000 is GHS 4,950.00 |
| Severities and priorities | low, medium, high, critical |
| Bodies | JSON, Content-Type: application/json |
Endpoints at a glance
Section titled “Endpoints at a glance”| Endpoint | Does | Permission | Success |
|---|---|---|---|
GET /api/v1/summary |
Queue and case counts | alerts.view |
200 |
GET /api/v1/alerts |
The alert queue, or alerts by status | alerts.view |
200 |
GET /api/v1/alerts/{id} |
One alert with its subject, hits and transactions | alerts.view |
200 |
POST /api/v1/alerts/{id}/close |
Dismiss an open alert with a disposition | cases.work |
204 |
POST /api/v1/alerts/close |
Dismiss up to 100 open alerts with one disposition and one reason | cases.work |
200 which were closed, which were not |
POST /api/v1/alerts/{id}/case |
Open a case from an alert, or join the subject’s active case | cases.work |
201 {"id": …} |
GET /api/v1/cases |
List cases | cases.view |
200 |
GET /api/v1/cases/{id} |
One case with subject, alerts, hits and timeline | cases.view |
200 |
POST /api/v1/cases/{id}/comments |
Add a note to the timeline | cases.work |
204 |
POST /api/v1/cases/{id}/assign |
Assign or unassign | cases.work (+ cases.approve to assign others) |
204 |
POST /api/v1/cases/{id}/team |
Put the case in a team’s queue, or take it out | cases.work |
204 |
POST /api/v1/cases/{id}/attachments |
Add an evidence file | cases.work |
201 the file’s record |
GET /api/v1/cases/{id}/attachments/{attachment_id} |
Download an evidence file | cases.view and entities.view |
200 the file |
POST /api/v1/cases/{id}/attachments/{attachment_id}/remove |
Remove an evidence file | cases.work (+ cases.approve for someone else’s file) |
204 |
POST /api/v1/cases/{id}/start |
Start work | cases.work |
204 |
POST /api/v1/cases/{id}/escalate |
Escalate one level | cases.work |
204 |
POST /api/v1/cases/{id}/close |
Close, or propose closure | cases.close |
200 {"status": …} |
POST /api/v1/cases/{id}/approve |
Approve a proposed closure | cases.approve |
204 |
POST /api/v1/cases/{id}/reject |
Send a proposed closure back | cases.approve |
204 |
POST /api/v1/cases/{id}/reopen |
Reopen a closed case | cases.approve |
204 |
Errors
Section titled “Errors”Errors use Problem Details: a JSON object with
status, title and detail.
| Status | Meaning | Examples |
|---|---|---|
401 |
Not signed in, or called with an API key | sign in required |
403 |
Missing permission, or a four-eyes rule | missing permission cases.close; the person who proposed the closure cannot approve it; only supervisors can assign cases to others; evidence files hold customer details; entities.view is needed to open them |
404 |
No such alert or case in your workspace. Another workspace’s records always look like this | not found |
409 |
The action doesn’t fit the current status | only open alerts can be closed; case is closed; already at the highest escalation level; nothing awaiting approval; no such team in this workspace; a case holds at most 50 files |
413 |
An evidence file larger than the request limit | request body too large |
422 |
The request is invalid: an unknown disposition, a note too short, a limit out of range, an evidence file of a type or content that isn’t accepted | Lists each problem, or says what is wrong with the file |
5xx |
Something failed on the server | Something went wrong on our side. Reference: …. Quote the reference to support; see When the server fails |
A refused request changes nothing.
Summary
Section titled “Summary”GET /api/v1/summary
Section titled “GET /api/v1/summary”Counts for a dashboard. Permission: alerts.view.
curl -s -b ~/.sp-cookies "$SP/api/v1/summary"{ "open_alerts": 37, "critical_alerts": 2, "active_cases": 14, "sla_at_risk": 3, "pending_approval": 1, "my_cases": 4, "open_exposure_minor": 186240000, "false_positive_rate_30d": 0.62}Illustrative figures.
| Field | Meaning |
|---|---|
open_alerts |
Alerts with status open |
critical_alerts |
Open alerts with severity critical |
active_cases |
Cases not closed |
sla_at_risk |
Active cases whose SLA deadline is less than an hour away or already passed |
pending_approval |
Cases waiting for a second person to approve closure |
my_cases |
Active cases assigned to you |
open_exposure_minor |
Total exposure of open alerts, in minor units |
false_positive_rate_30d |
Of the alerts closed in the last 30 days, the share (0 to 1) dismissed as false_positive. Alerts closed with their case count in the total. 0 when nothing was closed |
Alerts
Section titled “Alerts”The alert object
Section titled “The alert object”| Field | Type | Meaning |
|---|---|---|
id |
UUID | The alert |
subject_type |
string | customer, account or counterparty |
subject_id |
UUID | The subject |
subject_name |
string | The customer’s name, the account’s masked identifier, or the counterparty’s name or masked identifier |
status |
string | open, in_case or closed |
severity |
string | The highest severity of its hits |
score |
number | The highest score of its hits |
hit_count |
integer | How many hits it holds |
kind |
string | aml (post-event rules) or fraud (real-time rules and degraded decisions). sanctions and other are reserved. See AML and fraud alerts |
rule_keys |
array | The distinct rules that contributed |
exposure_minor |
integer | Total value of the transactions behind it, in minor units |
summary |
string | The explanation of the most recent hit |
first_hit_at, last_hit_at |
time | When the activity started and was last seen |
case_id, case_number |
UUID, string | The case it is in; omitted if none |
disposition |
string | Set once closed: false_positive, no_action, duplicate or escalated_to_case; omitted otherwise |
ai_recommendation |
string | The AI assistant’s current advice: likely_false_positive, needs_investigation or insufficient_information. Present only when it has advised on the alert since the alert’s last hit. Advice only: it changes nothing about the alert |
held |
boolean | true when the alert always calls for a person’s own look: its kind is sanctions, its severity is critical, or its subject has a pep, pep_rca or sanctioned designation in force (for an account, on the customer who owns it). Worked out on each read, never stored. A held alert is refused by POST /api/v1/alerts/close and is never advised as likely_false_positive by the assistant; ai_recommendation is omitted where a stored review says so |
disposition_note |
string | The rationale given when the alert was dismissed. Omitted if none, for alerts closed with their case, and for callers without entities.view (it can name the customer) |
closed_by |
string | Display name of the person who closed it. For an alert closed with its case, the person who closed the case, or who approved its closure |
closed_at |
time | When it was closed |
GET /api/v1/alerts
Section titled “GET /api/v1/alerts”| Query | Values | Default |
|---|---|---|
status |
open, in_case, closed, all |
open |
severity |
low, medium, high, critical |
All |
ai |
likely_false_positive, needs_investigation, insufficient_information: only alerts the AI assistant currently advises this way. none: alerts with no current advice (never advised, advised before their latest hit, or advised likely_false_positive and now held) |
All |
limit |
1 to 200 | 50 |
before |
A time: the last_hit_at of the last alert on the previous page |
— |
before_id |
With before: the id of that same last alert |
— |
Ordering and paging.
openis the work queue, ordered by risk: severity (critical first), then score, then most recent activity. It returns the toplimitalerts and ignoresbefore; work the queue from the top and ask again.in_case,closedandallare ordered newest activity first (ties broken byid). To get the next page, pass thelast_hit_atand theidof the last alert you received, asbeforeandbefore_id. An empty array means there are no more.
# The 20 riskiest open alertscurl -s -b ~/.sp-cookies "$SP/api/v1/alerts?limit=20"
# Closed alerts, second pagecurl -s -b ~/.sp-cookies "$SP/api/v1/alerts?status=closed&before=2026-10-08T17:42:10Z&before_id=0192f3a4-5b6c-7d8e-9f01-23456789abcd"[ { "id": "0192f3a4-5b6c-7d8e-9f01-23456789abcd", "subject_type": "customer", "subject_id": "0192f3a0-1111-7000-8000-000000000001", "subject_name": "Kwabena Asante", "status": "open", "severity": "high", "score": 70, "hit_count": 3, "rule_keys": ["gh.split_cash_in_agents", "gh.wallet_sweep_to_bank"], "exposure_minor": 2940000, "summary": "Cash-ins via 6 different agents totalling GHS 29,400 in 24h.", "first_hit_at": "2026-10-08T09:12:44Z", "last_hit_at": "2026-10-08T17:42:10Z", "kind": "aml" }]Illustrative data.
GET /api/v1/alerts/{id}
Section titled “GET /api/v1/alerts/{id}”The alert object, plus:
| Field | Meaning |
|---|---|
subject |
The subject: type, id, external_id, name, detail (segment and KYC tier for a customer; account type and provider for an account), risk_rating, and designations currently in force (for example ["pep"]) |
hits |
Every hit, newest transaction first (see below) |
Each hit:
| Field | Meaning |
|---|---|
id, rule_id, rule_key, rule_name, rule_version |
Which rule version fired |
severity, score |
From that version |
explanation |
The rule’s explanation, with the values filled in |
features |
The feature values the rule saw, for example {"agents_24h": 6, "cash_in_24h": 29400} |
params |
The parameters in force |
created_at |
When the hit was recorded |
transaction |
id, external_id, occurred_at, direction, type, channel, amount_minor, currency, amount_base_minor and counterparty. Omitted for a moment after the hit if the transaction is still being stored |
Customer and counterparty names are shown as sent to SpeculaPro, to people allowed to see
them (see Who sees names). Account and phone identifiers are only ever
shown masked (for example +233•••••••67); their encrypted full values are not returned by
these endpoints.
Who sees names
Section titled “Who sees names”Names identify real people, so the alert and case endpoints return them only to callers with the
entities.view permission (in the default roles: analysts, senior analysts, the MLRO, rule
authors and auditors). Anyone else, such as a rule approver or an administrator checking alert
volumes, gets a pseudonym instead:
| Field | With entities.view |
Without |
|---|---|---|
subject_name (alerts and cases) |
Kwabena Asante |
Customer 000001: the subject type plus the last six characters of subject_id |
Case title |
Kwabena Asante: Cash-ins via 6 different agents… |
Customer 000001: Cash-ins via 6 different agents… |
subject.name |
The name | The pseudonym |
subject.external_id, subject.detail |
As stored | Empty |
A hit’s transaction.counterparty |
The counterparty’s name or masked identifier | Empty |
The pseudonym is stable: the same customer always gets the same one, so a reviewer can see that three alerts concern one person without learning who it is.
Full identifiers in clear (entities.view_pii) are a separate, stronger permission; see
Roles and permissions.
POST /api/v1/alerts/{id}/close
Section titled “POST /api/v1/alerts/{id}/close”Dismisses an open alert. Permission: cases.work.
| Field | Required | Values |
|---|---|---|
disposition |
Yes | false_positive, no_action, duplicate |
note |
No | Up to 2,000 characters |
curl -s -b ~/.sp-cookies -X POST "$SP/api/v1/alerts/0192f3a4-5b6c-7d8e-9f01-23456789abcd/close" \ -H 'Content-Type: application/json' \ -d '{"disposition":"false_positive","note":"Monthly supplier collections, same 8 contractors since 2023; matches declared business."}'Returns 204. 409 only open alerts can be closed if the alert is in a case or already closed.
POST /api/v1/alerts/close
Section titled “POST /api/v1/alerts/close”Dismisses several open alerts in one request, with one disposition and one reason written on
each. It is a person’s decision about each alert, taken together; the
AI assistant never calls it. Permission: cases.work.
| Field | Required | Values |
|---|---|---|
ids |
Yes | 1 to 100 alert IDs |
disposition |
Yes | false_positive, no_action, duplicate |
note |
Yes | 10 to 2,000 characters: why these alerts are closed together |
curl -s -b ~/.sp-cookies -X POST "$SP/api/v1/alerts/close" \ -H 'Content-Type: application/json' \ -d '{"ids":["0192f3a4-5b6c-7d8e-9f01-23456789abcd","0192f3a4-5b6c-7d8e-9f01-23456789abce"], "disposition":"false_positive", "note":"Both are cash-outs at the registered agent in Madina; device confirmed as the agent's own."}'Returns 200 with what became of each alert:
{ "closed": ["0192f3a4-5b6c-7d8e-9f01-23456789abcd"], "refused": { "0192f3a4-5b6c-7d8e-9f01-23456789abce": "alerts about politically exposed or sanctioned persons, their relatives and their accounts are closed one at a time" }}An alert in refused is left exactly as it was. The reasons are:
| Reason | When |
|---|---|
sanctions alerts and critical alerts are closed one at a time |
The alert’s kind is sanctions, or its severity is critical |
alerts about politically exposed or sanctioned persons, their relatives and their accounts are closed one at a time |
The alert’s subject has a pep, pep_rca or sanctioned designation in force, or is an account whose owner has one |
no longer open |
The alert is in a case or already closed |
not found |
No alert with that ID in this workspace |
The first two are the alerts that carry held: true. Close those with POST /api/v1/alerts/{id}/close, one at a time.
- A request is all or nothing for the alerts it can close: they are closed in one transaction.
A refusal is not an error; the status is still
200. - Each alert closed gets its own
alert.closeentry in the audit log, with the disposition, the note andbulk(how many IDs the request named). 422for an emptyids, more than 100 IDs, anoteshorter than 10 characters or an unknowndisposition.
POST /api/v1/alerts/{id}/case
Section titled “POST /api/v1/alerts/{id}/case”Opens a case from an open alert. Permission: cases.work. No body.
- If the subject already has an active case of the same kind (
amlorfraud), the alert joins it. An AML alert never joins a fraud case, nor the other way round. - If it joins a case whose priority is lower than the alert’s severity, the case’s priority is raised to match, and its SLA deadline is brought forward if the new priority’s deadline is sooner. Priority is never lowered this way.
- Otherwise a new case is created (number
SP-<year>-<six digits>, the year in UTC; priority from the alert’s severity, assigned to you, SLA deadline set). - A new case follows the first escalation policy that fits
it, if any. The policy can put it in a team’s queue and give it that level’s deadline. A policy
that starts above level 1 opens the case with status
escalated, at that level, unassigned. - If the alert is already in a case, nothing changes and that case’s ID is returned.
curl -s -b ~/.sp-cookies -X POST "$SP/api/v1/alerts/0192f3a4-5b6c-7d8e-9f01-23456789abcd/case"{ "id": "0192f3b0-2222-7000-8000-0000000000aa" }Returns 201 in all three situations. 409 alert is closed for a dismissed alert.
The case object
Section titled “The case object”| Field | Type | Meaning |
|---|---|---|
id |
UUID | The case |
number |
string | SP-2026-000123 |
title |
string | Subject name and the first alert’s summary |
kind |
string | aml or fraud, from the alert it was opened from |
status |
string | open, in_progress, escalated, pending_approval, closed |
priority |
string | low to critical |
score |
number | The highest score of its alerts |
subject_type, subject_id, subject_name |
As for alerts | |
assignee_id, assignee_name |
Omitted when unassigned | |
escalation_level |
integer | 1 to 3 |
sla_due_at |
time | The current deadline |
sla_breached_at |
time | When the current deadline was recorded as missed. Omitted while the deadline hasn’t passed, and cleared whenever the case gets a new deadline (an escalation, a reopening) |
team_id, team_name |
UUID, string | The team whose queue the case is in; omitted when it is in none |
policy_name |
string | The escalation policy the case follows; omitted if none fitted when it was opened, or the policy was deleted since |
disposition |
string | Set when closed, or proposed while pending_approval |
proposed_by, proposed_by_id |
string, UUID | The name and user ID of who proposed the closure; only while pending_approval. That person can’t approve it |
created_at, updated_at, closed_at |
time | closed_at only when closed |
ai_suggestion |
string | What the AI assistant last suggested for the case: likely_false_positive, consider_report, keep_investigating or insufficient_information. Advice only. Present only while it stands: omitted when the assistant never summarised the case, when its latest summary predates suggestions, when the case has had an alert, a hit or a timeline entry since that summary, and, for likely_false_positive, once the case is held: critical, a sanctions case, about a held subject, or holding a held alert |
alert_count |
integer | Alerts in the case |
exposure_minor |
integer | Their total exposure, in minor units |
GET /api/v1/cases
Section titled “GET /api/v1/cases”| Query | Values | Default |
|---|---|---|
status |
active (everything not closed), pending_approval, closed, all |
active |
assignee |
me, unassigned |
Anyone |
priority |
critical, high, medium, low |
Any |
team |
mine (the queues of the teams you belong to), none (in no team’s queue), or a team’s ID from GET /api/v1/teams |
Any queue |
q |
Up to 128 characters. Matches part of the case number, the title or the subject’s name, ignoring case. For a caller without entities.view, matches the case number only |
None |
limit |
1 to 200 | 50 |
cursor |
The Next-Cursor header of the previous page |
First page |
The order depends on status:
status |
Order |
|---|---|
active, pending_approval |
Soonest SLA deadline first (cases without a deadline last), then priority, critical first |
closed |
Most recently closed first (closed_at, newest first) |
all |
Most recently updated first (updated_at, newest first) |
Paging
Section titled “Paging”When more cases match, the response carries a Next-Cursor header. Send its value back as
cursor, with the same status, assignee, priority, team and q, to get the next page. The last
page has no Next-Cursor header. Cases that share a deadline or timestamp are never skipped or
repeated between pages.
The cursor is opaque: pass it back unchanged and don’t build one yourself. A cursor that can’t be
read returns 422 with cursor is invalid; start again without it.
# My active casescurl -s -b ~/.sp-cookies "$SP/api/v1/cases?assignee=me"
# Escalated or new cases nobody has picked upcurl -s -b ~/.sp-cookies "$SP/api/v1/cases?assignee=unassigned"
# Unassigned cases in my teams' queuescurl -s -b ~/.sp-cookies "$SP/api/v1/cases?team=mine&assignee=unassigned"
# Closures waiting for a second approvercurl -s -b ~/.sp-cookies "$SP/api/v1/cases?status=pending_approval"
# Closed critical cases mentioning a customercurl -s -b ~/.sp-cookies -G "$SP/api/v1/cases" \ --data-urlencode "status=closed" --data-urlencode "priority=critical" --data-urlencode "q=Mensah"Reading every closed case, 200 at a time:
cursor=""; n=1while :; do curl -s -b ~/.sp-cookies -D headers.txt -G "$SP/api/v1/cases" \ --data-urlencode "status=closed" --data-urlencode "limit=200" \ --data-urlencode "cursor=$cursor" > "cases-$n.json" cursor=$(grep -i '^next-cursor:' headers.txt | cut -d' ' -f2 | tr -d '\r') [ -n "$cursor" ] || break n=$((n + 1))doneGET /api/v1/cases/{id}
Section titled “GET /api/v1/cases/{id}”The case object, plus:
| Field | Meaning |
|---|---|
subject |
As for an alert |
alerts |
Every alert in the case, newest activity first |
hits |
Every hit of those alerts, with transactions, as for an alert |
attachments |
The case’s evidence files, oldest first, as file objects. Removed files stay in the list with removed_at set |
timeline |
Every event, oldest first: id, type, actor_type (user or system), actor_name, payload, created_at |
closure_needs_approval |
true if closing the case now would go to a second approver: the case is high or critical, your workspace requires approval for every closure, or the case’s escalation policy does |
Timeline event types are listed in The case timeline. For example, an automatic link:
{ "id": "0192f3c1-…", "type": "alert_linked", "actor_type": "system", "payload": { "alert_id": "0192f3c0-…", "automatic": true, "rules": ["core.fan_in"], "priority": "critical" }, "created_at": "2026-10-09T08:03:11Z"}priority is present only when the new activity raised the case’s priority; it is the new
priority. When a person adds a more severe alert, the raise is recorded instead as a
status_changed event with {"priority": "critical", "reason": "a higher-severity alert joined"}.
Events about escalation policies, team queues and deadlines:
type |
actor_type |
payload |
|---|---|---|
created |
user |
number, from_alert, and, when a policy fitted, policy (its name) and level (the starting level) |
team_assigned |
user |
team_id and team (its name) when the case was put in a queue; team_id: null when it was taken out |
escalated |
user |
from_level, to_level, priority, reason, and, for a case that follows a policy, policy and the new level’s team_id if it has one |
sla_breached |
system |
due_at (the deadline that was missed) and level |
escalated |
system |
As above, with "auto": true and "reason": "deadline passed": the policy escalated the case after a missed deadline |
{ "id": "0192f3c2-…", "type": "escalated", "actor_type": "system", "payload": { "from_level": 2, "to_level": 3, "priority": "critical", "policy": "Politically exposed persons", "auto": true, "reason": "deadline passed" }, "created_at": "2026-10-10T08:04:00Z"}Missed deadlines are looked for once a minute, so sla_breached follows the deadline by up to a
minute or so. Each deadline is recorded once.
POST /api/v1/cases/{id}/comments
Section titled “POST /api/v1/cases/{id}/comments”| Field | Required | Values |
|---|---|---|
body |
Yes | 1 to 10,000 characters |
Allowed on any case, including closed ones. Returns 204.
POST /api/v1/cases/{id}/assign
Section titled “POST /api/v1/cases/{id}/assign”| Field | Required | Values |
|---|---|---|
assignee_id |
Yes | A user’s ID, or null to unassign |
- Assigning yourself or unassigning needs
cases.work. Assigning someone else also needscases.approve; otherwise403. - The assignee must be an active user in your workspace; otherwise
409. - Assigning an
opencase moves it toin_progress. 409on a closed case.
curl -s -b ~/.sp-cookies -X POST "$SP/api/v1/cases/$CASE/assign" \ -H 'Content-Type: application/json' -d '{"assignee_id": null}'POST /api/v1/cases/{id}/team
Section titled “POST /api/v1/cases/{id}/team”Puts the case in a team’s queue, or takes it out. Permission: cases.work.
| Field | Required | Values |
|---|---|---|
team_id |
Yes | A team’s ID, or null to take the case out of any queue |
- The assignee keeps the case; its level, status and deadline don’t change.
- The team’s active members are notified.
409 no such team in this workspacefor an unknown team;409on a closed case.- Being in a queue restricts nothing: anyone with
cases.workcan still take the case.
curl -s -b ~/.sp-cookies -X POST "$SP/api/v1/cases/$CASE/team" \ -H 'Content-Type: application/json' -d '{"team_id":"0192f3d0-aaaa-7000-8000-000000000001"}'Returns 204. Teams themselves are managed with the Escalation API.
POST /api/v1/cases/{id}/start
Section titled “POST /api/v1/cases/{id}/start”No body. Moves an open or escalated case to in_progress and, if nobody is assigned, assigns
it to you. 409 from any other status. Returns 204.
POST /api/v1/cases/{id}/escalate
Section titled “POST /api/v1/cases/{id}/escalate”| Field | Required | Values |
|---|---|---|
reason |
Yes | 3 to 2,000 characters |
Raises the level by one and the priority by one step, unassigns the case, sets it to
escalated and restarts the SLA deadline at the new priority’s target. 409 if the case is at
level 3, closed, or pending approval. Returns 204.
For a case that follows an escalation policy, the case
moves to the queue of the team the policy names for the new level (or out of any queue, if it
names none), and the deadline is that level’s time when the policy sets one. A policy with
auto_escalate does the same by itself when a deadline is missed.
curl -s -b ~/.sp-cookies -X POST "$SP/api/v1/cases/$CASE/escalate" \ -H 'Content-Type: application/json' \ -d '{"reason":"Possible mule network: 41 senders in 3 days, swept to a bank account within hours."}'POST /api/v1/cases/{id}/close
Section titled “POST /api/v1/cases/{id}/close”Permission: cases.close.
| Field | Required | Values |
|---|---|---|
disposition |
Yes | false_positive, true_positive_no_report, report_filed, customer_exited, duplicate, other |
note |
Yes | 3 to 10,000 characters. The rationale; regulators read this |
curl -s -b ~/.sp-cookies -X POST "$SP/api/v1/cases/$CASE/close" \ -H 'Content-Type: application/json' \ -d '{"disposition":"report_filed","note":"STR filed with FIC on 9 Oct 2026, ref recorded on timeline. Funds traced to 3 bank accounts."}'{ "status": "pending_approval" }"status": "closed": a low or medium case closed at once. Its open alerts are closed withescalated_to_case."status": "pending_approval": a high or critical case, any case when your workspace requires four eyes for all closures, or a case whose escalation policy hasfour_eyes_close. Nothing else closes until someone approves.409if the case is already closed or pending approval.
POST /api/v1/cases/{id}/approve and /reject
Section titled “POST /api/v1/cases/{id}/approve and /reject”Permission: cases.approve.
| Field | Required | Values |
|---|---|---|
note |
No | Up to 2,000 characters |
- Approve closes the case with the proposed disposition and closes its alerts.
- Reject returns it to
in_progress, same assignee, and clears the proposed disposition. 403if you proposed the closure yourself.409if nothing is awaiting approval.
Both return 204.
POST /api/v1/cases/{id}/reopen
Section titled “POST /api/v1/cases/{id}/reopen”Permission: cases.approve.
| Field | Required | Values |
|---|---|---|
reason |
Yes | 3 to 2,000 characters |
Moves a closed case to in_progress, assigns it to you, clears its disposition and restarts
the SLA deadline. 409 if the case isn’t closed. Returns 204.
Evidence files
Section titled “Evidence files”The documents an investigation collects, kept with the case. The screens are described in Evidence files.
The file object
Section titled “The file object”| Field | Meaning |
|---|---|
id |
The file’s ID |
file_name |
The name it was added under, without any folder path |
content_type |
Set from the extension, for example application/pdf |
size_bytes |
Size in bytes |
sha256 |
Hex SHA-256 of the file, recorded when it was added |
description |
The note given when it was added, if any |
uploaded_by_id, uploaded_by |
Who added it |
created_at |
When it was added |
removed_at, removed_by, remove_reason |
Present once the file was removed: its record stays, its content is gone |
reports |
How many reports the file goes with |
For callers without entities.view, file_name is replaced by File 1.pdf, File 2.jpg and so
on (the position in the list and the real extension), description and remove_reason are left
out, and the attachment_added and attachment_removed timeline events carry no file_name or
reason.
POST /api/v1/cases/{id}/attachments
Section titled “POST /api/v1/cases/{id}/attachments”Permission: cases.work. Send the file itself as the request body, with
Content-Type: application/octet-stream. This is not a multipart form.
| Query parameter | Required | Values |
|---|---|---|
name |
Yes | The file’s name with its extension, up to 300 characters. Any folder path is dropped, and names over 200 characters are shortened |
description |
No | What the file is, up to 500 characters |
Rules:
- Types:
pdf,png,jpg,jpeg,webp,txt,csv,eml,msg,docx,xlsx. - Size: 1 byte to 10 MB (10,485,760 bytes).
- Count: 50 files a case, not counting removed ones.
- Content: the first bytes must be what the extension says.
docxandxlsxmust be Word and Excel packages ([Content_Types].xmland parts underword/orxl/),msgby its signature,txt,csvandemlas plain text. Files are not scanned for malware. - Status: not on a closed case.
curl -s -b ~/.sp-cookies -X POST \ -H 'Content-Type: application/octet-stream' \ --data-binary @passport-data-page.pdf \ "$SP/api/v1/cases/$CASE/attachments?name=passport-data-page.pdf&description=Received%20by%20email%209%20October"Returns 201 with the file object:
{ "id": "0192f3d0-…", "file_name": "passport-data-page.pdf", "content_type": "application/pdf", "size_bytes": 482113, "sha256": "9f2c…e41a", "description": "Received by email 9 October", "uploaded_by_id": "0192f1aa-…", "created_at": "2026-10-12T09:14:02Z", "reports": 0}The file is encrypted with the workspace’s key, and an attachment_added event is added to the
timeline with attachment_id, file_name, size_bytes and sha256.
| Status | When |
|---|---|
409 |
the case is closed; reopen it to add a file; a case holds at most 50 files |
413 |
The body is larger than the limit |
422 |
the file needs a name; … files are not accepted; use one of .pdf .png .jpg .jpeg .webp .txt .csv .eml .docx .xlsx .msg; the file is empty; the file is larger than 10 MB; the content of this file is not what a pdf file holds; the note is longer than 500 characters |
GET /api/v1/cases/{id}/attachments/{attachment_id}
Section titled “GET /api/v1/cases/{id}/attachments/{attachment_id}”Permission: cases.view, and entities.view as well (403 without it). Returns the file’s
bytes as they were added, after checking them against the recorded SHA-256 ($FILE below is the file’s id). Bytes that don’t
match are never returned; the request fails with a 5xx.
The answer is always a file to save, never something a browser displays:
| Header | Value |
|---|---|
Content-Type |
application/octet-stream, whatever the file’s type |
Content-Disposition |
attachment; filename=… with the file’s name |
X-Content-SHA256 |
Hex SHA-256 of the file, to check your copy against |
X-Content-Type-Options |
nosniff |
Content-Security-Policy |
sandbox; default-src 'none' |
Cache-Control |
no-store |
curl -s -b ~/.sp-cookies -OJ -D headers.txt \ "$SP/api/v1/cases/$CASE/attachments/$FILE"404 for a file that doesn’t exist in this case or was removed. Every download is written to
the audit log as case.attachment_download; it is not on the timeline. Files of a closed case
can be downloaded. A support session is always refused.
POST /api/v1/cases/{id}/attachments/{attachment_id}/remove
Section titled “POST /api/v1/cases/{id}/attachments/{attachment_id}/remove”Permission: cases.work. You must also be the person who added the file, or hold
cases.approve.
| Field | Required | Values |
|---|---|---|
reason |
Yes | 3 to 500 characters |
Deletes the file’s content for good and keeps its record: the file stays in attachments with
removed_at, removed_by and remove_reason. The file is taken out of any draft report that
named it, and an attachment_removed event is added to the timeline with attachment_id,
file_name, sha256 and reason. Returns 204.
| Status | When |
|---|---|
403 |
only the person who added a file, or someone who approves closures, can remove it |
404 |
No such file in this case, or it was already removed |
409 |
the case is closed; the file goes with report STR-…, which is no longer a draft |
How to work an alert end to end with curl
Section titled “How to work an alert end to end with curl”An illustrative run for one analyst and one MLRO.
-
Find the riskiest open alert and read it:
Terminal window curl -s -b ~/.sp-cookies "$SP/api/v1/alerts?limit=1"curl -s -b ~/.sp-cookies "$SP/api/v1/alerts/$ALERT" -
Open a case from it and keep the ID:
Terminal window CASE=$(curl -s -b ~/.sp-cookies -X POST "$SP/api/v1/alerts/$ALERT/case" | jq -r .id) -
Record what you checked as you go:
Terminal window curl -s -b ~/.sp-cookies -X POST "$SP/api/v1/cases/$CASE/comments" \-H 'Content-Type: application/json' \-d '{"body":"Checked 12-month history: pattern started 3 weeks ago, no prior cases."}' -
Escalate if it is beyond you, or close it if you hold
cases.close. -
If the close returned
pending_approval, the MLRO lists pending closures, reads the case and approves or rejects it from their own session.