Reports API
The Reports API is what the Reports screens use: the register of STRs and CTRs, drafting an STR from a case, four-eyes approval, the goAML XML export, and the record of what was filed with the FIC and when. Read Regulatory reporting first for how the process works.
Before you start
Section titled “Before you start”Who can call it. Signed-in people only; an API key gets 401 sign in required. Someone who
hasn’t finished setting up their account (two-factor or a new password) gets 403.
Two permissions on every call. Reports hold customers’ identity in full, so every endpoint
below except the settings needs entities.view_pii and at least one of reports.draft,
reports.approve or reports.submit, plus the permission named for the action. With the default
roles, senior analysts, L2 investigators and MLROs can use it.
Everything is audited. Opening a report (at most once per person and report every 30 minutes), editing, every status change, the export, every download and settings changes are written to the audit log under your name.
Signing in with curl. Sign in once and keep the session cookie, as described in
Alerts and cases API: before you start. Then add
-b ~/.sp-cookies to every request below.
Endpoints at a glance
Section titled “Endpoints at a glance”| Endpoint | Does | Needs (as well as entities.view_pii) |
Success |
|---|---|---|---|
GET /api/v1/reports |
The register, newest first | Any reporting permission | 200 |
GET /api/v1/reports/counts |
How many reports in each status | Any reporting permission | 200 |
GET /api/v1/reports/{id} |
One report, with its subject, transactions and validation | Any reporting permission | 200 |
POST /api/v1/cases/{id}/reports/str |
Draft an STR from a case, or return the one in progress | reports.draft |
200 {"id", "created"} |
PATCH /api/v1/reports/{id} |
Edit a draft | reports.draft |
204 |
POST /api/v1/reports/{id}/request-approval |
Send a draft for approval | reports.draft |
204 |
POST /api/v1/reports/{id}/return |
Send a report back to draft | reports.approve |
204 |
POST /api/v1/reports/{id}/approve |
Approve (not one you drafted or sent) | reports.approve |
204 |
POST /api/v1/reports/{id}/void |
Void a report that must not be filed, with a reason | reports.approve |
204 |
POST /api/v1/reports/{id}/export |
Make the goAML XML, once | reports.approve or reports.submit |
200 |
GET /api/v1/reports/{id}/download |
The exported file | reports.approve or reports.submit |
200, the XML |
GET /api/v1/reports/{id}/package |
The filing package: the export, the chosen evidence files and a manifest, as one ZIP | reports.approve or reports.submit |
200, a ZIP |
POST /api/v1/reports/{id}/submission |
Record the upload to goAML | reports.submit |
204 |
POST /api/v1/reports/{id}/outcome |
Record the FIC’s acknowledgement or rejection | reports.submit |
204 |
GET /api/v1/settings/reporting |
The reporting settings | settings.manage alone is enough; otherwise any reporting permission |
200 |
PUT /api/v1/settings/reporting |
Replace the reporting settings | settings.manage alone, or reports.approve |
200 |
The register
Section titled “The register”GET /api/v1/reports
Section titled “GET /api/v1/reports”| Query parameter | Meaning |
|---|---|
status |
draft, pending_approval, approved, submitted, acknowledged, rejected or voided. Empty for all |
type |
str, ctr or sar. Empty for all |
case_id |
Only reports drafted from this case (a UUID) |
q |
Part of the report number, title, the FIU’s reference or case number (up to 128 characters) |
limit |
1 to 200, default 50 |
cursor |
The Next-Cursor header of the previous page |
Each report in the list:
| Field | Meaning |
|---|---|
id, number, type, status, title |
The report. number is like STR-2026-000014 or CTR-2026-000031 |
source |
case (an STR drafted from a case) or ctr_job (a CTR drafted automatically) |
business_day |
CTRs: the day, YYYY-MM-DD |
case_id, case_number |
STRs: the case it was drafted from |
regulator_ref, submitted_at |
Once submitted: the FIC’s reference and when it was uploaded |
created_at, updated_at |
When it was made and last changed |
created_by_name |
Who drafted it. Absent for CTRs drafted by the system |
transaction_count, amount_base_minor |
How many transactions are selected, and their total in the base currency’s minor units |
When there are more, the response has a Next-Cursor header; pass it as cursor for the next
page. A bad cursor gets 422 cursor is invalid; start again without it.
# Reports waiting for approvalcurl -s -b ~/.sp-cookies "$SP/api/v1/reports?status=pending_approval"GET /api/v1/reports/counts
Section titled “GET /api/v1/reports/counts”An object of status to count, for example {"draft": 3, "pending_approval": 1, "submitted": 12}.
Statuses with no reports are left out.
One report
Section titled “One report”GET /api/v1/reports/{id}
Section titled “GET /api/v1/reports/{id}”Everything in the list, plus:
| Field | Meaning |
|---|---|
jurisdiction, format |
The workspace’s country, and the report format from the settings: goaml, or centif-uemoa (the CENTIF declaration) |
fiu |
The short name of the FIU the workspace’s jurisdiction reports to, such as FIC, NFIU or CENTIF-CI. For an approved report, the FIU of the jurisdiction it was approved under. The app uses it wherever a report’s page names the FIU: the status, the reference, the hints and the submission and outcome panels |
report_code |
The FIU’s report code from the settings, such as STR (or DOS for a CENTIF) |
reason, action_taken, indicators |
The narrative, the action taken and the indicator codes |
attachments |
The titles of the documents filed with the report, in order. Frozen with the rest of the content once the report leaves draft |
files |
The case’s evidence files for this report, oldest first, each with id, file_name, size_bytes, sha256 (hex) and selected. On a draft made from a case: every file still on the case, with selected saying which go with the report. Once the report has left draft: only the chosen files, all selected: true. Empty for a report without a case (a CTR) or a case without files. Frozen with the rest of the content once the report leaves draft |
total_base_minor |
CTRs: the cash total recorded by the CTR job |
subject |
Who it is about: type, id, name, external_id, identity (an object of decrypted details, such as "Phone", "ID (ghana card)", "Date of birth", "Account") and designations (active designation types) |
transactions |
The transactions the report may include, each with id, external_id, occurred_at, direction, type, channel, status, amount_minor, currency, amount_base_minor, counterparty, account and selected. For an STR: the case’s alert transactions and any already selected, newest first, at most 500 |
problems |
Validation of the report as it would be exported now: each with path, message and severity (error blocks, warning doesn’t) |
can_void |
true when you may void it now: it is a draft, awaiting approval or approved, and you hold reports.approve |
can_approve |
true when you may approve it now: it is awaiting approval, you hold reports.approve, and you didn’t draft it or send it for approval |
created_by, requested_by, requested_by_name, requested_at |
Who drafted it, and who sent it for approval and when |
approved_by_name, approved_at, submitted_by_name |
Who approved and when, and who recorded the submission |
outcome_by_name, outcome_at, outcome_note |
Who recorded the FIC’s answer, when, and the note |
voided_by_name, voided_at, void_reason |
For a voided report: who voided it, when and why |
export |
Once exported: file_name, sha256 (hex), size_bytes, schema_version (goAML 4.0 report schema, or SpeculaPro CENTIF declaration 1.0 (DOS/DTE; XML and printable form; layout to be confirmed with the CENTIF)), exported_at, exported_by |
Drafting
Section titled “Drafting”POST /api/v1/cases/{id}/reports/str
Section titled “POST /api/v1/cases/{id}/reports/str”No body. Drafts an STR from the case, pre-filled as described in
Drafting an STR from a case, adds a
report_linked event to the case timeline, and returns:
{ "id": "0192f4a1-…", "created": true }If the case already has an STR that is a draft, awaiting approval or approved, nothing is created:
that report’s id comes back with "created": false. After a report is submitted, acknowledged,
rejected or voided, a new one is drafted.
PATCH /api/v1/reports/{id}
Section titled “PATCH /api/v1/reports/{id}”Drafts only. Send only what you change:
| Field | Rules | Meaning |
|---|---|---|
title |
Up to 200 characters | The register title (not sent to the FIC) |
reason |
Up to 4,000 characters | The narrative (goAML reason) |
action_taken |
Up to 4,000 characters | goAML action |
indicators |
Up to 50 codes | The full list of indicator codes. Duplicates and blanks are dropped |
attachments |
Up to 50 titles | The full list of attachment titles (pièces jointes), in order; an empty list clears it. Each is trimmed and cut at 200 characters, and blanks are dropped. Titles only: no file is uploaded. The CENTIF declaration prints them; the goAML file doesn’t carry them |
transaction_ids |
Up to 500 IDs | The full selection. Each must be one of the report’s transactions; any not listed is removed |
reason |
See the row above. A narrative the AI assistant drafted is saved through this field like any other text; asking for a draft never changes the report | |
file_ids |
Up to 50 IDs | The full selection of the case’s evidence files that go with the report; any not listed is removed, and an empty list clears it. Leave the field out to keep the selection. Each must be a file of the report’s own case that hasn’t been removed, and together they may add up to at most 100 MB, or the request fails with 422 and nothing changes. The files come in the filing package; they don’t change the exported report and are separate from attachments |
curl -s -b ~/.sp-cookies -X PATCH -H 'Content-Type: application/json' \ -d '{"action_taken":"Wallet restricted pending review","indicators":["MULE1"]}' \ "$SP/api/v1/reports/0192f4a1-0000-7000-8000-000000000001"Approval
Section titled “Approval”POST /api/v1/reports/{id}/request-approval
Section titled “POST /api/v1/reports/{id}/request-approval”No body. Sends a draft for approval. Refused with 422 while the report has validation errors.
POST /api/v1/reports/{id}/return
Section titled “POST /api/v1/reports/{id}/return”| Field | Required | Rules |
|---|---|---|
note |
Yes | 3 to 2,000 characters: what the drafter should change |
Sends a report awaiting approval back to draft. The note is kept in the audit log.
POST /api/v1/reports/{id}/approve
Section titled “POST /api/v1/reports/{id}/approve”| Field | Required | Rules |
|---|---|---|
note |
No | Up to 2,000 characters |
Approves a report awaiting approval. Refused with 403 if you drafted it or sent it for approval,
and with 422 while it has validation errors. After approval the report’s content can never
change, and the reporting settings in force at that moment are kept with it: the export uses them,
whatever changes later.
POST /api/v1/reports/{id}/void
Section titled “POST /api/v1/reports/{id}/void”| Field | Required | Rules |
|---|---|---|
reason |
Yes | 3 to 2,000 characters: why the report must not be filed |
Needs reports.approve. Voids a draft, a report awaiting approval or an approved report (for
example one approved by mistake, a duplicate, or the wrong subject). It answers 204, and the
report stays in the register with status voided, voided_by_name, voided_at and void_reason,
audited as report.void. A new STR can then be drafted from the case. A submitted report can’t be
voided (409 conflict: a submitted report can't be voided). An export already made stays stored,
but can’t be recorded as submitted.
Export and download
Section titled “Export and download”POST /api/v1/reports/{id}/export
Section titled “POST /api/v1/reports/{id}/export”No body. For an approved report: builds the file in the report’s format (goAML XML, or the CENTIF declaration archive: a ZIP holding the declaration as XML, the same declaration as a print-ready French HTML form, and the XML’s schema) from the report and the current
settings, validates it, re-reads the XML to prove it is well-formed, stores the
exact bytes encrypted with the workspace’s key, and returns the export record. A report that has
already been exported returns its existing export, whatever its status: the file is made once.
GET /api/v1/reports/{id}/download
Section titled “GET /api/v1/reports/{id}/download”The exported file, exactly as stored, after checking it against its SHA-256. Headers:
| Header | Value |
|---|---|
Content-Type |
application/xml; charset=utf-8 (goAML), or application/zip (CENTIF declaration) |
Content-Disposition |
attachment; filename="STR-2026-000014.xml", or DOS-2026-000003-centif.zip for the CENTIF archive |
X-Content-SHA256 |
The file’s SHA-256, in hex |
Cache-Control |
no-store |
curl -s -b ~/.sp-cookies -OJ "$SP/api/v1/reports/0192f4a1-0000-7000-8000-000000000001/download"Every download is audited. A report that hasn’t been exported gets
404 not found: this report has not been exported.
GET /api/v1/reports/{id}/package
Section titled “GET /api/v1/reports/{id}/package”The filing package of an exported report, as one ZIP put together at each call:
- the exported file, exactly as stored, after checking it against its SHA-256;
- each evidence file chosen for the report (
fileswithselected: true), decrypted and checked against the SHA-256 recorded when it was added. A file whose name is already used in the ZIP gets a number in front, such as2-statement.pdf; MANIFEST.txt: a heading, then one line for each file as<sha256> <size> bytes <name>.
A report with no chosen files still gets a package: the export and the manifest.
| Header | Value |
|---|---|
Content-Type |
application/zip |
Content-Disposition |
attachment; filename="STR-2026-000014-package.zip": the export’s name without its extension, then -package.zip (so DOS-2026-000003-centif-package.zip for a CENTIF archive) |
X-Content-SHA256 |
The SHA-256 of the ZIP that was sent, in hex |
Cache-Control |
no-store |
curl -s -b ~/.sp-cookies -OJ "$SP/api/v1/reports/0192f4a1-0000-7000-8000-000000000001/package"Needs reports.approve or reports.submit, and entities.view_pii. Every download is audited as
report.package_download, with the export’s SHA-256 and the names of the evidence files in the
package. A report that hasn’t been exported gets 404 not found: this report has not been exported. If a stored file doesn’t match its SHA-256 the call fails with 500 and nothing is
sent. A support session can’t call it.
Filing
Section titled “Filing”POST /api/v1/reports/{id}/submission
Section titled “POST /api/v1/reports/{id}/submission”| Field | Required | Rules | Meaning |
|---|---|---|---|
regulator_ref |
Yes | 1 to 100 characters | The reference goAML gave the submission |
submitted_at |
Yes | An RFC 3339 time, not in the future | When it was uploaded |
For an approved, exported report. The report becomes submitted; the reference and time can never
change.
curl -s -b ~/.sp-cookies -H 'Content-Type: application/json' \ -d '{"regulator_ref":"GH-FIC-2026-118734","submitted_at":"2026-03-24T10:15:00Z"}' \ "$SP/api/v1/reports/0192f4a1-0000-7000-8000-000000000001/submission"POST /api/v1/reports/{id}/outcome
Section titled “POST /api/v1/reports/{id}/outcome”| Field | Required | Rules |
|---|---|---|
status |
Yes | acknowledged or rejected |
note |
For rejected |
Up to 2,000 characters: the FIC’s reason |
For a submitted report. Either outcome is final.
Reporting settings
Section titled “Reporting settings”GET /api/v1/settings/reporting and PUT /api/v1/settings/reporting
Section titled “GET /api/v1/settings/reporting and PUT /api/v1/settings/reporting”GET returns the settings, to anyone with settings.manage, settings.view or a reporting
permission; PUT replaces them all and returns what was stored. Changing them needs
settings.manage or reports.approve; each change is audited as reporting.settings with the
values before and after. What each one means: Reporting settings.
| Field | Type | Notes |
|---|---|---|
format |
String | goaml or centif-uemoa. The default is your jurisdiction’s; change it only if your FIU tells you to. It applies to reports drafted from then on: a report keeps the format it was drafted in |
fiu, fiu_name, format_name |
Strings | Read-only, worked out from the workspace’s jurisdiction each time the settings are read and never stored; PUT ignores them. fiu is the FIU’s short name (FIC, NFIU, CENTIF-CI; FIU where the jurisdiction names none of its own, as for The Gambia and Other country), fiu_name its full name (Financial Intelligence Centre), and format_name the format in words (goAML or CENTIF declaration). The app uses them to name the FIU and format on the Reports pages |
rentity_id |
Integer | The reporting entity ID. Must be set before ctr.enabled |
rentity_branch |
String | Up to 255 |
submission_code |
String | E or M |
institution_name, institution_code, swift |
Strings | Up to 255, 50 and 11. swift must be a valid BIC |
non_bank_institution |
Boolean | |
report_codes |
Object | str, ctr: up to 20 characters each |
reporting_person |
Object | title, first_name, last_name, email, phone, occupation |
location |
Object | type (default B), address (up to 100), town, city, zip (up to 10), country (two letters), state |
indicators |
Array | Up to 500 of {"code", "label"} |
typology_indicators |
Object | Rule typology, category or key → array of indicator codes |
require_indicators |
Boolean | STRs need at least one indicator |
codes |
Object | funds_cash, funds_account, default_transmode, contact_type, mobile_comm_type, signatory_role (strings); transmode, id_types, account_status (objects of SpeculaPro value → FIC code) |
ctr |
Object | enabled, threshold_individual_minor and threshold_business_minor (minor units of the base currency, so GHS 50,000 is 5000000 and XOF 15,000,000 is 15000000; 0 means not set), comparison (gte: cash at or above the threshold is reported; gt: only cash above it. Default: the jurisdiction’s), aggregate, cash (up to 50 of {"channel", "type"}), lookback_days (1 to 31). Read only: thresholds_kept_from, a currency code set by an upgrade when the thresholds are that currency’s figures read in the base currency; cleared when a PUT changes either threshold |
warnings |
Array, read only | Problems with the settings as they stand, worked out when they are read and never stored. ctr_no_threshold: CTR drafting is on but no threshold is set, so nothing is drafted. ctr_threshold_currency_mismatch: CTR drafting is on against thresholds an upgrade kept from another currency (ctr.thresholds_kept_from names it), so almost nothing is drafted; it clears only when a PUT changes threshold_individual_minor or threshold_business_minor; a PUT that leaves both as they are keeps it. Leave warnings out of a PUT |
Fields you leave out are filled with their defaults. Values you send are checked as sent:
| You send | Answer |
|---|---|
| A threshold below zero | 422 invalid: CTR thresholds can't be negative (0, or left out, means not set) |
lookback_days outside 1 to 31 |
422 invalid: CTR lookback must be 1 to 31 days |
A comparison other than gte or gt |
422: only those two values are accepted |
ctr.enabled: true while either threshold is unset |
422 invalid: set the CTR thresholds for individuals and businesses before enabling CTRs (this workspace's jurisdiction defines none) |
A threshold of 0, or left out, takes the jurisdiction’s figure. Where the jurisdiction defines
none (Other country, global), it stays 0: no CTR is drafted and ctr.enabled is refused
until you send both thresholds. Settings can’t be saved that way, but a workspace can end up there
when the jurisdiction it relied on for its threshold stops applying; warnings then carries
ctr_no_threshold. See When no threshold is set.
Errors
Section titled “Errors”| Status | Meaning | Examples |
|---|---|---|
401 |
Not signed in, or called with an API key | sign in required |
403 |
A permission is missing, or four eyes | forbidden: reports contain customer identity; you need entities.view_pii; forbidden: missing permission reports.draft; forbidden: you need a reporting permission; forbidden: you drafted this report or sent it for approval; someone else must approve it; forbidden: reports.approve or reports.submit is needed to export; missing permission settings.manage or reports.approve |
404 |
No such report or case in your workspace | not found; not found: this report has not been exported |
409 |
The report isn’t in the right status | conflict: only drafts can be edited (this report is pending_approval); conflict: report is approved; conflict: nothing awaiting approval; conflict: only approved reports are exported (this one is draft); conflict: only approved reports can be recorded as submitted (this one is submitted); conflict: export the file before recording the submission; conflict: only submitted reports get an outcome (this one is approved); conflict: a submitted report can't be voided |
422 |
Validation | invalid: fix the report first: rentity_id: is required: … (up to five problems, then and N more); invalid: transaction <id> is not one of this report's transactions; invalid: file <id> is not one of the case's evidence files; invalid: the submission date can't be in the future; invalid: say why the FIU rejected it; invalid: the reference number the FIU gave the filing is required; invalid: say why the report is voided; case_id must be a UUID; cursor is invalid; start again without it; settings: invalid: "ABC" is not a SWIFT/BIC code, invalid: indicator "X" is listed twice, invalid: typology "mule" maps to "M9", which is not in the indicator list, invalid: unknown channel "x" in transmode codes, invalid: set the reporting entity id before enabling CTRs, invalid: say which transactions count as cash before enabling CTRs |