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:
{ "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.
| 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 |
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.
| Parameter | Meaning |
|---|---|
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 |