Skip to main content

API reference

Measured Cloud exposes a versioned REST API over HTTP. Everything you can do in the app, you can do through the API: connect data sources, add rules, run validations, read results, and manage alerts.

This page is the entry-level walkthrough: the surface, the auth, and the conventions. For every endpoint with its parameters, request bodies, and response shapes, use the full API reference. It is generated from the API's own OpenAPI schema on every docs release, so it always matches the shipped code.

Base URL​

https://dq.example.com/api in production. http://localhost:8000/api locally.

API versioning​

There are two routing prefixes today:

PrefixUse
/api/v1/…The original stable surface. Most read endpoints and AI / alert / schedule / agent-token resources live here.
/api/v2/…The newer routes that introduce org-scoped + workspace-scoped paths. Use these for new integrations. The pattern is /api/v2/organizations/{org_id}/workspaces/{ws_id}/....

Both versions sit behind the same auth flow. No version is deprecated as a whole; individual deprecated endpoints (such as the checkpoint alias) answer with a Deprecation: true header and a Link: <…>; rel="successor-version" header naming their replacement.

Authentication​

Three token types — never confuse them.

TokenWhere it comes fromHeader
JWTPOST /auth/login returns one as access_token. Lifetime: 15 min.Authorization: Bearer <jwt>
dqk_… (personal API key)User generates one in Settings → API keys.Authorization: Bearer dqk_…
dqa_… (agent token)Owner generates one in Settings → Agents. Scoped to one org + (optional) workspace.Authorization: Bearer dqa_…

JWT refresh: POST /auth/refresh exchanges a dqr_… refresh-token cookie for a fresh JWT.

For non-interactive callers (CI, scripts, the SDKs), a personal API key is the right token: mint one in Settings → API keys, send it as Authorization: Bearer dqk_…, and it acts with your org role and optional workspace scope. The raw token is shown once at mint time. See Create an API token for the full walkthrough and the personal-api-keys endpoints below to manage keys over the API.

A dqk_ personal key (or a scoped dqsa_ service account) is also the correct token for a BI tool — PowerBI and Tableau can't refresh short-lived JWTs, so the BI feeds (bi/asset-status, bi/runs) accept dqk_/dqsa_ and reject agent/SCIM tokens. See Connect PowerBI or Tableau.

Token lookups are cached in Redis for 5 seconds (TOKEN_CACHE_TTL_SECONDS). Revoked tokens stay accepted for at most that long.

Interactive API reference​

The full API reference on this site renders the OpenAPI schema of the current release with Redoc: every endpoint grouped by resource, with searchable request and response schemas.

Your own deployment also serves its OpenAPI schema and interactive docs, so the authoritative shape is always live alongside the version you run:

PathWhat
/api/openapi.jsonThe raw OpenAPI 3 schema.
/api/docsSwagger UI — try endpoints in the browser.
/api/redocReDoc — a reference-style read-only rendering.

All three are reachable by authenticated users. In the hosted deployment the API sits behind IAP, so you reach them through the same login as the rest of the product. You can also dump the schema offline with dqc export-schema.

Response shape​

Most endpoints wrap their payload:

{"data": ...}

Errors use FastAPI's default envelope:

{"detail": "human-readable error"}

with appropriate HTTP status codes (400 / 401 / 403 / 404 / 409 / 413 / 422 / 429 / 500).

File downloads are the documented exception. Export endpoints (e.g. GET .../validation-results/export) return a file attachment — Content-Disposition: attachment; filename="…" with a Content-Type of text/csv (default) or application/vnd.openxmlformats-officedocument.spreadsheetml.sheet for ?format=xlsx — instead of the {"data": …} envelope.

Pagination​

List endpoints page with an opaque cursor: pass ?limit=N (plus &cursor=<value> for the next page) and read next_cursor from the response, next to data. It is null on the last page. On most list endpoints pagination is opt-in: omit limit and cursor to get the full {"data": [...]} list. A few older lists (for example the Results list) page with ?limit=N&offset=M and return next_offset instead. Each endpoint's parameters are in the full API reference.

Rate limiting​

Auth endpoints are rate-limited per IP and per IP+email (HARDEN-3, shipped). Mutation endpoints with abuse potential carry per-org token-bucket limits (HARDEN-18, shipped — see Configuration → Per-org rate limits for the bucket list). Rate-limited requests return 429 Too Many Requests with a Retry-After header.

Surface​

The full list belongs in the OpenAPI schema. The high-level groups:

GroupPrefixWhat's in it
Auth/auth/...Login, refresh, logout, and self-service password reset (POST /auth/forgot-password, POST /auth/reset-password — detailed below). GET /auth/me/permissions returns the caller's role + flattened permission set (#169); GET /auth/me/principal returns the caller's identity — email, full_name, role, org_id, org_name, workspace_id, ai_available, and team_ids (the live teams the caller belongs to in the org) — so the UI nav can show which account is active (#366) and which team they are in. Both 404 / fail-closed for tokens with no associated user.
Accounts/organizations/{org_id}/accounts/meWho you are; org and workspace memberships.
Datasources/api/v2/organizations/{org_id}/workspaces/{ws_id}/datasources/...A technical admin (no workspace membership needed) may list, read, create and edit data sources in any workspace of their org — and test a connection — but not delete one or reach the assets. CRUD + test + discover. PATCH .../datasources/{id} rotates credentials in place — send config_encrypted.connection_string and it is re-encrypted at rest, shallow-merged over the stored config so sibling secrets survive; linked assets/suites/history are preserved. Requires can_edit_datasource + step-up auth (#313). POST .../datasources/{id}/discover/register promotes a set of discovered tables to first-class DataAsset rows in one transaction — body {"tables": [{"schema", "name", "kind"}, ...]}, returns {"data": {assets_registered, assets_skipped, asset_ids}}. Idempotent (existing names matched on datasource_id + name are skipped, not duplicated); gated on can_create_asset like GET .../discover (#456). DELETE .../datasources/{id} (and the ?name= variant) soft-deletes the datasource and its whole owned subtree into the recycle bin — a 204, reversible for RECYCLE_BIN_RETENTION_DAYS. Editor+ (can_edit_datasource).
Data assets/api/v2/.../data-assets/...CRUD + profile-now. DELETE .../data-assets/{id} (and the ?name= variant) soft-deletes the asset into the recycle bin — a 204, reversible for RECYCLE_BIN_RETENTION_DAYS. Admin/owner (can_delete_asset).
Recycle bin/api/v2/organizations/{org_id}[/workspaces/{ws_id}]/recycle-bin/...List / preview / restore / purge soft-deleted datasources + data assets. Deleting a datasource or asset moves it (and its owned subtree) here instead of destroying it; items are restorable for RECYCLE_BIN_RETENTION_DAYS (default 30), then a daily cron purges them. Shared objects (rule groups, shared suites, validation results, agent tokens) are never touched. List / preview / restore gated editor+; permanent purge gated workspace admin / super admin. Detailed below. See Recycle bin.
RulesGET /api/v2/organizations/{org_id}[/workspaces/{ws_id}]/rules and .../rules/{rule_id}Workspace-wide reads of the first-class rules (the successor of /rule-suites). The list returns the rules you can see, newest first: {"data": [{id, data_asset_id, rule_type, kwargs, severity, name, description, enabled, position, memory_flag, memory_note, memory_updated_at, created_at, updated_at}]}. ?data_asset_id= narrows it to one asset and ?validation_id= to the rules one validation holds. Pagination is opt-in: limit (1–500) and cursor in, next_cursor out; without either you get the full list. GET .../rules/{rule_id} returns one rule plus validation_ids, the validations that hold it (404 when it's gone or you can't see it). Viewer tier (can_view_rule), workspace- and team-isolated. Rules are written on their asset (next rows).
Rule suites (deprecated alias)/api/v1/.../rule-suites/... and /api/v1/.../validation-configs/...Rule suites were retired. The legacy endpoints are a read-only alias for one release; see Deprecated rule-suite alias and Rule suites (retired).
Rule templates/api/v2/.../rule-templates and .../rule-templates/{id}Reusable, asset-agnostic rule templates — a named set of rules (a {rules: [...]} list with no asset binding) you author once and apply to assets via the asset apply-template endpoint. GET/POST/PATCH/DELETE CRUD; the GET list supports opt-in cursor pagination (?limit=). Writes validate the rule list through the shared rule validator (a malformed custom-SQL rule → 422). Reads gated can_view_rule (viewer+); create / edit gated can_create_rule / can_edit_rule (editor+), delete can_delete_rule. Workspace-isolated.
Asset-attached rules/api/v2/.../data-assets/{id}/rules and .../rules/{rule_id}The API behind the asset page's Rules card (see Rules). Each rule on a data asset is its own record with a stable id. GET returns {"data": {data_asset_id, suite_id, rules}} (suite_id is deprecated and always null), each rule {id, position, rule_type, kwargs} in order — an empty rules list (not a 404) when the asset has none. One rule at a time: POST with {"data": {rule_type, kwargs, position?}} adds one rule (201, returns the rule: {id, data_asset_id, rule_type, kwargs, severity, name, description, enabled, position, memory_flag, memory_note, memory_updated_at, created_at, updated_at}); PATCH .../rules/{rule_id} with any of rule_type, kwargs (replaces the whole object), name, description edits it in place — the id is kept, so its validation memberships survive; changing what the rule checks clears its memory, a severity-only change keeps it; DELETE .../rules/{rule_id} removes it (204) and its validation memberships. A rule of another asset, or one you can't see, is a 404. Each rule is validated by the shared rule validator (a malformed custom-SQL rule or bad severity / row condition → 422). Whole list (compatibility): PUT (or POST with a rules list) replaces the list — an entry carrying an id updates that rule in place, an entry without one matches an identical existing rule (else it's added), and rules left out are deleted. A POST body is read by its keys: rule_type adds one rule, rules replaces the list, and a body with neither is a 422 (never an empty list). DELETE clears the rule list. POST .../rules/apply-template applies a rule template to the asset: it merges the template's rules into the asset's rules (append + de-dupe on (rule_type, kwargs) — additive, never replaces hand-authored rules), remapping each rule's target column through an optional column_map ({template_column: asset_column}); dry_run: true returns the would-be merged list + added/skipped counts without persisting (it drives the apply preview). Gated can_edit_rule. GET .../rules/running is the consolidated every rule running on this asset read (#164): it returns {"data": {data_asset_id, suite_id: null, own_rules, rule_groups: [{group_id, group_name, batch_definition_id, batch_definition_name}]}} — the asset's own rules plus the validation(s) that hold at least one of them (the wire field stays rule_groups). Being in a validation doesn't add distinct rules (a validation holds the asset's own rules), so own_rules is the single rule set and rule_groups carries only labelling refs (empty when the asset is in no validation). Read-only, viewer-tier, workspace-isolated; it powers the asset-detail "Rules on this asset" card. Reads gated can_view_rule (viewer+); writes, including deleting one rule, can_edit_rule (editor+). (The in-app POST .../rules/suggest AI-suggest enqueue was retired in #133 — the profiling → rule-generation flow is now driven by your own MCP-connected LLM; see AI features.)

Rule memory (#142; API shipped, editor UI planned): each first-class rule carries a single durable annotation — an easy positive / negative flag plus a free-text "what happened" note. PUT .../data-assets/{asset_id}/rules/{rule_id}/memory sets or clears it (body {"data": {flag, note}}, flag ∈ positive / negative / null, note ≤ 4000 chars), returning {"data": {rule_id, flag, note, updated_at}}; a foreign / soft-deleted / cross-asset rule → 404; editor-tier (can_edit_rule). POST .../rules/{rule_id}/memory/draft is an optional one-shot "Draft with AI" that returns {"data": {draft, narrative_status}} — a suggested note grounded in the rule + a small sample of its recent failing runs — without persisting (you edit/accept it, then save via the PUT); it degrades gracefully (narrative_status no_llm_config when no default LLM config, error on a provider blip) and is can_edit_rule + org-rate-limited. The flag/note also surface on each GET .../data-assets/{asset_id}/rules/first-class item (memory_flag / memory_note / memory_updated_at). Each item also carries rule_key: the opaque 32-character identity stamp a run puts on every result the rule produces (meta.dq_rule_key on a direct-mode result, a top-level rule_key on an agent-mode one). It is a hash, never the rule's settings, so an agent-mode result, whose settings are redacted on-premises, can still be matched to its rule. Memory rides on the rule row, so it dies with the asset and is dropped if the rule's own type/kwargs change (consistent with validation memberships).
Import from Great ExpectationsPOST /api/v1/.../data-assets/{id}/rule-suites/importImport an existing Great Expectations ExpectationSuite onto an asset (see Coming from Great Expectations). The body is the GX suite inside the {"data": ...} envelope — liberal in what it accepts: a full suite object with an expectations list (each expectation typed by type (GX 1.x) or expectation_type (GX 0.x); name or expectation_suite_name) or a bare list of expectation objects. Every expectation becomes one of the asset's rules (form-editable when its type is in the gallery; an unknown type still imports and runs but isn't form-editable). Returns {"data": {data_asset_id, suite_id, imported_count, editable_count, unknown_types}} (suite_id is deprecated and always null). Importing onto an asset that already has rules → 409 (clear them first). A body that isn't a recognisable GX suite → 422. Gated can_edit_rule (editor+); org-rate-limited; workspace-isolated.
Asset and rule runsPOST /api/v2/organizations/{org_id}/workspaces/{ws_id}/data-assets/{asset_id}/rules/run and .../data-assets/{asset_id}/rules/{rule_id}/runRun one data asset, or one of its rules — the asset page's and Rules card's ▶ Run. Both start an execution run of the asset's default validation: the asset run with trigger: "asset", the rule run restricted to that rule (rule_ids: [rule_id]) with trigger: "rule". Both work for direct and agent-mode (on-prem DQ Agent) data sources. 202 with {"data": {validation_id, parent_result_id, execution_run_id, execution_mode, status: "pending", trigger, rule_ids, run_queue_id: null, checkpoint_id: null}} — validation_id is the default validation, parent_result_id the Stop handle for POST /api/v2/.../validations/{validation_id}/run/cancel, execution_run_id the run to poll; run_queue_id and checkpoint_id are deprecated and always null. Gated can_run_validation (editor+), rate-limited on the run bucket (RATE_LIMIT_CHECKPOINT_RUN_PER_MINUTE) and counted against the monthly run quota. An asset or rule you can't see, a rule of another asset, or a disabled rule is a 404; an asset with no rules is a 422, and one whose rules are all disabled a 409.
Validations/api/v2/.../validations and .../validations/{id}The validation tier-1 runnable unit (#640). Internal name: the API path is /validations, but the wire fields (rule_group_id/rule_group_name) and DB table stay rule_group (#206) — the internal machine names are unchanged. GET/POST/PATCH/DELETE CRUD (create/edit gated can_manage_validation, the delete gated can_delete_resource); GET .../{id}/members is a deprecated derived list (one entry per asset the validation's rules cover: {id, rule_group_id, rule_suite_id: null, data_asset_id, batch_definition_id, created_at, deprecated: true}, with Deprecation and Link headers), and POST .../{id}/members / DELETE .../{id}/members/{member_id} return 410 Gone: the asset list was retired, so add or remove rules instead. The detail read's members field is the same derived list. POST .../{id}/run runs the validation (202, below). GET .../validations/asset-memberships is the asset→validation reverse lookup (#149): for the whole workspace it returns {"data": [{data_asset_id, rule_groups: [{group_id, group_name}]}]} — the validation(s) holding at least one of each data asset's rules (its default validation included), in one query. Only assets with a rule in ≥1 validation appear; the rest are absent. Read-only, viewer-tier, workspace-isolated; it powers the Data Assets list's "Validations" column and is reused by the "every rule on an asset" surface (#164).

Asset defaults: every data asset with rules has one default validation holding all of its rules. Reads carry is_default and default_for_asset_id; the list takes ?include_defaults=false to leave them out (default true; a binned asset's default is hidden). Deleting a default, or editing its rules, is a 409. The data-asset GET (/api/v1/.../data-assets/{id}) returns default_validation_id. GET .../{id}/rules rows and the asset's rule-membership chips carry source: default (the asset's default validation), asset_list (the asset is in all mode there) or explicit. DELETE .../{id}/rules/{rule_id} takes one rule out of any validation except a default (409); taking one out of an asset in all mode switches that asset to selected, keeping its other rules.

Choosing rules: a validation holds rules one by one. GET .../{id}/assets returns {"data": [{data_asset_id, data_asset_name, mode, member_id, batch_definition_id, rule_ids, asset_rule_count}]}, one entry per asset its rules cover; mode is all when the asset is in "All rules" mode (its rows read source: asset_list), else selected, and member_id is always null. PUT .../{id}/assets/{asset_id}/rules with {"data": {"mode": "all" | "selected", "rule_ids"?: [...], "batch_definition_id"?: ...}} sets which of the asset's rules the validation holds, with set semantics: all holds every rule of the asset, and a rule added to the asset later joins automatically; selected holds exactly rule_ids, and later rules don't join. The response adds previous_mode, added_rule_ids, removed_rule_ids; selected needs at least one live rule of the asset (422 otherwise); batch_definition_id must be a batch of that asset (422 for another asset's, 404 when unknown); omitted, each rule keeps its scope. DELETE .../{id}/assets/{asset_id} takes the asset out. POST .../{id}/rules takes an optional batch_definition_id (of the rule's asset) and PATCH .../{id}/rules/{rule_id} {"data": {"batch_definition_id": ... | null}} re-scopes one rule. PUT /api/v2/.../data-assets/{asset_id}/rules/{rule_id}/validations with {"data": {"validation_ids": [...], "create_validation"?: {"name": ...}}} sets which curated validations a rule is in (the default always keeps it) and returns {rule_id, validations, added, removed, created_validation_id}. Rule creation (POST .../data-assets/{asset_id}/rules, and each entry of the whole-list PUT) and the AI accept (POST /api/v1/.../ai-suggestions/{id}/accept) take the same validation_ids / create_validation, applied atomically with the rule write. Every membership write needs can_manage_validation; a validation you can't see is a 404, one in another workspace than the rule or asset a 400, and an asset's default validation a 409.

Run: POST .../validations/{id}/run runs exactly the validation's enabled rules (its validation_rules rows on live, enabled rules), one GX pass per asset and effective batch: each rule's batch is its membership's batch_definition_id unless the run overrides it. The body is optional — {"data": {batch_definition_id?, partition_value? | partition_values?, rule_ids?}}: batch_definition_id runs that definition's asset's rules on it (other assets keep their batches; 404 when it doesn't exist, 422 when its asset has no rule in the run or runs through the on-prem agent, which validates an asset whole); partition_value (or partition_values, up to 100 — one run per value) picks the partition of a column_partition definition (422 for another kind, or without batch_definition_id); rule_ids runs only those rules of the validation (422 for a rule that isn't in it). Running (and .../run/cancel) needs can_run_validation. The response is {"data": {rule_group_id, parent_result_id, execution_run_id, member_count, runnable_count, has_agent_member, status, runs: [{parent_result_id, partition_value}]}} — execution_run_id (additive) is the execution run to poll (GET .../execution-runs/{id}) — member_count = the assets the run touches, runnable_count = its passes; parent_result_id is the first of runs. A validation with nothing runnable (every rule disabled, or its assets gone) is a 409, one with no rules and no assets a 400. The run's scope is recorded on its execution run (below). Agent-mode assets: the agent's job carries the run's rule-id snapshot and its claim envelope is built from exactly those rules (same envelope keys as before), so a rule_ids subset (including a single rule's run) is honoured on agent-mode assets too.

The rules a run executes: GET .../validations/{id}/run-rules returns {"data": [{member_id, rule_suite_id, data_asset_id, data_asset_name, batch_definition_id, batch_definition_kind, has_scoping_batches, runnable, rules: [{rule_id, rule_type, kwargs, severity, name}]}]} — one entry per pass of a run (an asset, or one batch of it, by asset name), each with the rules that pass executes. batch_definition_kind is the kind of the pass's batch (whole_asset, column_partition or interval; null without one), and has_scoping_batches is true when the asset has a batch that checks only part of it (any kind but whole_asset); the validation page shows its batch controls only then. It is the run engine's own plan, so it lists the validation's enabled rules (GET .../{id}/rules lists every membership, disabled rules included). member_id is a stable id derived from the pass, rule_suite_id is always null (deprecated) and runnable is always true. rule_type + kwargs are the stored rule verbatim. Viewer-tier (can_view_rule), workspace-isolated, read-only; it powers the validation page's Rules card.

Execution runs: GET .../validations/{id}/execution-runs lists a validation's runs (in-flight and finished, newest first; each carries result_id, the run's own result page /results/{id}, or null when none is recorded — GET .../execution-runs/{run_id} carries it too) and GET .../execution-runs/{run_id}/results its per-asset and per-rule results. Each result also carries validation_result_id — the per-asset result (/results/{id}) it came from, null when none is recorded (for example, an asset still running) — so a run breakdown can link each asset and rule to its result. Each run also carries its scope: trigger (manual, schedule, api, asset — an asset's run, or a legacy checkpoint run filed by migration 063 — or rule, a single rule's run; null on older runs), schedule_id, the per-run override batch_definition_id / partition_value, and rule_filter (the rule ids it was restricted to, else null); total_assets / completed_assets count its passes (asset x batch). GET .../execution-runs/{run_id}/progress returns a run's live progress: {"data": {run_id, validation_id, status, in_flight, parent_result_id, cancel_requested, total_assets, completed_assets, assets: [{position, rule_suite_id, data_asset_id, data_asset_name, execution_mode, batch_definition_id, rule_ids, state, validation_result_id}]}} (rule_suite_id is deprecated and always null). assets lists every pass of the run (an asset, or one batch of it — batch_definition_id) in the order the worker runs them; rule_ids names the rules the pass runs. Each state is queued, running, stopping (a Stop was requested while it runs), passed, failed, cancelled (it won't run, or was interrupted, because the run was stopped) or not_run. parent_result_id is the handle POST .../validations/{id}/run/cancel takes: {"data": {"parent_result_id": ...}} stops the whole run, and adding "data_asset_id" stops only that asset's part of it (the asset is skipped or interrupted, the rest of the run carries on, and the asset reads cancelled; an asset the on-prem agent already claimed finishes there). A per-asset Stop takes out every pass of that asset. An asset that isn't in the run is a 404. The state is derived from rows the run already keeps, so it is at pass grain: the engine checks all of a pass's rules together. Viewer-tier (can_view_results). Each run's health_score is that run's own pass rate (the share of its rule checks that passed), never blended with earlier runs; previous_health_score records the validation's score before the run, for reference.

Team ownership: reads carry team_id (null = workspace-shared) and team_restricted. POST and PATCH accept both: assigning one of your teams to an unowned validation is editor tier; changing or clearing a team, or flipping team_restricted, needs workspace admin on the validation's workspace (403); team_restricted: true without a team is a 422. A team state that would mix two teams' rules under a restriction is a 422 "mixing two teams' assets" (so is listing such an asset, or adding such a rule by any path: POST .../{id}/rules, PUT .../{id}/assets/{asset_id}/rules in either mode, PUT .../data-assets/{asset_id}/rules/{rule_id}/validations, and a rule create or AI accept with validation_ids; nothing is written). A validation created inline (create_validation) takes the asset's team, restricted, when the asset is restricted, and no team otherwise. A default validation's team is its asset's (409). GET .../{id}/rules and GET .../data-assets/{id}/rules/first-class rows carry the rule's team_id (its asset's). Data assets take the same team_id / team_restricted on PATCH /api/v1/.../data-assets/{id}. See Team ownership.

Health: every list and detail read (and the create / patch responses) carries window_health — {window_days, health_score, failure_rate, checks_evaluated, checks_passed, checks_failed, runs, runs_failed} — the headline health score: 100 × passed ÷ evaluated rule checks over the validation's runs in the last 30 days. ?window_days= (1–365, default 30) on the list and detail reads picks another window; out of range is a 422. health_score / failure_rate are null when nothing was evaluated (runs tells "no runs" apart from "runs that errored"). runs counts the validation's finished runs in the window and runs_failed the failed ones. Computed on read in one grouped query per page. The validation's own health_score field is its latest run's score.
Expectation gallery/api/v1/expectations/galleryRead-only catalogue of every form-builder expectation type — one-sentence description, example payload, kwargs schema, and applicable column types per entry. Tenant-independent (FEATURE-40).
Custom expectations/api/v1/organizations/{org_id}/custom-expectations and .../custom-expectations/packagesList the great_expectations.Expectation subclasses discovered from EXPECTATIONS_PLUGIN_PACKAGES; the /packages route surfaces per-package import status so an operator can spot import failures without tailing worker logs (FEATURE-35).
Checkpoints (deprecated)/api/v1/.../checkpoints/...A one-release, read-and-run alias over the retired checkpoints; writes return 410 Gone. Removed in the next major SDK release. See Deprecated checkpoint alias below and Checkpoints (retired).
Schedules/api/v1/.../schedules, /api/v1/.../validations/{id}/schedules and /api/v1/.../schedules/{id}Cron schedules. A schedule runs 1 to 50 validations (validation_ids), and each firing starts one execution run per validation (trigger: "schedule", schedule_id). POST .../schedules requires validation_ids. The per-validation POST adds any validation_ids to its path validation. PATCH with validation_ids replaces the list. GET .../schedules?validation_id= filters. A validation you can't see, or one in another workspace, is a 404; an empty list or mixed team-restricted work is a 422. rule_group_id is a deprecated alias, set only while a schedule runs exactly one validation. The deprecated POST .../checkpoints/{id}/schedules creates a schedule for the checkpoint's mapped validation, and a legacy checkpoint schedule nobody edited keeps firing that validation (a checkpoint that maps to no validation is skipped). A create without timezone runs in the organization's schedule timezone (below); an explicit IANA timezone is still accepted, and an unknown one is a 400. See Schedules.
Schedule timezoneGET / PUT /api/v1/organizations/{org_id}/schedule-timezoneThe organization's one schedule timezone: every new schedule runs in it and the app shows schedule times in it. GET (any org member; 403 cross-org) returns {"data": {timezone, source, mismatched_schedules, schedule_timezones, aligned_schedules}}: source is "default" (UTC) until an admin saves one, then "org"; mismatched_schedules counts the schedules you can see whose stored zone differs, and schedule_timezones is [{timezone, schedules}] over the schedules you can see, keyed by the zone exactly as stored (a blank one is ""; such a schedule, like one with an unrecognised zone, runs in UTC), most-used first. PUT with {"timezone": "<IANA zone>", "align_schedules"?: false} sets it. It needs can_manage_org (org admin), and a workspace- or team-scoped service account gets a 403. An unknown zone, or a region name such as America, is a 422. Saving never moves an existing schedule; align_schedules: true also moves every schedule in another zone onto it (next runs recalculated) and aligned_schedules reports how many. Audited as schedule_timezone; an align's audit entry lists every moved schedule with its previous zone (moved_schedules: [{id, old_timezone}]). See Set the schedule timezone.
Team scopingteam_id on schedules, alert rules, and the read-only legacy rule suites, validation configs and checkpointsOptional team pin on the five team-scoped resources. Send team_id in any create/update body: 422 "Invalid team for this workspace" if the team is unknown / deleted / in another org or workspace, 422 "Assigning a team requires a workspace-scoped resource" when there's no workspace anchor at all, 403 if you can't reach it (member or workspace-admin-or-higher). Omit it on create and the resource inherits the referenced parent's team (rule suite → validation config, validation → schedule / alert rule); a body value that names a different team than the parent's is a 422, and an explicit null child under a team-scoped parent needs workspace-admin-or-higher (the child stays pinned to the parent's workspace). Un-scoping on update (team_id: null) also needs workspace-admin-or-higher and keeps the workspace pin. All five resources expose team_id in their response payloads. See Team-scoped resources.
Validation results/api/v1/validation-results/...List + detail. The list accepts optional filters: ?checkpoint_id=<uuid> (deprecated: one retired checkpoint's runs, plus the runs of the validation it maps to), ?status=pass|fail, an inclusive run_time window via ?start_date= / ?end_date= (ISO-8601), ?asset=<text> (the owning data asset's name contains the text, case-insensitive), and ?data_asset_id=<uuid> (one data asset's results). Filters compose (AND) and work alongside ?limit= / ?offset= pagination (#311 / #339 / #318 / #315). ?sort= orders the list by run_time, result, validation or asset (prefix - for descending; default -run_time, newest first), with run_time DESC, id DESC as the tie-breaker so offset pagination stays stable. Both list and detail rows carry the owning data asset as data_asset_id / data_asset_name, resolved for every per-asset row (a result names its asset itself). On the list, both are null for a run's rollup row, or when the asset is in the recycle bin or hidden from the caller. rule_suite_id / rule_suite_name are deprecated: rule suites were retired, so they are set only on results written before then (null since). Results are minted by the worker after a run and are read-only through the API: no write verb is registered, so POST/PUT/PATCH/DELETE return 405 Method Not Allowed — a result can't be forged, rewritten, re-linked, or deleted by a client.
Validation results — CSV / Excel exportGET /api/v1/organizations/{org_id}[/workspaces/{ws_id}]/validation-results/exportDownload the (filtered) results list as a file attachment (Content-Disposition: attachment). ?format= selects the container: csv (default, Content-Type: text/csv) or xlsx (Excel, Content-Type: application/vnd.openxmlformats-officedocument.spreadsheetml.sheet). Accepts the SAME filters as the list (?checkpoint_id= / ?status=pass|fail / ?start_date= / ?end_date= / ?batch_definition_id= / ?asset=), so a filtered export matches the on-screen filtered view. Both formats carry the same columns: run_time, checkpoint, data_asset, rule_suite, status, success, severity, batch_kind, partition_value, result_id — the CSV precedes them with a #-comment preamble (org/workspace id, export timestamp, filters); the xlsx puts the same metadata in a labelled block above a frozen header row. Capped at 50,000 rows — past that the endpoint returns 400 and asks you to narrow the date range. Gated by can_view_results (export what you can see). PDF export is the browser print dialog, not a server endpoint. See Export validation results (#408 / #664).
Validation results — reporting summaryGET /api/v1/organizations/{org_id}[/workspaces/{ws_id}]/validation-results/summaryThe read-only quality-report aggregate powering the Reports page. Returns {"data": {total_runs, passed, failed, pass_rate, trend, worst_checkpoints, filters}} — pass_rate is a 0–1 fraction (or null when there are no runs in scope), trend is a per-day [{date, pass, fail}] series (oldest→newest), and worst_checkpoints is the top failing-checkpoint rollup [{checkpoint_id, checkpoint_name, failed, total}] (a legacy field: it ranks retired checkpoints' runs only). Accepts the SAME filters as the list / export (?checkpoint_id= / ?status=pass|fail / ?start_date= / ?end_date= / ?batch_definition_id=), so a filtered report's numbers match a filtered Results view (and a filtered CSV export). Gated by can_view_results. First cut of the reporting module — see Build a quality report (#407).
Results — "All workspaces" aggregatorGET /api/v1/organizations/{org_id}/results/allThe Super Admin org-wide roll-up (Phase-2 Unit 3). Returns {"data": [recent results], "summary": {total_count, by_workspace: {"<ws-uuid>": {count, passed, failed}, …}, by_team: {"<team-uuid>": {count, passed, failed}, …, "workspace_shared": {…}}, by_status: {success, failed}, workspace_names: {"<ws-uuid>": {name, archived}, …}, team_names: {"<team-uuid>": {name, archived}, …}}, "next_cursor"}. workspace_names / team_names name every uuid key in by_workspace / by_team, including a deleted (archived) workspace and its retired teams (archived: true), whose runs stay in the roll-up; clients can't open an archived workspace's runs. The summary is a SQL GROUP BY workspace_id (and, for by_team, GROUP BY the owning team: the run's validation's team, else its legacy checkpoint's; no team → "workspace_shared") over the same scope the data page walks, and is returned on the first page only — cursor pages return summary: null (the roll-up is identical across a walk; keep the first page's value). Results with workspace_id IS NULL (org-wide rows) appear under the "org_shared" key. Page rows omit the full result payload — fetch the result detail endpoint for it. Each page row carries the owning asset as data_asset_id / data_asset_name (null when there's none the caller can open), its workspace_id, and its owning team_id (the run's validation's team, else its legacy checkpoint's; null for none), on this endpoint and the per-team one below. data is cursor-paginated newest-first (?cursor= / ?limit=, default 50, max 200; next_cursor null on the last page) and both the page and the summary accept ?since=<ISO8601> (inclusive run_time lower bound; a naive timestamp is interpreted as UTC) and ?checkpoint_id=<uuid>. Requires the super admin role (super_admin) and can_view_results — callers without full-org visibility get a 403, not a partial roll-up. Agent (dqa_) / SCIM tokens are rejected.
Results — "All teams" aggregatorGET /api/v1/organizations/{org_id}/workspaces/{ws_id}/results/allThe Workspace Admin per-team roll-up (Phase-2 Unit 3). Returns {"data": [recent results], "summary": {total_count, by_team: {"<team-uuid>": {count, passed, failed}, …, "workspace_shared": {…}}}, "next_cursor"}. validation_results carries no team_id, so team ownership is derived via the parent legacy checkpoint (GROUP BY checkpoints.team_id over a LEFT JOIN); results whose checkpoint is workspace-shared (team_id IS NULL) or that have no parent checkpoint at all — which includes every validation run, since checkpoints were retired — land in the "workspace_shared" bucket. Gated by workspace membership + can_view_results; the roll-up never bypasses isolation — a workspace admin sees every team's bucket, a team-tier caller sees only their teams' + workspace_shared (a sibling team's results are absent from both data and the summary, and total_count always equals the rows the caller can page through). Same ?cursor=/?limit=/?since=/?checkpoint_id= params and first-page-only summary semantics as the org-level aggregator.
Dashboard metrics (workspace / team)GET /api/v1/organizations/{org_id}/workspaces/{ws_id}/dashboard/metrics?period=30The workspace-scoped Dashboard v2 roll-up — one read that powers every dashboard card (so each card reads a field instead of firing its own list query). ?period= is one of 30 | 60 | 90 | 365 days (the 30/60/90d/1y selector); anything else is a 422. Returns {"data": {period_days, health_score, failure_rate, checks_evaluated, checks_passed, checks_failed, pass_rate, total_runs, by_status: {passed, failed}, coverage: {covered, total, rate}, severity_counts: {critical, warning, info}, by_group: [{key, passed, failed, name, archived}], group_by, scope, team_id}}. total_runs / by_status count finished runs — one per run (a validation run's rollup, never its per-asset results; a run still in progress isn't counted), the same unit the Reports summary (GET .../validation-results/summary) counts — and by_group splits those runs by owning team (group_by: "team"; key is the team id, or workspace_shared for a validation with no team) or, on the org roll-up, by workspace (group_by: "workspace"); the groups sum to total_runs. Each group carries the server-resolved name of its team or workspace (null for workspace_shared) and archived: true for a deleted workspace or retired team, whose runs stay in the breakdown — label it "(deleted)" and never link into it (#279). health_score is 100 × passed ÷ evaluated rule checks over the period, 0–100 to one decimal (or null when the period evaluated no checks) — the same definition as a validation's or data asset's health score, with the period as the window; failure_rate is 100 − health_score, and the checks_* fields are the counts behind them; pass_rate is the whole-result success rate over the period as an integer % (or null with no runs); coverage is the share of in-scope data assets with ≥1 run in the window; severity_counts buckets the period's failed expectations by severity. ?team_id=<uuid> (#665) narrows the metrics to one team's slice — it must be a live team of this workspace (else 404), and health_score/pass_rate/total_runs/by_status/severity_counts/coverage.covered all narrow to that team's checkpoints+suites plus workspace-shared (team_id IS NULL) rows (a team-tier caller can never widen to a sibling team they can't see). coverage.total is the one metric that stays workspace-level under a team filter — data assets are workspace-shared by design and carry no team. scope echoes workspace / team; team_id echoes the resolved filter. Viewer-readable (can_view_results + workspace membership); isolation-respecting. Agent (dqa_) / SCIM tokens are rejected. (#657, #665)
Data-asset healthGET /api/v2/organizations/{org_id}/workspaces/{ws_id}/health/data-assets, GET /api/v2/organizations/{org_id}/workspaces/{ws_id}/data-assets/{asset_id}/health (and the org-scoped /api/v2/organizations/{org_id}/... forms)A data asset's health score: 100 × passed ÷ evaluated rule checks over every run that checked it in the last ?window_days= days (1–365, default 30; out of range is a 422), from validation runs and asset / single-rule runs alike. Both return {data_asset_id, window_days, health_score, failure_rate, checks_evaluated, checks_passed, checks_failed, runs}. The list form returns {"data": [...]} for every asset with at least one run in the window, in one grouped query (an absent asset had no runs). The single-asset form returns {"data": {...}} with runs: 0 and null percentages when nothing ran, and a 404 when the asset isn't visible to you. Gated on the asset's own visibility, so a restricted viewer never sees a restricted asset's health. Viewer-readable (can_view_results + workspace membership). Isolation-respecting; agent (dqa_) / SCIM tokens are rejected.
Dashboard metrics (org roll-up)GET /api/v1/organizations/{org_id}/dashboard/metrics?period=30The org-wide Dashboard v2 roll-up (#665): the same metrics aggregated across the caller's accessible workspaces (no {workspace_id} in the path, so the workspace clamp never fires). Same response shape as the workspace endpoint, with scope: "org" and team_id: null. Super admin ONLY — mirrors the results/all org aggregator exactly: a caller below super_admin (or the legacy owner/admin compat spellings) gets a 403, not a partial roll-up, because an org-wide roll-up only makes sense for a caller with full-org visibility. The metric SQL is shared with the workspace tier so the two can't drift. No ?team_id= (team is a within-workspace concept). Agent (dqa_) / SCIM tokens are rejected. (#665)
Dashboard asset health (workspace / team / org)GET /api/v1/organizations/{org_id}/workspaces/{ws_id}/dashboard/asset-health, GET /api/v1/organizations/{org_id}/dashboard/asset-healthThe asset-level clean/failing roll-up + failure-owner attribution (#142) — distinct from the run-level dashboard/metrics. Each data asset is counted once, on its latest validation result (a table that failed once and has passed since reads "good"). Returns {"data": {total_assets, assets_with_results, good, failing, never_run, by_owner: [{owner_email, failing_assets}], scope, team_id, team_narrowed}}. good/failing split the latest-result-per-asset set (they sum to assets_with_results); never_run is total_assets − assets_with_results; by_owner groups the currently-failing assets by the free-text data_assets.owner_email (no FK — rendered verbatim; a null owner is the "unowned" bucket), largest-first, capped. Viewer-readable (can_view_results + workspace membership) on the workspace endpoint; super admin ONLY on the org endpoint (mirrors dashboard/metrics — a caller below super_admin/legacy owner/admin gets a 403). ?team_id=<uuid> narrows good/failing/by_owner to one team's runs (live team of this workspace, else 404; a team-tier caller can't widen to a sibling team), but total_assets/never_run stay workspace-level — assets are workspace-shared by design and carry no team, so the response sets team_narrowed: false to signal the asymmetry (the same coverage.total behaviour on dashboard/metrics). Isolation-respecting; agent (dqa_) / SCIM tokens are rejected.
BI feeds — asset statusGET /api/v1/organizations/{org_id}/workspaces/{ws_id}/bi/asset-status, GET /api/v1/organizations/{org_id}/bi/asset-statusFlat, dqk_/dqsa_-authable tabular rows a BI tool (PowerBI / Tableau) pulls into a table (#37) — one flat row per data asset, keyed on its latest validation result. Distinct from dashboard/asset-health (a nested roll-up); this is one scalar-column row per entity. Returns {"data": [{asset_id, asset_name, asset_type, datasource_id, datasource_name, workspace_id, owner_email, tags, is_restricted, status, last_run_time, last_result_id, rule_suite_id, rule_suite_name}], "next_cursor"}. status is passing / failing / never_run ("latest result wins" — a table that failed once and has passed since reads passing). No secrets — the datasource is named but its connection config and the raw result payload are never surfaced. Pagination is opt-in (#417): omit ?limit= for the whole set in one response (next_cursor: null), or supply ?limit= (≤ 5000) and follow next_cursor (the cursor keys on the asset, not last_run_time, so never_run rows survive the walk). ?team_id=<uuid> (workspace form, live team of this workspace else 404) narrows the runs feeding each asset's status. Viewer-readable (can_view_results) on the workspace form; super admin ONLY on the org form. Isolation-respecting; agent (dqa_) / SCIM tokens are rejected. See Connect PowerBI or Tableau.
BI feeds — runsGET /api/v1/organizations/{org_id}/workspaces/{ws_id}/bi/runs, GET /api/v1/organizations/{org_id}/bi/runsThe time-series companion to bi/asset-status (#37) — one flat row per validation run, newest-first, over an optional ?since=/?until= window (inclusive run_time bounds; naive timestamps read as UTC). Returns {"data": [{result_id, run_time, created_at, workspace_id, team_id, asset_id, asset_name, rule_suite_id, rule_suite_name, checkpoint_id, checkpoint_name, rule_group_id, rule_group_name, success, status, batch_kind, partition_value}], "next_cursor"}. status is passed / failed (from the authoritative success flag). v1 cut: run-level columns only — per-expectation pass-rate / severity columns need the heavy result payload and are a documented follow-up; the result JSONB is never surfaced. Same opt-in cursor pagination as bi/asset-status ((run_time DESC, id DESC), identical to results/all), ?team_id=<uuid> narrowing (workspace form), and auth posture (dqk_/dqsa_ pass; agent / SCIM rejected; org form super admin only). See Connect PowerBI or Tableau.
Result comments/api/v1/.../validation-results/{id}/comments/...Annotations on validation results: list, create, edit (author/owner), delete (author/owner).
Root cause analysisGET /api/v1/.../validation-results/{id}/rcaRead-only correlation of a failed result's signals into ranked likely causes. Viewer-readable. Optional AI narrative via ?include_narrative=true. Detailed below (FEATURE-410).
Profile runs/api/v1/ai/profile-runs/...List + detail + run-now. Stop: POST /api/v1/organizations/{org_id}/workspaces/{ws_id}/profile-runs/{run_id}/cancel flips a pending / running run to the terminal cancelled status and returns {"data": {id, status, cancelled}} (cancelled: true only when this call stopped it). It is cooperative: a queued run never starts; a run already sampling finishes that pass but its stats are discarded and drift / anomaly detection is skipped, so the asset's previous profile stays current. Idempotent — an already-finished run is a no-op 200 (never a 409). Gated can_edit_asset (the same tier as starting a profile run), rate-limited on the profile-runs bucket, audit-logged (action=cancel, resource_type=profile_run), workspace-isolated (a run whose asset is in another workspace 404s like an unknown id). cancelled is also accepted by the list's ?status= filter.
Active runsGET /api/v1/organizations/{org_id}/workspaces/{ws_id}/active-runsEverything in flight in the workspace right now, newest first — the read the UI's run buttons derive their spinner from, so a run started on another page, before a refresh, by a schedule, or by another user still shows as running (and stoppable). {"data": [{kind, run_id, status, cancel_requested, started_at, validation_id, checkpoint_id, data_asset_id, rule_id, rule_ids, trigger, schedule_id, running_asset_ids, running_asset_id, validation_name}]} where kind is validation (a validation run's parent rollup still pending / running; run_id is the parent_result_id the validation-run cancel takes; running_asset_ids lists the data asset(s) the run is on right now, derived the same way as the execution-run progress read, and running_asset_id is the first of them; both are empty / null while the run is still queued, and an asset stopped on its own drops out of them; validation_name names the validation; trigger says what started it, and an asset's or a single rule's run — trigger asset / rule — also sets data_asset_id, and for a rule run rule_id / rule_ids), checkpoint (legacy, drain-only: a run-queue row queued before checkpoints were retired, still pending / claimed / running; run_id is the run-queue id; nothing creates these any more), or profile (a pending / running profile run). Runs started more than 24 hours ago are left out (orphans shouldn't spin forever). Readable by any workspace member (viewers too); dqa_ agent tokens get 403. Workspace- and team-isolated; a restricted viewer doesn't see runs of restricted assets.
Anomalies/api/v2/.../anomaly-events/...List + ack. Scoped to the workspace in the path (through the anomaly's data asset; an org-wide asset's anomalies show in every workspace): the list and export return only that workspace's anomalies, and acknowledging another workspace's anomaly is a 404. Profile runs and AI suggestions are scoped the same way.
AI suggestions/api/v2/.../ai-suggestions/... and /api/v1/ai/...List + accept + dismiss + explain. POST /api/v1/organizations/{org_id}/workspaces/{ws_id}/ai-suggestions/{id}/accept takes a bare body {"data_asset_id"?, "validation_ids"?, "create_validation"?}: the accepted rules become first-class rules on data_asset_id (default: the suggestion's own asset; a different asset is a 400) and join its default validation plus any validation_ids. It returns {rule_suite_id: null, data_asset_id, rules_added, total_rules, rule_ids}. rule_suite_id in the body is deprecated: given alone, it resolves to the asset its legacy suite was attached to (a legacy suite with no asset is a 422). Bulk accept (.../ai-suggestions/bulk-accept) takes {suggestion_ids, data_asset_id?, rule_suite_id? (deprecated)}. A rule_suggestions suggestion is editor tier (can_edit_rule, no step-up); other kinds need admin tier plus step-up.
Explain a resultPOST /api/v1/organizations/{org_id}/workspaces/{ws_id}/explainBody {validation_result_id} or {anomaly_event_id}, plus optional extra_context and regenerate. Returns {explanation, llm_config_id, prompt_tokens, completion_tokens, cached}. A validation result's explanation is cached against its exact prompt (the worker pre-generates it after a failed run), so a repeat request returns cached: true without an LLM call; regenerate: true skips the cache and replaces it. Requires can_view_results; rate-limited on the per-org AI bucket.
Cached result explanationGET /api/v1/organizations/{org_id}/workspaces/{ws_id}/validation-results/{result_id}/explanation{"data": {explanation, generated_at}} — the result's explanation if one is already cached, else nulls. Never calls an LLM. Requires can_view_results; workspace-isolated (404 for a result you can't see).
AI profiling + rule suggestions (native, Phase 2)POST /api/v1/organizations/{org_id}/workspaces/{ws_id}/ai/profile-summary and .../ai/suggest-rulesServer-side, native-or-BYOLLM AI over an asset's latest completed profile. profile-summary (kind profile_summary) → a read-only markdown data-quality narrative + structured per-column findings (severity, observation, suggested check, PII/freshness/completeness flags). suggest-rules (kind rule_suggestions) → LLM-authored rule proposals, each validated against the expectation-gallery kwargs schema server-side (invalid ones dropped); accept them as rules on the asset via .../ai-suggestions/{id}/accept. Both take {"asset_id": …}, enqueue a worker job, return a suggestion_id, and are polled via GET .../ai-suggestions/{id}. Gated on can_create_rule + workspace membership + the shared AI-suggestions rate bucket; no step-up (a dqk_ / MCP bearer can call them). Under native AI they work with no LLMConfig. Optional "reuse_existing": true returns a still-fresh suggestion of the same kind for the asset (newer than its latest profile, or already in flight) instead of enqueueing a new one; suggest-rules ignores it when guidance is set. profile-summary no longer needs a profile: without one it summarises the asset's metadata and the payload carries profile_based: false plus a note.
AI severity review (#317)POST /api/v1/organizations/{org_id}/workspaces/{ws_id}/ai/severity-reviewBody {"asset_id": …}. Asks the AI to propose a relative severity for each of the asset's existing rules; enqueues a worker job on a severity_review suggestion and returns {"data": {"suggestion_id": …}}. Poll GET .../ai-suggestions/{id}: payload.reviews is one {rule_id, rule_type, kwargs, current_severity, proposed_severity, reason} per rule. Read-only, with no accept endpoint: apply a proposal by editing the rule's kwargs.severity. Same gates as suggest-rules (can_create_rule, workspace membership, the AI-suggestions rate bucket, no step-up); 404 unknown asset, 422 an asset with no rules, 400 no AI engine. A rule_suggestions proposal the model left unrated has severity: null, and accepting a batch that still holds one is a 422. See Severity review.
AI auto-runGET /api/v1/organizations/{org_id}/ai-autorunWhich AI features generate without a click (on page open and in the background after a profile / run) — {"data": {asset_summary, rule_suggestions, failures_review, rca, explain, source, editable}}. Readable by any org member (the AI panels read it); 403 cross-org. source is "deployment" while the org follows the deployment default (AI_AUTORUN_FEATURES) and "org" once someone who manages the AI engine has saved its own choice. See Auto-run.
Set AI auto-runPUT /api/v1/organizations/{org_id}/ai-autorunSave the org's auto-run switches. Body: any of asset_summary, rule_suggestions, failures_review, rca, explain (booleans; an omitted flag keeps its current value). Requires can_manage_llm_configs; audited as ai_autorun. Returns the same shape as the GET, with source: "org".
AI validation drafts (validations epic PR 6)POST /api/v1/organizations/{org_id}/workspaces/{ws_id}/ai/validation-drafts and .../ai/validation-drafts/{id}/acceptDraft a whole validation with AI. Generate takes {"guidance": …, "data_asset_ids"?: [...]} (assets optional: without them the AI picks from the workspace catalog), enqueues the generate_validation_draft job on a validation_draft suggestion and returns {suggestion_id, reused} (reused = an identical request of yours from the last hour, still unaccepted). Poll GET .../ai-suggestions/{id}; the payload is {name, description, items: [{data_asset_id, asset_name, reason, existing_rules: [{id, rule_type, kwargs, severity, selected, duplicate}], new_rules: [{rule_type, kwargs, severity, rationale, confidence}]}], picked_by_ai, dropped, notes}. Accept takes {"name", "description"?, "items": [{data_asset_id, existing_rule_ids, new_rules: [{rule_type, kwargs}]}]} and atomically creates the validation, the new rules (reusing an existing rule that checks the same thing) and a membership for exactly the chosen rules; it returns {validation_id, name, created_rule_ids, reused_rule_ids, asset_count}. Both need can_manage_validation + can_edit_rule; generate also needs an AI engine (400), is rate-limited on the AI-suggestions bucket and counts against the hourly AI cap (429). A hidden asset is a 404, one in another workspace a 400, two restricted teams' assets a 422, a second accept a 409. No step-up (a dqk_ / MCP bearer can call them).
AI bulk actionsPOST /api/v1/organizations/{org_id}/workspaces/{ws_id}/ai-suggestions/bulk-accept and .../bulk-dismissAccept or dismiss many AI suggestions in one round trip; step-up auth + the can_manage_llm_configs permission (super admin or technical admin) required. Shipped in FEATURE-39.
Alert rules/api/v1/alert-rules/..., .../alert-rules/{id}/events and .../alert-rules/eventsCRUD + test-send. The workspace's default incident rule (is_system_default: true) accepts only a PATCH of enabled; any other change, or a DELETE, is a 409. Deleting a rule that has fired keeps its alert history and fired alerts (they're detached from the rule); a delete that another record still blocks is a 409 whose detail names the blocker. The /events sub-collection is the per-rule alert history (dispatch log) — GET lists the rule's AlertEvent rows (status pending / sent / failed, sent_at, channel, error, and the alert_id / execution_run_id of the firing — a rule fires at most once per execution run) newest-first with opt-in cursor pagination, gated on can_view_alert (viewer+). GET .../alert-rules/events is the same feed across every rule in the workspace (the Alerts page's Alert history); each row also carries the parent rule's rule_trigger / rule_channel / rule_scope_type / rule_validation_ids plus the resolved rule_validation_names, and the firing's own validation_id / validation_name — names only for validations you can see. A firing whose rule has since been deleted stays in this feed with alert_rule_id and the rule_* fields set to null. Any secret that leaked into an error (a webhook URL's secret path / a token) is redacted out of the response.
Slack integration/api/v1/organizations/{org_id}/slack/integrations[/{id}], .../slack/resolve-mentions, .../slack/usersThe per-org Slack bot token (users:read) behind name-based mentions. GET / POST (create or rotate) / DELETE on /integrations need can_manage_integrations (super admin or technical admin); POST is step-up + rate-limited, and the token is encrypted at rest and never returned (reads show only bot_token_set). POST .../resolve-mentions {"ids": [...]} maps stored member IDs to display names for display (viewer+, can_view_alert; at most 100 IDs per call). GET .../users?q=&limit= searches the org's Slack members by name for the alert form's mention picker (editor+, can_manage_alert, rate-limited per user); backed by users.list, cached per org for 5 minutes. Both degrade instead of erroring: no token → token_configured: false; a Slack failure → empty data (plus an error code on the search). See Show Slack display names in mentions.
Incidents/api/v1/organizations/{org_id}[/workspaces/{ws_id}]/incidents[/{id}], .../incidents/{id}/events, .../incidents/{id}/external-ticket, .../incidents/{id}/exportNative incident lifecycle (#411). List / get gated on can_view_incident (viewer+); create / PATCH (status open → acknowledged → resolved + assignee/notes edits) / delete gated on can_manage_incident (editor+). The list supports opt-in cursor pagination. A PATCH that flips status stamps acknowledged_at / resolved_at and appends a status_change timeline entry carrying {from, to}. The /events sub-collection is the append-only timeline — GET lists it (can_view_incident), POST {data:{body}} adds a comment whose author is the authenticated principal (never the body). The timeline comment POST is gated on can_comment_incident (#321) — a lower bar than triage: a viewer may comment, while create / triage / resolve / delete stay editor+. A failed validation run (including an asset's or a single rule's run) opens one incident (source=auto) through the workspace's default incident alert rule, idempotently per validation; its alert_id names the fired alert. Create requires data_asset_id or rule_id (422 without one; 404 for one you can't see), and the incident takes that asset's or rule's team. PATCH accepts data_asset_id / rule_id too (clamped the same way), to anchor an older incident that has none. Every incident carries a human-facing incident_number (INC-<8 hex>). POST .../external-ticket {data:{alert_rule_id}} (#155, can_manage_incident) pushes the incident to an external ticket by reusing an existing ServiceNow / PagerDuty alert-rule destination's stored (encrypted) credentials — it stamps external_ticket_provider/id/url/status on the incident and appends an external_ticket timeline entry. Push-only: a second push is refused (409), and there is no status sync-back (ServiceNow returns an INC… number + URL + state; PagerDuty's Events API echoes no URL, so external_ticket_url stays null). The credential is never returned or logged. GET .../export returns the incident plus its full timeline for a printable / fallback record. See Incidents + Manage incidents.
Incidents summaryGET /api/v1/organizations/{org_id}/workspaces/{ws_id}/incidents/summary, GET /api/v1/organizations/{org_id}/incidents/summaryThe compact open-incidents roll-up the business dashboard reads (#141) — the org-safe alternative to the (unpaginated, workspace-scoped) incident list for a dashboard card. Covers the live (un-resolved) incidents only. Returns {"data": {open_count, acknowledged_count, by_severity: {critical, warning, info}, unassigned_open_count, recent: [{id, title, severity, status, assignee_email, team_id, created_at}], scope, team_id}} — the counts plus a small newest-live preview (title + owner). A resolved incident contributes to nothing. Viewer-readable (can_view_incident + workspace membership) on the workspace endpoint; super admin ONLY on the org endpoint (mirrors the results/all / dashboard/metrics org roll-ups — a caller below super_admin/legacy owner/admin gets a 403). ?team_id=<uuid> (workspace endpoint) narrows to one team's slice via the incident's own team (live team of this workspace, else 404; a team-tier caller can't widen to a sibling); it's a 422 on the org endpoint (team is a within-workspace concept — the org roll-up is the union across workspaces). Isolation-respecting; agent (dqa_) / SCIM tokens are rejected.
Agent tokens/api/v1/agent-tokens/...CRUD + revoke (for on-prem agents).
Personal API keys/api/v1/organizations/{org_id}/personal-api-keys/...List / mint / revoke your own dqk_ keys. You only ever see and act on your own keys. Mint is step-up gated + rate-limited (rate_limit_personal_api_keys_per_minute). Detailed below.
Invites/api/v1/organizations/{org_id}/invites/...Mint, list pending, revoke. Accept via POST /auth/accept-invite.
MembersGET /api/v1/organizations/{org_id}/members, /api/v1/organizations/{org_id}/workspaces/{ws_id}/members[/{user_id}]GET .../members is the org-wide roster (super admin only): one row per org member with org_role and every workspace and team membership. Per workspace: GET lists its members (org super admins are merged in as super_admin); POST {"user_id", "role"} gives an existing org member access (201; role is workspace_admin, editor or viewer, a viewer starts unrestricted unless an org-level or same-workspace team marker already restricts them; 404 for a user outside the org or an archived workspace, 409 when they're already a member, are deactivated, or are a super admin, who reaches every workspace already); PATCH .../{user_id} {"role"} changes the role; PUT .../{user_id}/viewer-access sets a viewer's restricted flag; DELETE .../{user_id} removes the membership (the org membership stays). Writes need a super admin or a workspace admin of that workspace (for viewer-access, workspace admin through a team counts too), and step-up auth. A concurrent duplicate POST is a 409, not a 500.
Workspaces — deleteDELETE /api/v1/organizations/{org_id}/workspaces/{ws_id}?confirm_name=<name> and GET .../workspaces/{ws_id}/deletion-previewDelete (archive) a workspace: 204. Super admin only, with step-up auth; confirm_name must be the workspace's exact name (422 otherwise). The org's last workspace may be deleted too; the preview's is_last flags it, and the org then has no live workspace (sessions carry a null workspace_id, GET .../workspaces is empty) until one is created or restored. The workspace is soft-deleted: every workspace-scoped route then 404s it and it leaves GET .../workspaces, its queued and running runs (validation, legacy checkpoint and profile runs) are cancelled the way Stop cancels them, its schedules are disabled, credentials pinned to it are revoked, pending invites expire and its teams are retired, while its runs, results and alert history are kept. Audited. The preview returns {"data": {name, is_last, counts: {data_sources, data_assets, validations, schedules, alert_rules, execution_runs}, stops: {runs_to_cancel, profile_runs_to_cancel, schedules_to_stop}}}. See Deleting a workspace.
Workspaces — deleted list + restoreGET /api/v1/organizations/{org_id}/deleted-workspaces and POST /api/v1/organizations/{org_id}/workspaces/{ws_id}/restoreThe list returns {"data": [{id, name, slug, deleted_at}]}, newest first: every deleted workspace for a super admin, and for anyone else the ones they belonged to (directly or through a team). slug is the one it had. Restore is super admin only (can_manage_workspaces; other roles get 403) and counts against the plan's workspace quota (402). It clears the delete, gives back the original slug, brings back the teams the delete retired and resumes the schedules the delete turned off from their next tick, without catching up missed firings. It returns {"data": {id, org_id, name, slug, created_at, updated_at, schedules_resumed}}. Cancelled runs stay cancelled, and revoked credentials and expired invites aren't revived. 409 when the workspace isn't deleted, or when an active workspace now has its name or slug. Audited as restore. See Restoring a workspace.
Workspace members/api/v1/organizations/{org_id}/workspaces/{ws_id}/members/...List, change role, remove. Each listed member carries viewer_access_level (restricted / unrestricted for a viewer, null otherwise). PATCH .../members/{user_id} takes `{"role": "workspace_admin"
Org-wide membersGET /api/v1/organizations/{org_id}/membersThe Super Admin org-wide member roster (#649). Returns {"data": [{user_id, email, full_name, org_role, workspaces: [{workspace_id, name, role, viewer_access_level}, …], teams: [{team_id, name, role, workspace_id, viewer_access_level}, …]}, …]} — one row per org member, listing every workspace + team they belong to across the whole org regardless of the selected workspace, each with its per-membership role. Requires the super admin role (super_admin) — a workspace_admin / technical_admin / team role gets a 403 and keeps the workspace-scoped …/workspaces/{ws_id}/members view instead. Read-only; per-membership edits stay on the workspace-members + Teams endpoints.
LLM configs/api/v2/organizations/{org_id}/llm-configs/...CRUD.
Identity providers/api/v1/organizations/{org_id}/identity-providers/...CRUD for SAML / OIDC providers (FEATURE-31). Listing needs can_view_identity_providers (super admin + technical admin); create, update, delete and /discover need can_manage_identity_providers (super admin only). default_role is one of super_admin / technical_admin / editor / viewer. POST .../identity-providers/{id}/link (password step-up, user sessions only) returns {"data": {"redirect_url"}} to link the caller's own account to that provider; admin-tier accounts are never linked automatically by email. Login flow is GET /auth/sso/{org_slug}/start + GET (OIDC) or POST (SAML) /auth/sso/{org_slug}/callback; the callback always answers with a 303 — into the app on success, or to /login?sso_error=<code> on any failure (codes in Configure SSO). See Configure SSO.
Audit events/api/v1/organizations/{org_id}/audit-eventsRead-only audit log (HARDEN-21) — covers logins (success + failure), logout, tenant provisioning, agent-token mint/revoke, LLM-config + datasource + alert-rule + rule-suite + data-asset + incident writes and deletes, AI suggestion bulk actions. Super admin and technical admin only (can_export_audit_log), so the per-resource History button works for them. Pass ?resource_type=<kind>&resource_id=<uuid> together to scope the list to one resource's history (FEATURE-8); the UI surfaces the slice via a "History" button on each detail page. The underlying audit_events / global_audit_events tables are append-only at the DB layer (a trigger blocks UPDATE/DELETE; the retention prune is the only sanctioned delete).
Step-up authPOST /auth/elevateRefresh auth_fresh_at with a fresh password proof (HARDEN-24). Required before mutating bearer-secret-bearing resources — agent tokens, LLM configs, datasources, alert rules. The UI catches 401 detail=step_up_required and prompts inline.
Data docs/api/v1/organizations/{org_id}/data-docs-config and .../data-docs-config/rebuildGet/set the static data-docs destination URI; trigger an out-of-cycle build. Super admin or technical admin (can_manage_integrations). See publish a data-docs site.
Branding/api/v1/organizations/{org_id}/branding and .../branding/logoThe org-configurable sidebar logo (#647). GET .../branding returns {"data": {logo_light_type, logo_dark_type, has_light, has_dark, logo_light, logo_dark, updated_at}} where each logo_* is an inline data: URI carrying its own per-variant type (or null when unset) — readable by any org member (the logo shows in everyone's sidebar). GET .../branding/logo?variant=light|dark streams the raw bytes with that variant's own content type; SVG variants are served nosniff + a sandboxed CSP so a directly-opened mark can't execute embedded script. PUT .../branding/logo sets/replaces the logo (body {content_type, logo_light?, logo_dark?, logo_light_type?, logo_dark_type?}, the logo_* fields base64-encoded); content_type is the default type, with optional per-variant logo_*_type overrides so the two marks may differ in format (e.g. SVG light + PNG dark). At least one variant is required, each variant ≤ 256 KB, every type ∈ {image/png, image/jpeg, image/svg+xml}. DELETE .../branding/logo resets to the default Measured Cloud wordmark. Mutations are org-admin only (can_manage_org — super admin / owner) + step-up gated; an unset logo simply falls back to the wordmark (no default is stored). See Customize your organization's logo.
Tags (taxonomy)/api/v1/organizations/{org_id}/tags[/{id}]The org-wide, SA-managed tag taxonomy — a controlled vocabulary of classification tags (PII / Financial / Restricted / …) that replaces free-text-per-asset tagging (#221). Per-org (not workspace). GET .../tags lists the taxonomy ({"data": [{id, name, colour, description, created_at, updated_at}]}, alphabetised) — readable by any org member (the asset editor's tag picker needs it). POST / PATCH .../tags/{id} / DELETE .../tags/{id} create / edit / remove a tag and are org-admin only (can_manage_org — super admin / owner). Body: {name, colour?, description?} — name is unique per org case-insensitively (a duplicate is a 409), colour is an optional #RRGGBB hex (bad hex → 422). Backward-compatible: DataAsset.tags stays a list[str] on the wire, the taxonomy defines the allowed set + constrains the asset editor's new input, and a legacy tag not in the taxonomy still stores + displays (deleting a definition does not strip it from assets). See Manage the tag taxonomy.
Catalog sync/api/v1/organizations/{org_id}/catalog-sync-config and .../catalog-sync/testGet/set the outbound catalog-sync webhook URL + optional HMAC signing secret; fire a synthetic test event. Super admin or technical admin (can_manage_integrations). POSTs a stable JSON envelope after every validation result. See Integrate with your data catalog.
Catalog integrations/api/v1/organizations/{org_id}/catalog-integrations[/{id}[/sync]]CRUD + "Sync now" for first-party (native) catalog connectors — one row per (org, kind). Accepted kind values: datahub, atlan, data.world, alation (#403 / #404 / #415). Super admin or technical admin (can_manage_integrations); writes are step-up-gated + audit-logged. The auth_token is AES-256-GCM encrypted at rest and redacted to the "configured" sentinel on read. DataHub also does inbound discovery; the others are outbound-only. See DataHub, Atlan, Data.world, Alation.
dbt ingestPOST /api/v1/organizations/{org_id}/workspaces/{ws_id}/dbt/ingestSynchronously parse a dbt manifest.json + run_results.json pair: upsert one DataAsset per model node, write one synthetic ValidationResult per matched run-result. Re-ingest is idempotent. See Ingest dbt artifacts.
SearchGET /api/v1/organizations/{org_id}/workspaces/{ws_id}/search?q=<term>Unified, workspace-scoped global search across datasources, data assets, validations, schedules, alerts, results, and column names (matched inside the latest profile's column_stats). Returns a flat grouped list of hits — each carries type, id, label, optional description, optional timestamp (a result's run time), route, and group. Incidents are not searched while they are hidden in the UI (#144). Optional ?type=<kind> narrows to one hit type; ?limit= (1–25, default 8) caps hits per type. Any workspace member may search; a blank q returns {"data": []} (no full-table dump). No secret/DSN/ciphertext ever crosses the boundary. Powers the always-visible top-bar search box and the Cmd+K palette — see Search across objects (#455, #667).
JWKS/.well-known/jwks.jsonPublic keys for verifying RS256 JWTs. Gated on JWT_PUBLISH_JWKS=true (HARDEN-13).
VersionGET /api/versionPublic, unauthenticated build marker: {"data": {sha, short_sha, build_time, version}}. sha is the full commit the running API image was built from (null on a local build, where short_sha reads dev); build_time is that commit's timestamp. The same short sha shows in the UI's account menu (bottom left), so anyone can confirm which deploy they're on.
DeploymentGET /api/v1/deploymentPublic, unauthenticated metadata about this deployment: region, version, sso_enabled, llm_provider. Lets a customer / auditor confirm where their data is hosted without signing in. See FEATURE-34.
Health/healthLiveness probe.

Deprecated checkpoint alias​

Checkpoints were retired (see Checkpoints (retired)). For one release the /checkpoints endpoints stay as a deprecated alias so existing pipelines keep running. Each checkpoint maps to a validation, and the alias reads and runs through that mapping. The alias is removed in the next major SDK release.

EndpointBehaviour
GET /api/v1/organizations/{org_id}/workspaces/{ws_id}/checkpointsThe legacy checkpoint rows, each with validation_id (the mapped validation) and deprecated: true.
GET .../checkpoints/{id}One legacy checkpoint, with the same two fields.
POST .../checkpoints/{id}/runRuns the mapped validation. 202 with {validation_id, parent_result_id, execution_run_id, run_queue_id: null, deprecated: true}; poll GET /api/v2/.../execution-runs/{execution_run_id}. A non-empty rule_parameters body is a 422 (per-run overrides are no longer supported).
POST .../checkpoints/{id}/run-subsetRuns the mapped validation once per partition value.
POST .../checkpoints/{id}/schedulesCreates a schedule for the mapped validation.
Any checkpoint write (POST / DELETE .../checkpoints, PUT / PATCH / DELETE .../checkpoints/{id}, on the org- and workspace-scoped paths)410 Gone.

Every alias response carries Deprecation: true and Link: <…/validations>; rel="successor-version". A checkpoint that maps to no validation returns 409 on run. The run endpoints take the same can_run_validation permission and run rate limit as a validation run.

The permission names changed with the retirement: can_run_validation (run and stop validations, asset runs and rule runs) and can_manage_validation (create and edit validations and their membership) replace can_run_checkpoint and can_create_checkpoint. The old names, and can_view_checkpoint, stay in GET /auth/me/permissions for one release as deprecated aliases held by exactly the same roles; nothing is gated on them any more.

Deprecated rule-suite alias​

Rule suites were retired: an asset's rules are first-class rules, and a validation holds rules one by one (the validation's asset list went with them, as did the suite-grain validation configs). So that existing integrations don't break, the old V1 endpoints stay for one release as a read-only alias. It is removed in the next major SDK release.

EndpointBehaviour
GET /api/v1/organizations/{org_id}[/workspaces/{ws_id}]/rule-suites and GET .../rule-suites/{id}The legacy rule-suite rows, in the unchanged item shape plus deprecated: true. For a suite attached to an asset, suite.rules is the asset's live, enabled rules; a legacy suite with no asset returns its stored rules.
POST .../rule-suites/{id}/validateThe dry-run lint of the suite's rules, kept: {"data": {valid, errors}}. Nothing is written.
GET /api/v1/organizations/{org_id}[/workspaces/{ws_id}]/validation-configs and GET .../validation-configs/{id}The legacy rows, unchanged shape plus deprecated: true. An existing config's batch_definition_id still scopes its asset's rules when a run gives them no batch of their own.
Any write (POST / DELETE ?name= on the collection, PUT / PATCH / DELETE on an item, on the org- and workspace-scoped paths)410 Gone. Write rules on their asset instead (/api/v2/.../data-assets/{asset_id}/rules).

Every alias response, the 410s included, carries Deprecation: true and a Link: <…/api/v2/organizations/{org_id}[/workspaces/{ws_id}]/rules>; rel="successor-version" header (…/validations for /validation-configs). The validation members endpoints are deprecated the same way (see the Validations row above).

Other shapes that referred to suites keep their fields for one release, set to null: the asset rules read's suite_id, the GX import response's suite_id, the dbt ingest response's rule_suite_id, BI asset-status rule_suite_*, and a run's run-rules and progress rule_suite_id. The catalog webhook's result.rule_suite is null, and result.validation ({id, name}) names the validation that ran. A deletion preview counts rules.

The permission names changed too: can_view_rule (viewer and up), can_create_rule and can_edit_rule (editor and up) and can_delete_rule (workspace admin and up) replace can_view_suite, can_create_suite, can_edit_suite and can_delete_suite. The old names stay in GET /auth/me/permissions for one release as deprecated aliases held by exactly the same roles; nothing is gated on them any more.

Rate limiting (per-org)​

Mutation endpoints with abuse potential carry per-org token-bucket limits (HARDEN-18). Over-limit hits return 429 Too Many Requests with Retry-After. See Configuration → Per-org rate limits for the list and defaults.

Personal API keys​

Self-service management of your own dqk_ keys. Every route is scoped to the org in the path; you only ever see and act on keys you minted. A minted key inherits your org role and, if workspace_id is set, your access to that one workspace. See Create an API token for the UI walkthrough.

GET /api/v1/organizations/{org_id}/personal-api-keys​

List your keys in this org. The raw token is never returned — only metadata.

{
"data": [
{
"id": "key-uuid",
"name": "ci-nightly",
"workspace_id": null,
"last_used_at": "2026-05-28T11:04:00Z",
"created_at": "2026-05-01T09:00:00Z"
}
]
}

POST /api/v1/organizations/{org_id}/personal-api-keys​

Mint a new key. Step-up gated (a recent password proof — see Step-up auth / POST /auth/elevate) and rate-limited by rate_limit_personal_api_keys_per_minute (default 10; over-limit → 429 with Retry-After).

Request:

{"data": {"name": "ci-nightly", "workspace_id": null}}

workspace_id is optional — null (or omitted) makes the key mirror your own access: for a super admin that's the whole org, for everyone else it's exactly the workspaces you can reach through your memberships (direct or via a team) — an unpinned key never reads a workspace your own login couldn't. A workspace UUID scopes the key to that one workspace.

Breaking change (Phase-2 defense-in-depth). Unpinned (workspace_id: null) keys held by users other than a super admin used to inherit full-org access; they are now clamped to the holder's own membership-derived workspace set. An existing unpinned key that was reading a workspace its holder isn't a member of flips from 200 to 404 on those requests. Remedies: add the key's holder to the workspace (directly or via a team), have a super admin re-mint the key under their own identity, or mint a key pinned to the specific workspace. Org owner/admin keys are unaffected.

Response — the only time the raw token is ever returned:

{
"data": {
"id": "key-uuid",
"name": "ci-nightly",
"workspace_id": null,
"last_used_at": null,
"created_at": "2026-05-29T12:00:00Z",
"token": "dqk_..."
}
}

Store the token immediately — only its hash is kept, so it can't be shown again.

DELETE /api/v1/organizations/{org_id}/personal-api-keys/{id}​

Revoke one of your keys. Returns 204 No Content. Revocation isn't instant: a revoked key keeps working for up to TOKEN_CACHE_TTL_SECONDS (default 5) until its cached lookup expires.

Password reset​

Two unauthenticated endpoints back the self-service password-reset flow (Reset your password). Both are rate-limited per IP (+ IP-email) by auth_password_reset_rate_limit_per_minute (default 5; over-limit → 429 with Retry-After). Reset mail only leaves the deployment when SMTP and PUBLIC_BASE_URL are configured — see Configuration → SMTP. SSO / IAP-only accounts have no local password and are silently skipped (there is nothing to reset).

POST /auth/forgot-password​

Request the reset email for an address.

{"email": "you@example.com"}

Always returns 200 with a generic {"message": "..."} regardless of whether the address maps to an account — the response never reveals account existence. If the email belongs to an active account with a local password, a single-use reset link ({PUBLIC_BASE_URL}/reset-password?token=…) is emailed; it stays valid for password_reset_expiry_minutes (default 60).

POST /auth/reset-password​

Consume an emailed token and set a new password.

{"token": "…from the emailed link…", "new_password": "your-new-password"}

On success returns 200 with {"message": "..."}, sets the new password (minimum 8 characters), and signs the user out of all existing sessions (every refresh-token family is revoked). A token that is invalid, expired, or already used returns 400; a new_password shorter than 8 characters is a 422.

Recycle bin​

Soft-delete for datasources and data assets. Deleting one (via its resource DELETE endpoint) tombstones it and its owned subtree — child assets and their rules (plus any legacy rule-suite and validation-config rows) — under a single deletion_group_id, hidden from every list/search/query but restorable for RECYCLE_BIN_RETENTION_DAYS (default 30) before a daily cron purges it. Shared objects (curated validations, legacy asset-less rule suites, validation results, agent tokens) are never touched. All routes are workspace-isolated — you only see and act on items in workspaces you can reach. See Recycle bin.

Both an org-scoped and a workspace-scoped mount exist:

/api/v2/organizations/{org_id}/recycle-bin/...
/api/v2/organizations/{org_id}/workspaces/{ws_id}/recycle-bin/...

GET /api/v2/.../recycle-bin​

List the top-level restorable entries: every binned datasource, plus assets binned independently (their parent datasource is still live — assets binned as part of a datasource delete ride under the datasource's entry). Gated require_role(owner, admin, editor).

Cursor-paginated, newest-first over (deleted_at DESC, id DESC). Pass limit (1–500) to page and cursor (the opaque next_cursor from the previous page) to advance; next_cursor is a sibling of data and is null on the last page. Omit limit and the endpoint returns the full set (back-compatible — no next_cursor).

{
"data": [
{"resource_type": "datasource", "id": "…", "name": "prod-postgres",
"deleted_at": "2026-09-01T12:00:00Z", "purge_at": "2026-10-01T12:00:00Z",
"deletion_group_id": "…"},
{"resource_type": "data_asset", "id": "…", "name": "analytics.orders",
"datasource_id": "…", "deleted_at": "…", "purge_at": "…", "deletion_group_id": "…"}
],
"next_cursor": null
}

purge_at is deleted_at + RECYCLE_BIN_RETENTION_DAYS.

GET /api/v2/.../recycle-bin/preview/datasource/{id} · .../preview/data-asset/{id}​

Blast-radius preview for a live object you're about to delete — the counts a delete would bin, plus the shared rule groups it would keep. Gated editor+. 404 if the id isn't a live datasource / asset.

{
"data": {
"resource_type": "datasource", "id": "…", "name": "prod-postgres",
"counts": {"data_assets": 12, "rules": 57, "rule_suites": 12,
"validation_configs": 12, "profile_runs": 40, "anomaly_events": 3,
"ai_suggestions": 5, "batch_definitions": 4, "rule_groups_kept": 2},
"retention_days": 30
}
}

POST /api/v2/.../recycle-bin/datasources/{id}/restore · .../data-assets/{id}/restore​

Un-tombstone the whole deletion_group_id — the object and everything owned under it reappears where it was. Gated editor+. Returns per-table restored-row counts.

{"data": {"resource_type": "datasource", "id": "…", "name": "prod-postgres",
"restored": {"datasources": 1, "data_assets": 12,
"rule_suites": 12, "validation_configs": 12}}}
  • 404 — no binned item with that id.
  • 409 (data-asset restore only) — the asset's parent datasource is also in the recycle bin. Restore the datasource first. If the two were binned in the same delete they share a deletion_group_id, so restoring the datasource brings the asset back with it; if the asset was binned independently and the datasource binned later (separate groups), restoring the datasource clears only its own group — restore the asset afterward once it's the only binned member of its subtree.

Restores are audit-logged (action=restore).

DELETE /api/v2/.../recycle-bin/datasources/{id} · .../data-assets/{id}​

Permanently purge a binned item's owned subtree now, without waiting for the retention window. Irreversible. Gated to a workspace admin or super admin (require_role(*ADMIN_WRITE_ROLES)). Returns 204. Shared objects and the immutable validation-result history are still preserved. 404 if the id isn't in the bin. Purges are audit-logged (action=purge).

The same cascade runs automatically for expired items via the worker's purge_recycle_bin cron (03:45 UTC daily) — see Configuration → RECYCLE_BIN_RETENTION_DAYS.

Root cause analysis​

Status: first cut (FEATURE-410). Heuristic correlation + optional AI narrative; ML causal inference and several signal families are deferred. See Root cause analysis.

GET /api/v1/organizations/{org_id}/workspaces/{workspace_id}/validation-results/{result_id}/rca​

Correlate a failed validation result's already-available signals — failing expectations, cross-run failure pattern, last-passing-vs-current statistical diff, recent anomalies, and schema drift — into a ranked likely-cause summary. Read-only; nothing is persisted.

Readable by any role with can_view_results (viewer included) — it is read-augmentation of a result you can already see. A cross-org org_id is rejected with 403; an unknown result_id returns 404.

Query parameters

ParamDefaultEffect
include_narrativefalseWhen true and an AI engine is available (native, or a default LLM config), appends a plain-English AI narrative. Served from the narrative cache when the identical analysis was already narrated (for example in the background after the run). Off by default so a plain read never spends.

Response — {"data": RcaResponse}:

FieldNotes
applicablefalse for a passing result (RCA is for failures) — everything else is then empty/default.
data_asset_id / data_asset_resolvedThe linked asset, when resolvable from the result's validation, legacy checkpoint, or validation config. data_asset_resolved: false → expectation-only RCA (no stat diffs / anomalies).
failure_patternnew | recurring | regression | unknown.
last_passed_at / first_failed_atThe RCA timeline. Either may be null.
failing_expectations[]{expectation_type, column, description, unexpected_count, observed_value, severity}.
stat_diffs[]{metric, column, previous, current, delta, pct_change} — row_count plus per-failing-column null_rate/min/max/mean.
contributing_factors[]Ranked likely causes: {tag, title, detail, confidence, evidence}, most-confident first.
narrative / narrative_statusThe optional AI text and its state: generated | no_llm_config | not_requested | error.
llm_config_id / prompt_tokens / completion_tokensPresent only when a narrative was generated.

The narrative path is covered by the per-org ai_suggestions rate limit, and no credential, ciphertext, or DSN ever appears in the response.

Webhooks​

The generic-webhook alert channel (FEATURE-16) POSTs a JSON envelope to your operator-supplied URL. The canonical payload shape, signing scheme, and reference verification snippet live in Alerts → Generic webhook.

SDKs​

Hand-rolling requests/fetch works fine, but the typed clients are usually quicker: