On this page

Catalog

The catalog is Wexa's inventory of the data estate: tables, columns, the classifications attached to them, and the lineage between them. Thirteen routes read and write it. Eleven of them are ordinary project-scoped REST; one is a service-to-service ingest path you cannot call; one is an inbound webhook from OpenMetadata.

Response bodies are quoted as they came back, trimmed where a field repeats.

Grants, not roles

Two grants gate the whole surface, and neither is included in the default set an OWNER's API key receives. Ask for them explicitly when you mint the key:

GrantRoutes
fabric:catalog.readevery GET on this page
fabric:catalog.writePOST /v1/catalog/ingest, POST /v1/catalog/sync

A key without the grant is refused before anything is read:

{ "error": "forbidden", "error_description": "catalog:write grant required" }

Every read is additionally confined to the caller's own project. For a user token the gateway confirms membership before answering, and adopts the platform's authoritative organization for that project, so a spoofed x-project-id header buys nothing.

POST /v1/catalog/ingest

Writes assets and lineage into the catalog. The body is OpenMetadata-shaped, which is the format the synchroniser emits, so the same payload works whether it came from OpenMetadata or from your own extraction.

curl -sS -X POST https://fabric.wexa.ai/v1/catalog/ingest \
  -H "Authorization: Bearer $FABRIC_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "project_id": "proj_abc",
    "source": "manual",
    "tables": [{
      "id": "t1",
      "name": "orders",
      "fullyQualifiedName": "warehouse.public.orders",
      "description": "One row per customer order.",
      "tableType": "Regular",
      "tags": [{ "tagFQN": "Sensitive" }],
      "columns": [
        { "name": "customer_email", "fullyQualifiedName": "warehouse.public.orders.customer_email",
          "dataType": "VARCHAR", "description": "Billing contact", "tags": [{ "tagFQN": "PII" }] },
        { "name": "total_cents", "fullyQualifiedName": "warehouse.public.orders.total_cents",
          "dataType": "BIGINT", "description": "Order total in cents" }
      ]
    }],
    "lineage": [{
      "fromEntity": { "fullyQualifiedName": "warehouse.public.orders" },
      "toEntity":   { "fullyQualifiedName": "warehouse.analytics.orders_daily" }
    }]
  }'
{ "created": 5, "updated": 0, "edges": 5 }

Sending exactly the same body again returned {"created":0,"updated":5,"edges":5} — assets are keyed on their fully-qualified name, so re-ingesting is an update rather than a duplicate. The counts include the derived nodes: a two-table, three-column payload created five assets, because each column is an asset in its own right.

project_id may be omitted, in which case the token's project is used. If it is present and names a different project the call is refused:

{ "error": "forbidden", "error_description": "project_id must match token scope" }

Two optional fields appear in the response when they apply: rejected, the count of lineage edges the allow-list refused, and errors, a list of strings.

GET /v1/catalog/assets

Search. Five query parameters, all optional.

ParameterMeaning
qsubstring match on name and fully-qualified name
kindone asset kind — Table, Column, Classification, DataService
pii_onlytrue or 1 restricts to assets flagged as personally identifiable
limitdefault 20, clamped to 100 — asking for 500 returns 100
offsetdefault 0
curl -sS -H "Authorization: Bearer $FABRIC_API_KEY" \
  "https://fabric.wexa.ai/v1/catalog/assets?kind=Table&limit=2"
{
  "limit": 2,
  "offset": 0,
  "total": 2,
  "results": [
    {
      "id": "cat_000001",
      "kind": "Table",
      "fqn": "warehouse.public.orders",
      "project_id": "proj_abc",
      "props": {
        "description": "One row per customer order.",
        "columnCount": 2,
        "sourceSystem": "warehouse",
        "tableType": "Regular",
        "tags": "[\"Sensitive\"]"
      },
      "created_at": "2026-09-15T16:21:16Z",
      "updated_at": "2026-09-15T16:21:16Z"
    }
  ]
}

Two things to expect. props.tags is a JSON string, not an array — parse it a second time. And the unfiltered listing includes four global Classification assets — PII, Sensitive, Confidential and Public — that carry "project_id": "_global"; they are seeded, not ingested, and filtering on kind=Table removes them.

pii_only=true on the payload above returned exactly one row, the customer_email column, because its PII tag set props.piiFlag during ingestion.

GET /v1/catalog/assets/{id}

One asset plus both sides of its edge list. The {id} is the catalog id (cat_000001), not the fully-qualified name.

{
  "asset": { "id": "cat_000001", "kind": "Table", "fqn": "warehouse.public.orders", "…": "…" },
  "incoming_edges": null,
  "outgoing_edges": [
    { "id": "edge_000003", "label": "HAS_COLUMN",    "from_id": "cat_000001", "to_id": "cat_000002" },
    { "id": "edge_000006", "label": "HAS_COLUMN",    "from_id": "cat_000001", "to_id": "cat_000005" },
    { "id": "edge_000010", "label": "DERIVES_FROM",  "from_id": "cat_000001", "to_id": "cat_000007" }
  ]
}

An unknown id returns 404 with {"error":"not_found","error_description":"asset not found"}. So does an id belonging to another project — the tenant guard answers 404 rather than 403 deliberately, so a guessable id cannot confirm that someone else's asset exists.

GET /v1/catalog/lineage/{id}

Walks lineage edges outward from one asset.

ParameterMeaning
directiondownstream (default) or upstream
max_depthdefault 5, clamped to 10
{
  "root": { "id": "cat_000001", "fqn": "warehouse.public.orders", "…": "…" },
  "direction": "upstream",
  "node_count": 1,
  "nodes": [
    { "asset": { "fqn": "warehouse.analytics.orders_daily", "…": "…" },
      "depth": 1, "via": "DERIVES_FROM" }
  ]
}

node_count is the length of nodes, and nodes is null rather than [] when the walk finds nothing. The root asset is never included in nodes.

GET /v1/catalog/schema

The platform schema the catalog stores assets against — node kinds, their properties, which properties are indexed. It takes no parameters and is the same for every project.

{
  "version": "1",
  "domain": "data-estate",
  "nodes": [
    {
      "name": "DataService",
      "label": "DataService",
      "description": "A data source or connector (Snowflake, Postgres, Kafka…)",
      "category": "data",
      "properties": [
        { "name": "serviceType", "type": "string", "indexed": true },
        { "name": "projectId",   "type": "string", "indexed": true }
      ]
    }
  ]
}

Read it when you are writing an ingestion client and need to know which property names the catalog will index. It is not the project ontology — that is GET /v1/catalog/ontology below.

GET /v1/catalog/scorecard

Quality rules evaluated over the project's catalog, with a count and up to a few examples each.

{
  "project_id": "proj_abc",
  "status": "healthy",
  "rules": [
    { "key": "pii_columns_without_description", "severity": "warning", "count": 0,
      "description": "PII-classified columns missing a description" },
    { "key": "orphan_columns",        "severity": "warning", "count": 0 },
    { "key": "tables_without_lineage","severity": "info",    "count": 0 },
    { "key": "undescribed_tables",    "severity": "info",    "count": 0 },
    { "key": "tables_without_owner",  "severity": "info",    "count": 2,
      "examples": ["warehouse.analytics.orders_daily", "warehouse.public.orders"] }
  ]
}

status summarises the rules; examples appears only on rules with a non-zero count. Ownership is the rule that fires on a fresh ingest, because nothing in the ingest payload assigns an owner.

GET /v1/catalog/ontology

The entity-and-relationship ontology inferred from the project's catalog: the containment hierarchy plus relationships derived from lineage.

{
  "project_id": "proj_abc",
  "entity_count": 3,
  "relationship_count": 1,
  "entities": [
    { "name": "orders",       "kind": "Table",       "fqn": "warehouse.public.orders",
      "service": "warehouse", "properties": ["customer_email", "total_cents"] },
    { "name": "orders_daily", "kind": "Table",       "fqn": "warehouse.analytics.orders_daily",
      "service": "warehouse", "properties": ["day"] },
    { "name": "warehouse",    "kind": "DataService", "fqn": "warehouse",
      "service": "warehouse", "properties": null }
  ],
  "relationships": [
    { "from": "orders", "label": "DERIVES_FROM", "to": "orders_daily", "via": "lineage" }
  ]
}

?columns=true additionally emits one entity per column. It is heavier — on the two-table example above it took the entity count from 3 to 6 — so request it only when you are rendering columns.

If the ontology comes back empty the gateway re-reads the project from its durable store once before answering, so a restart does not make a previously discovered ontology disappear.

POST /v1/catalog/sync and GET /v1/catalog/sync/stats

Trigger a synchronisation from OpenMetadata, and read the last run's statistics. Both depend on the synchroniser being configured, and both say so plainly when it is not:

{ "error": "unavailable", "error_description": "OM sync not configured (OM_SYNC_ENABLED=false)" }
{ "error": "unavailable", "error_description": "OM sync not configured for this organization" }

GET /v1/catalog/connectors and GET /v1/catalog/connectors/om-status

The connector inventory, and how much of it OpenMetadata knows about.

{
  "connectors": [
    { "name": "slack", "display_name": "Slack (Fabric)", "category": "messaging",
      "origin": "fabric", "om_synced": false,
      "description": "Slack workspace messages, channels, and files" }
  ]
}

origin distinguishes a connector Wexa registers itself from one discovered in OpenMetadata; om_synced says whether the two have been reconciled. The om-status route answers with the reconciliation counts, and on an unconfigured deployment returns zeros and nulls rather than an error:

{ "fabric_registered": null, "om_native": null, "total_fabric": 0, "total_om_native": 0,
  "fetched_at": "2026-09-15T16:20:22Z" }

POST /v1/catalog/om/webhook

The inbound webhook OpenMetadata calls when something in the estate changes, so the catalog refreshes in seconds rather than at the next scheduled synchronisation. It is unauthenticated in the usual sense — no bearer token — and is covered in full, with its signature scheme, on Webhooks.

POST /v1/internal/catalog/ingest

Not reachable by you. It is the service-to-service ingest path, authenticated by a shared x-server-key header that only Wexa's own services hold, and it refuses everything else:

{ "error": "unauthorized", "error_description": "valid x-server-key required" }

It is listed here so that seeing it in a network trace does not look like a route you are missing. Use POST /v1/catalog/ingest with a credential instead.

Per-route ledger

RouteResult
POST /v1/catalog/ingest200, and 403 on both grant and project mismatch
GET /v1/catalog/assets200, with q, kind, pii_only, limit, offset
GET /v1/catalog/assets/{id}200 and 404
GET /v1/catalog/lineage/{id}200 in both directions
GET /v1/catalog/schema200
GET /v1/catalog/scorecard200
GET /v1/catalog/ontology200, with and without columns=true
POST /v1/catalog/sync503 — synchroniser not configured
GET /v1/catalog/sync/stats503 — synchroniser not configured
GET /v1/catalog/connectors200
GET /v1/catalog/connectors/om-status200
POST /v1/catalog/om/webhook503 — webhook handler not configured
POST /v1/internal/catalog/ingest401, with and without a wrong header