> Source: https://wexa.ai/docs/api/catalog

# 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:

| 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:

```json
{ "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.

```bash
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" }
    }]
  }'
```

```json
{ "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:

```json
{ "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.

| Parameter | Meaning |
|---|---|
| `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` |

```bash
curl -sS -H "Authorization: Bearer $FABRIC_API_KEY" \
  "https://fabric.wexa.ai/v1/catalog/assets?kind=Table&limit=2"
```

```json
{
  "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.

```json
{
  "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.

| Parameter | Meaning |
|---|---|
| `direction` | `downstream` (default) or `upstream` |
| `max_depth` | default `5`, clamped to `10` |

```json
{
  "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.

```json
{
  "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`](#get-v1catalogontology) below.

## `GET /v1/catalog/scorecard`

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

```json
{
  "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.

```json
{
  "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:

```json
{ "error": "unavailable", "error_description": "OM sync not configured (OM_SYNC_ENABLED=false)" }
```

```json
{ "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.

```json
{
  "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:

```json
{ "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](/docs/api/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:

```json
{ "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 |
