Skip to content

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.

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:

Terminal window
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
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 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.

Counts for a dashboard. Permission: alerts.view.

Terminal window
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
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
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.

  • open is the work queue, ordered by risk: severity (critical first), then score, then most recent activity. It returns the top limit alerts and ignores before; work the queue from the top and ask again.
  • in_case, closed and all are ordered newest activity first (ties broken by id). To get the next page, pass the last_hit_at and the id of the last alert you received, as before and before_id. An empty array means there are no more.
Terminal window
# The 20 riskiest open alerts
curl -s -b ~/.sp-cookies "$SP/api/v1/alerts?limit=20"
# Closed alerts, second page
curl -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.

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.

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.

Dismisses an open alert. Permission: cases.work.

Field Required Values
disposition Yes false_positive, no_action, duplicate
note No Up to 2,000 characters
Terminal window
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.

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
Terminal window
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.close entry in the audit log, with the disposition, the note and bulk (how many IDs the request named).
  • 422 for an empty ids, more than 100 IDs, a note shorter than 10 characters or an unknown disposition.

Opens a case from an open alert. Permission: cases.work. No body.

  • If the subject already has an active case of the same kind (aml or fraud), 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.
Terminal window
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.

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
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)

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.

Terminal window
# My active cases
curl -s -b ~/.sp-cookies "$SP/api/v1/cases?assignee=me"
# Escalated or new cases nobody has picked up
curl -s -b ~/.sp-cookies "$SP/api/v1/cases?assignee=unassigned"
# Unassigned cases in my teams' queues
curl -s -b ~/.sp-cookies "$SP/api/v1/cases?team=mine&assignee=unassigned"
# Closures waiting for a second approver
curl -s -b ~/.sp-cookies "$SP/api/v1/cases?status=pending_approval"
# Closed critical cases mentioning a customer
curl -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:

Terminal window
cursor=""; n=1
while :; 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))
done

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.

Field Required Values
body Yes 1 to 10,000 characters

Allowed on any case, including closed ones. Returns 204.

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 needs cases.approve; otherwise 403.
  • The assignee must be an active user in your workspace; otherwise 409.
  • Assigning an open case moves it to in_progress.
  • 409 on a closed case.
Terminal window
curl -s -b ~/.sp-cookies -X POST "$SP/api/v1/cases/$CASE/assign" \
-H 'Content-Type: application/json' -d '{"assignee_id": null}'

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 workspace for an unknown team; 409 on a closed case.
  • Being in a queue restricts nothing: anyone with cases.work can still take the case.
Terminal window
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.

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.

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.

Terminal window
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."}'

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
Terminal window
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 with escalated_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 has four_eyes_close. Nothing else closes until someone approves.
  • 409 if 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.
  • 403 if you proposed the closure yourself. 409 if nothing is awaiting approval.

Both return 204.

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.

The documents an investigation collects, kept with the case. The screens are described in Evidence files.

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.

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. docx and xlsx must be Word and Excel packages ([Content_Types].xml and parts under word/ or xl/), msg by its signature, txt, csv and eml as plain text. Files are not scanned for malware.
  • Status: not on a closed case.
Terminal window
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
Terminal window
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

An illustrative run for one analyst and one MLRO.

  1. 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"
  2. 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)
  3. 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."}'
  4. Escalate if it is beyond you, or close it if you hold cases.close.

  5. If the close returned pending_approval, the MLRO lists pending closures, reads the case and approves or rejects it from their own session.