Skip to content

API

How to call SPOT's HTTP APIs: authentication, errors, submitting a message and reading it back. Each route, its parameters, bodies and answers are in the API documents, generated from the code.

Users, the dashboard, the CLI and plugins call the api-gateway: https://api.<your domain> behind the deployment's Traefik, http://localhost:8001 in development. The gateway serves its own document at /openapi.json, and /docs, only when DEBUG is set.

Authentication

Users

Users log in with the OAuth2 password grant and send the access token, a JWT, as Authorization: Bearer:

TOKEN=$(curl -s -X POST http://localhost:8001/auth/token \
  -d "username=admin&password=$SPOT_ADMIN_PASSWORD" | jq -r .access_token)

curl -H "Authorization: Bearer $TOKEN" http://localhost:8001/api/v1/analyze
  • POST /auth/refresh trades the refresh_token of the same answer for a new pair; POST /auth/client-credentials logs in an OAuth client.
  • Each route needs a role: viewer reads, analyst also submits messages and reads email content, admin also configures SPOT (users, workflows, plugins, configuration). A role too low is 403.
  • Email content is for analysts and admins: an email's detail, forensics, view and raw message. For a viewer, the analysis routes leave empty the text analyzers quote from the email (the signals' evidence and location, the analyzers' summaries and details).

Plugins

A plugin holds its own token (spot_pt_...), read from /run/secrets/spot_plugin_token, and sends it as Authorization: Bearer on every call to the api-gateway and to the knowledge service. Its scopes come from its kind:

Kind Scopes Routes
mail_retriever ingest POST /internal/ingest; GET /internal/jobs/{job_id} and POST /internal/jobs/{job_id}/action (its own jobs only)
analyzer knowledge:read, content:read knowledge reads; GET /internal/emails/{email_id}/raw and /parts/{part_id}
context_provider knowledge:write knowledge writes, its own documents only
responder results, content:read GET /internal/results/{job_id}; the content routes

A missing or invalid token is 401, a token without the route's scope 403. Admins issue and rotate tokens with POST /api/v1/plugins/{plugin_id}/token (see Plugin tokens).

Core calls the routes a plugin serves (/internal/analyze, /internal/sync, /internal/state, /internal/respond) with the plugin's own token too. The internal key (X-Internal-API-Key) is core to core only: the api-gateway does not accept it, and no plugin receives it.

Correlation

Every core service and every plugin reads a request's X-Correlation-ID, keeps it when it is 1 to 128 characters of A-Z a-z 0-9 . _ : -, gives the request a new one otherwise, and answers with it, a 500 included: core's services through their Exception handler, a plugin's own 500s through the SDK's app. The ID follows the work it started:

Where work starts Its ID
An HTTP request to a core service or a plugin The X-Correlation-ID sent, when valid; else a new one. The answer carries it, a 500 included.
A call from a core service to another, or to a plugin The current ID, unless the caller set the header
A message published on RabbitMQ The current ID, in the message's X-Correlation-ID header
A message consumed Its header's ID, when valid; else a new one, for that message only
A scheduled provider sync A new one per run
A plugin's calls to SPOT (SpotClient, KnowledgeClient) The ID of the call being handled

So a retriever's ingest, its analysis, the analyzers and responders called for it, and their calls back to SPOT share one ID, and every log line written for them carries it (correlation_id, see Logging).

Errors

The gateway answers every error with the same envelope:

{
  "error": "message_too_large",
  "message": "The message is larger than 25 MiB",
  "details": {}
}

error is a stable code, message says what went wrong and details adds to it (the limit hit, the fields refused). No error quotes a message's content or a secret: a request that fails validation (422, validation_error) lists the refused fields in details.errors (type, loc, msg), never the values sent.

Status error When
400 bad_request The request breaks a rule of the route
401 unauthorized No valid JWT or plugin token
403 forbidden The role or the token's scope is too low
404 not_found Unknown resource
405 method_not_allowed The route does not take that method
408 body_timeout The body did not arrive within intake.body_timeout_s
409 conflict The resource is not in a state that allows it (a job not finished, a plugin needing consent, an email stored before 2.0, which has no raw message or view)
413 message_too_large A message larger than 25 MiB, or a JSON body larger than 34 MiB
415 unsupported_media_type A submission neither message/rfc822 nor JSON
422 validation_error, empty_message, parse_limit An invalid request, an empty message, a message that cannot be parsed within the intake limits
429 rate_limited Over a rate limit; Retry-After says when to retry
502 bad_gateway A core service behind the gateway failed, or the analysis an ingest waits for failed
503 service_unavailable Retry later. The gateway takes or serves as many messages as it can, or no parse worker could take the message: these answers carry Retry-After. Or a service the route needs is down (the broker for a submission, the mail-orchestrator for an ingest): without Retry-After
504 gateway_timeout A core service behind the gateway did not answer in time (the orchestrator, for a workflow test or a dry run)
500 internal_error A fault of SPOT, logged

Rate limiting

Rate limiting is on by default (enable_rate_limiting, in the platform section of spot.yaml or the environment). Over a limit the gateway answers 429 with Retry-After: the whole seconds left in the window of the limit, at least 1, or the window's length when the gateway cannot tell.

Requests Limit Settings
/auth/* 10 per 60 seconds per client IP fixed
With a valid plugin token 6000 per period per plugin plugin_rate_limit_requests, rate_limit_period
Any other 100 per period per client IP rate_limit_requests, rate_limit_period (60 seconds)

The plugin limit is sized for a mail retriever ingesting a busy mail flow: 100 requests a second with the default period. Requests whose token is invalid count against the client IP, and a client IP over its limit is refused before its token is checked, so guessing tokens stays limited.

A change of a limit or of the period applies to the next request, without a restart. A window already running keeps its length: a new period applies from the next window.

Intake

SPOT takes raw RFC 5322 messages, at most 25 MiB. The gateway parses each one once (spot_sdk.mime), stores the raw message and its view, both encrypted, then queues the analysis. The parse runs in worker processes bounded in time and memory (intake.* in Configuration); a message refused is not stored.

Users: POST /api/v1/analyze

Analysts and admins submit the message itself, as a message/rfc822 body:

curl -X POST http://localhost:8001/api/v1/analyze \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: message/rfc822" \
  --data-binary @message.eml

or as JSON, the message base64-encoded in raw, with the workflow to run. Left out, the email's route chooses it, else the default workflow runs (Routing):

jq -n --arg raw "$(base64 -w0 message.eml)" '{raw: $raw, workflow_id: "default-workflow"}' |
  curl -X POST http://localhost:8001/api/v1/analyze \
    -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" -d @-

The answer names the job and the stored email:

{
  "job_id": "3f0e7c1a-...",
  "email_id": "9b2d4e6f-...",
  "status": "queued",
  "workflow": "default-workflow",
  "message": "Email analysis job queued successfully"
}

The job's route names the route that chose its workflow (none for a workflow named, the default, or an analysis from before routing).

Poll GET /api/v1/analyze/{job_id} until status is completed, failed or cancelled. A finished job carries the workflow's verdict (malicious, suspicious, spam, benign or unknown), its score (0 to 100, how likely the email is malicious), decided_by, the signals behind the verdict, strongest first, and the whole result, every analyzer's answer included. A failed job is unknown, decided by failed, with its error. Workflows explains how the verdict is decided.

Analysts and admins cancel a job queued or running with DELETE /api/v1/analyze/{job_id}: a running job's analysis stops at once, and a queued one never starts. The job is recorded cancelled, unknown, decided by cancelled, and no responder is called. The answer is {"job_id": "...", "status": "cancelled"}, also for a job already cancelled; a completed or failed job is 409 and an unknown one 404. A 503 asks to retry the cancel. When the database or the analyzer-orchestrator is not available, nothing was cancelled. When the orchestrator could not record the cancel, it keeps the cancel pending and does not run the job meanwhile, unless it restarts. A retriever's ingest has no job record until the analyzer-orchestrator picks it up, so it can be cancelled only from then on.

GET /api/v1/analyze/stats counts the analyses in total, by_status and by_verdict, and GET /api/v1/analyze/stats/daily?days=N (1 to 90, 30 by default) gives the same for each of the last N UTC days, with failed, cancelled, and the median and 95th percentile of the completed analyses' execution times. A cancelled job counts only as cancelled: in by_status and in its day's cancelled, never in total or by_verdict, where a failed job counts as unknown.

To analyse a stored email again, analysts and admins call POST /api/v1/emails/{email_id}/analyze, without a body or with JSON naming the workflow (left out, the stored email's route chooses it, else the default workflow runs):

curl -X POST http://localhost:8001/api/v1/emails/$EMAIL_ID/analyze \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"workflow_id": "default-workflow"}'

Nothing is stored again: the job runs on the stored email, is listed with its analyses (GET /api/v1/analyze?email_id=...), and the answer is the same as a submission's. An unknown email is 404, and an email stored before 2.0, which has no raw message or view, 409.

The workflow dry run, POST /api/v1/workflows/{workflow_id}/test (analysts and admins), takes the same bodies, and is never routed. It stores the message for the length of the run, as an email no list shows, so analyzers fetch its raw copy and its parts, and deletes it when the run ends; it stores no job and no analysis.

Mail retrievers: POST /internal/ingest

A retriever sends an IngestionRequest: the message base64-encoded in raw, and optionally the SMTP envelope, a priority, and wait_for_verdict with its timeout_ms. It names no workflow: the email's route chooses it, else the default workflow runs, and a route's timeout_ms caps the wait. A request with a workflow_id is refused (422). The body may be up to 34 MiB. The answer is an IngestionResult: accepted at once, or, with wait_for_verdict, analyzed with the verdict and the score, or timeout. The SDK's SpotClient.ingest sends it.

Routing

The routing document is three sections of spot.yaml, {recipient_groups, routing, shadow}, as the file holds them (Routing):

  • GET /api/v1/routing (analysts and admins: the groups list staff addresses) answers it.
  • PUT /api/v1/routing (admins) replaces it whole and answers it as saved; an empty document clears the routing. A document that breaks a rule is refused with 422 (validation_error), nothing saved: one the model refuses (a route named twice, an unknown group, a malformed entry or domain) lists its errors in details.errors, by field and rule, never the value; one naming a workflow that is not loaded, or sender_domains without mail.trusted_authserv_ids, has the message naming the route. A saved document is a commit of spot.yaml's history by the user.
  • GET /api/v1/workflows/{workflow_id}/shadow?limit=50&offset=0 (viewers) lists the workflow's shadow differences, newest first: the job, the email, production's workflow, verdict and score, and the shadow workflow's version, verdict, score and decided_by; never a result. The page also gives the rate of the differences over the last days UTC days, today's included (?days=30, 1 to 90): compared, the production results the workflow ran on (an email analysed again counts again), and differed, those on which its verdict class differed (how they are counted).

Observe and enforce

Each job is in observe or enforce: enforce only when its mail connection and its route both are; a user's submission is always in observe (Observe and enforce). The job's status gives its mode and its actions: what was done with the email, or would have been in observe.

  • PUT /api/v1/config/plugin/mail_retriever/{plugin_id}/mode (admins), with {"mode": "enforce"} or {"mode": "observe"}, switches a mail connection. Nothing restarts: the jobs queued from then on take the new mode.
  • GET /api/v1/analyze/actions?days=7 (viewers, 1 to 90 days) counts the actions of the last days by actor, kind, action, mode and whether each was applied, and gives every mail connection's mode in connections.
  • POST /internal/jobs/{job_id}/action (scope ingest) is how a retriever reports what it did with one of its jobs' messages, an ActionReport of action and applied: 204, 404 for a job that is not its own, 422 for applied on a job in observe. A second report replaces the first.

Content

The gateway keeps each message as received and its parsed view, both encrypted, for data_retention_days.

  • Users (analysts and admins): GET /api/v1/emails/{email_id}/view returns the view, as analyzers receive it (spot_sdk.EmailView), and GET /api/v1/emails/{email_id}/raw the message as received (message/rfc822).
  • Plugins with content:read: GET /internal/emails/{email_id}/raw returns the message, and GET /internal/emails/{email_id}/parts/{part_id} one decoded part, with its media type. Parts are numbered as in the view (Part.part_id); a container part (multipart/*, message/*) is not served.
  • Responders with results: GET /internal/results/{job_id} returns the workflow's OrchestrationResult; 409 while the job runs, 404 for a job unknown, failed or analysed before 2.0.

Each read of a raw message or a part is logged, without the content. The gateway serves at most intake.max_content_in_flight raw messages, parts and views at once, and answers 503 past it. An email stored before 2.0 has no raw copy or view: those routes answer 409, and 404 only for an unknown email.

Queues

The dashboard's Health and Queues pages read GET /api/v1/observability/services and GET /api/v1/observability/queues (viewers). An admin replays the messages a queue parked in its dead-letter queue with POST /api/v1/observability/queues/{queue}/replay, for orchestrator.analysis, orchestrator.dispatch, orchestrator.shadow and orchestrator.benchmark (what a replay of the last does: Messages in a dead-letter queue). It answers {"queue": ..., "replayed": N}; ?limit=N (at least 1) moves at most N. Only the messages parked when the replay starts move, each at least once. A queue that parks nothing is 404, and a broker failure 503: what was not moved stays parked, and a second replay moves the rest. Fix the cause first, then replay one (?limit=1) and check that it is handled before replaying the rest (Configuration).

spot.yaml's history

Every change of spot.yaml is a commit, by the user who made it (Configuration). Admins read and roll it back:

  • GET /api/v1/config/history?limit=50&offset=0 lists the commits, newest first: each one's id, short_id, author (a username, or SPOT), at, message, and the sections and workflows it changed, with total.
  • GET /api/v1/config/history/{commit_id} gives a commit, its diff from the commit before it (from no file for the first), and rollback_diff: what a rollback to it would change in the settings in force, as SPOT writes them (a hand edit not yet reloaded is not shown), null when its spot.yaml no longer validates. Each sealed secret reads (sealed), or (sealed, changed) where its value changed.
  • POST /api/v1/config/history/{commit_id}/rollback restores every section of that commit but plugins:, as a commit of its own, Roll back to <12 digits>. It answers that commit and the sections it changed from the settings in force before it. commit is null when none was recorded: when nothing changed (no sections either), while the history is paused (a start or a reload is sealing secrets, and its commit records the rollback), or when the commit failed (logged; the next commit records the change). A commit whose spot.yaml no longer validates is 422, naming each field and the rule it breaks, and nothing is written.

commit_id is a commit's full id or a prefix of at least 7 hex digits that only it starts with: anything else is 422, and an id no commit (or more than one) has is 404.

A workflow's versions

The gateway numbers a workflow's versions: every save stores 1 + the highest version its id has had, in force or in its history, so a version number names one version, even after a config rollback or a hand edit lowered the one in force. A hand edit that changes a workflow should raise its version past that highest (spot-cli workflow history <id>): one that keeps the number gives it a second definition. POST and PUT /api/v1/workflows drop the body's version and created_by, whatever they hold: the author is the user who created the workflow, and a restore keeps the restored version's. A workflow's versions are read from spot.yaml's history:

  • GET /api/v1/workflows/{workflow_id}/history?limit=20&offset=0 (viewers) lists the commits that changed it, newest first: each one's commit, the workflow's version in it, and deleted for the one that deleted it.
  • GET /api/v1/workflows/{workflow_id}/history/{commit_id} (viewers) gives the workflow as that commit stores it (null when it holds none) and its diff from the commit before.
  • POST /api/v1/workflows/{workflow_id}/history/{commit_id}/restore (admins) saves that version as the next one, a deleted workflow included. A workflow of contract 1 is converted first; one that breaks a rule is 400. An unknown commit, or one without the workflow, is 404.
  • POST /api/v1/workflows/{workflow_id}/clone (admins), with {"id": "new-id", "name": "New name"}, copies a workflow under a new id (letters, digits, - and _, at most 100) and answers as a create. name is the source's when not given. An unknown source is 404, an id already stored 409.

While spot.yaml's history cannot be read (an object or a ref of history.git missing or damaged), these routes and the saves that number a version (POST and PUT /api/v1/workflows, a restore, a clone) answer 503, naming it, and save nothing.

Benchmark runs

spot-cli benchmark run records the run it makes, and the platform runs backtests of its own (Stored runs). A run holds what ran and each email's verdict, score and times, never an email's content, so viewers read it; analysts and admins start, record, cancel and delete.

  • POST /api/v1/benchmarks (analysts) starts a run, with the workflow_id and the emails it takes (the dataset files with their SHA-256, the total, the label filter, shuffle, seed and max_emails; at most 50 000 emails). The gateway takes the snapshot now: the workflow's version, spot.yaml's last commit, SPOT's version, and each analyzer's version and image. It answers 201 with the run, and the audit log holds benchmark.start. A workflow that does not load is 404. emails is one of three. {"files": ...} as above is spot-cli's run; it starts without the message broker. {"dataset": "<id>"} (a dataset of promoted copies; an unknown one is 404) and {"mail": {"since": ..., "until": ..., "sources": [...], "labelled_only": false, "limit": 500}} (the stored mail received in the period, the most recent first; until defaults to now, which the run records, no sources is every source, limit is 1 to 10 000 and defaults to 500) are the platform's: the gateway queues one message per email, on orchestrator.benchmark, and the analyzer-orchestrator runs them one at a time, recording the results. A platform run needs the message broker (503 without it), takes 10 000 emails at most (422 for a larger dataset, and for a selection that matches no email), and fingerprints the emails in selection_sha256. One platform run runs at a time: a start while one runs is 409 naming it, a rule the database keeps even for two simultaneous starts.
  • POST /api/v1/benchmarks/{run_id}/results (analysts) records up to 1000 results, each in place of the one of the same key, so a batch sent again leaves one result per email. It answers recorded of total. A run that has ended is 409, and an unknown run 404. The platform records a platform run's results itself: for it, this and the next call are 409.
  • POST /api/v1/benchmarks/{run_id}/finish (analysts) ends the run and counts its metrics over the results recorded: overall, per dataset and per analyzer, with the median and 95th percentile of the times. A run already ended is answered as it is. A platform run's metrics, counted when it ends, also hold production's (baseline) when its results carry production's verdicts: counted from them over the same labelled emails, an email without one as failed.
  • POST /api/v1/benchmarks/{run_id}/cancel (analysts) cancels a running run, as benchmark.cancel in the audit log, and counts its metrics over the results recorded so far. The messages of a platform run still queued are skipped; spot-cli's next batch is refused and it stops, unless the run is resumed (Stored runs). A cancel is final: a run that has ended, whoever ended it, answers 409.
  • GET /api/v1/benchmarks?workflow_id=...&limit=50&offset=0 (viewers) lists the runs, newest first, with total; limit is 1 to 100.
  • GET /api/v1/benchmarks/{run_id} (viewers) gives a run with its snapshot, the emails recorded so far and, once ended, its metrics.
  • GET /api/v1/benchmarks/{run_id}/results?missed=false&differs=false&limit=50&offset=0 (viewers) lists its results in the order recorded; missed=true keeps those the workflow got wrong, and differs=true those whose verdict differs from the one production gave. A result of a stored email holds baseline_verdict and baseline_job_id, the production analysis that verdict is of: the email's latest completed analysis, whichever workflow ran it.
  • DELETE /api/v1/benchmarks/{run_id} (analysts) deletes the run and its results, as benchmark.delete in the audit log.

Every request is under the rate limit.

Labels, datasets and sender lists

An analyst corrects a verdict by labelling the email, listing its sender and promoting it into a dataset (Correcting a verdict). Analysts and admins use all of these routes, except that viewers read the datasets: a label carries a note and names a sender.

  • PUT /api/v1/emails/{email_id}/label sets the label, in place of the previous one: a verdict (malicious, suspicious, spam or benign, never unknown) and a note of at most 2000 characters. GET reads it, with its note; DELETE removes it. An unknown email or a missing label is 404. A dataset's copy keeps its label: DELETE answers 409, and PUT relabels it.
  • GET /api/v1/labels?verdict=...&since=...&limit=50&offset=0 lists the labels, newest first, with total. Each names its email's sender and, for a dataset's copy, its dataset_id (null for a received email), so a copy's label is told from its original's. since is an ISO 8601 time with its offset (one without is 422); limit is 1 to 100.
  • POST /api/v1/datasets (id, name, description) creates a dataset; an id in use is 409. GET /api/v1/datasets lists them with their counts and GET /api/v1/datasets/{dataset_id} gives one with a page of its copies, each with its subject and sender (viewers too).
  • POST /api/v1/datasets/{dataset_id}/emails (email_id) copies a labelled email into the dataset and answers 201 with the copy. It is 409 when the email has no label or no raw message, when the dataset holds its message already or has its 10 000 emails, or when its stored view is unreadable.
  • DELETE /api/v1/datasets/{dataset_id}/emails/{email_id} removes a copy, by the copy's id, which deletes it; DELETE /api/v1/datasets/{dataset_id} deletes the dataset and its copies and answers how many emails. The emails they were made from stay.
  • GET /api/v1/sender-lists gives the block and allow lists. POST /api/v1/sender-lists/{which} (allow or block; domain, and optionally the email_id of the label it comes from) adds a domain and answers 201 with the lists; DELETE /api/v1/sender-lists/{which}/{domain} removes one. A domain already on a list is 409, and the allow list is 422 while mail.trusted_authserv_ids is empty. Each change is a commit of spot.yaml's history by the user. What a list does is a workflow's policy to decide (Sender lists).

Plugin resources

An admin overrides an installed plugin's memory and CPU limits with PUT /api/v1/plugins/{plugin_id}/resources and a body of memory_mb (at least 128), cpus (above 0, at most 64) or both, such as {"memory_mb": 3072}; a value left out keeps the limit in force. DELETE on the same path removes the override: the image's request applies again, else 1024 MB and 1 CPU. Both restart an enabled plugin and answer the installed plugin with resources (the override), requested_resources (the image's request), effective_resources (the limits in force) and restarting. A plugin that is not installed, a manual entry included, is 404, and a body that gives neither value, or another key, 422. A plugin that cannot be restarted with the new limits gets its previous ones back, saved again and restarted with them: when Docker refused the new limits, such as more CPUs than the host has, the answer is 422 with Docker's reason, and when the restart failed otherwise, 503. A 503 also says that spot.yaml could not be saved (nothing changed). See Plugin resources.

Audit log

The api-gateway records who did what: sign-ins, plugin lifecycle actions, user accounts, plugin sources, labels, datasets, reads of a raw message, deletions of stored data and replays of parked messages (Audit log). Configuration changes are the commits of spot.yaml's history. Admins read the log:

  • GET /api/v1/audit?limit=50&offset=0 lists the entries, newest first, with total: how many pass the filters. limit is 1 to 500.
  • The filters, each optional, all applied:
  • action: an action (plugin.install), or a family ending in . (plugin.), in lowercase letters and _;
  • actor: who acted, as named then; actor_id: who acted, by an id a rename keeps (a user's UUID, an OAuth client's id);
  • target: what was acted on;
  • outcome: success or failure;
  • since and until: ISO 8601 times, both included; one without an offset is UTC.

actor, actor_id and target hold at most 255 characters. A filter that breaks its rule is 422.

An entry holds at (UTC), actor and actor_id, action, target, outcome, details, ip_address, user_agent and correlation_id. details names what changed; a failure adds status, the HTTP status the route answered. A failed sign-in names the account only when it exists: actor and actor_id are null otherwise.

Action Route Target Details
auth.login POST /auth/token failure: reason (unknown_account, bad_password, inactive)
auth.client_login POST /auth/client-credentials failure: reason (refused)
plugin.install POST /api/v1/plugins/install plugin version, kind, egress, consented, settings, secrets (names)
plugin.update POST /api/v1/plugins/{id}/update plugin from_version, to_version, consented, secrets (names), and settings (names) when sent
plugin.uninstall DELETE /api/v1/plugins/{id} plugin
plugin.start, plugin.stop, plugin.restart POST /api/v1/plugins/{id}/start, /stop, /restart plugin
plugin.settings PUT /api/v1/plugins/{id}/settings plugin settings, secrets (names)
plugin.token POST /api/v1/plugins/{id}/token plugin
plugin.resources PUT, DELETE /api/v1/plugins/{id}/resources plugin memory_mb, cpus as sent and reset: false for the PUT; reset: true for the DELETE
plugin.add POST /api/v1/config/plugin/{kind} plugin kind
plugin.configure PUT /api/v1/config/plugin/{kind}/{id} plugin fields, secrets (names)
plugin.enable, plugin.disable POST /api/v1/config/plugin/{kind}/{id}/enable, /disable plugin
plugin.remove DELETE /api/v1/config/plugin/{kind}/{id} plugin
plugin.mode PUT /api/v1/config/plugin/mail_retriever/{id}/mode plugin mode
plugin.sync POST /api/v1/plugins/context_provider/{id}/sync plugin a failure when the trigger failed, though the route answers 200
user.create POST /api/v1/users, POST /auth/register username role
user.update PUT /api/v1/users/{id} username fields; username, the new name, on a rename
user.role PUT /api/v1/users/{id}/role username role
user.password PUT /api/v1/users/{id}/password username
user.activate, user.deactivate POST /api/v1/users/{id}/activate, DELETE /api/v1/users/{id} username
source.add, source.update, source.remove, source.enable, source.disable /api/v1/plugin-sources... source id type (not for source.remove)
email.delete DELETE /api/v1/emails/{email_id} email id
email.raw GET /api/v1/emails/{email_id}/raw email id
email.label PUT /api/v1/emails/{email_id}/label email id verdict, and replaced, the class it replaces (null when none), never the note
email.unlabel DELETE /api/v1/emails/{email_id}/label email id replaced, the class removed
dataset.create POST /api/v1/datasets dataset id
dataset.promote POST /api/v1/datasets/{dataset_id}/emails dataset id email_id, and on success copy_id, verdict
dataset.remove DELETE /api/v1/datasets/{dataset_id}/emails/{email_id} dataset id copy_id
dataset.delete DELETE /api/v1/datasets/{dataset_id} dataset id emails: how many copies were deleted
knowledge.delete DELETE /api/v1/knowledge/sources/{source}/documents/{doc_id} <source>/<id>
benchmark.start POST /api/v1/benchmarks workflow id run, emails
benchmark.cancel POST /api/v1/benchmarks/{run_id}/cancel run id
benchmark.delete DELETE /api/v1/benchmarks/{run_id} run id
queue.replay POST /api/v1/observability/queues/{queue}/replay queue messages: how many were moved back

A user's routes name the user as it was before the action, by its id when no user has it.

The documents

Document Served by Called by
gateway.openapi.json api-gateway users, the dashboard, the CLI, plugins (/internal/*)
knowledge.openapi.json knowledge service (SPOT_KNOWLEDGE_URL) context providers and analyzers; routes tagged "Core only" are core's
plugin-<kind>.openapi.json: analyzer, context provider, mail retriever, responder every plugin of that kind, through the SDK's create_plugin_app core
analyzer-orchestrator.openapi.json analyzer-orchestrator the api-gateway; the mail-orchestrator probes its /health
mail-orchestrator.openapi.json mail-orchestrator the api-gateway only
plugin-manager.openapi.json plugin-manager the api-gateway only

API documents renders each of them. make docs:openapi regenerates them, and the CI job openapi fails when the committed documents differ from the code.