On this page

Connectors

Catalog

The catalog is Wexa's inventory of the data estate: tables, columns, the classifications attached to them, and the lineage between them. Reads need the fabric:catalog.read grant; ingest and sync need fabric:catalog.write. Neither grant is in an OWNER key's default set, so ask for them when you create the API key.

Besides asset search, shown below, the API has routes for ingest, asset detail, lineage, the schema, the scorecard, the ontology, sync, connectors and the OpenMetadata webhook. They are all under "All the catalog routes" at the end of this page.

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"
    }
  ]
}

Parameters

Query parameters of the read routes.

ParameterTypeRequiredNotes
qstringOptionalGET /v1/catalog/assets: substring match on name and fully-qualified name.
kindstringOptionalGET /v1/catalog/assets: one asset kind, Table, Column, Classification or DataService.
pii_onlystringOptionalGET /v1/catalog/assets: true or 1 restricts to assets flagged as personally identifiable.
limitintegerOptionalGET /v1/catalog/assets: default 20, clamped to 100, so asking for 500 returns 100.
offsetintegerOptionalGET /v1/catalog/assets: default 0.
directionstringOptionalGET /v1/catalog/lineage/{id}: downstream (default) or upstream.
max_depthintegerOptionalGET /v1/catalog/lineage/{id}: default 5, clamped to 10.
columnsbooleanOptionalGET /v1/catalog/ontology: true also emits one entity per column.
Errors

Each route answers with {"error": ..., "error_description": ...}.

  • forbidden, 403: the key lacks the grant (catalog:write grant required on a write), or the ingest body's project_id names a different project (project_id must match token scope).
  • not_found, 404: GET /v1/catalog/assets/{id} names an unknown asset, or one in another project.
  • unavailable, 503: the synchroniser or the webhook handler is not configured. This covers POST /v1/catalog/sync, GET /v1/catalog/sync/stats and POST /v1/catalog/om/webhook.
Show full response

A real response, captured from a live call to GET /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"
    }
  ]
}
More details
All the catalog routes

Most of these routes 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. For when and how to use the catalog, read Ingestion.

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:

| Grant | Routes | | fabric:catalog.read | every GET on this page | | fabric:catalog.write | POST /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.

  • q: substring match on name and fully-qualified name
  • kind: one asset kind — Table, Column, Classification, DataService
  • pii_only: true or 1 restricts to assets flagged as personally identifiable
  • limit: default 20, clamped to 100 — asking for 500 returns 100
  • offset: default 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.

  • direction: downstream (default) or upstream
  • max_depth: default 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

| Route | Result | | POST /v1/catalog/ingest | 200, and 403 on both grant and project mismatch | | GET /v1/catalog/assets | 200, 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/schema | 200 | | GET /v1/catalog/scorecard | 200 | | GET /v1/catalog/ontology | 200, with and without columns=true | | POST /v1/catalog/sync | 503 — synchroniser not configured | | GET /v1/catalog/sync/stats | 503 — synchroniser not configured | | GET /v1/catalog/connectors | 200 | | GET /v1/catalog/connectors/om-status | 200 | | POST /v1/catalog/om/webhook | 503 — webhook handler not configured | | POST /v1/internal/catalog/ingest | 401, with and without a wrong header |

Every tool call works the same way: How every tool call works.