Lineage API¶
You can manage lineage edges and query the lineage graph programmatically using the Qualytics API.
Tip
For complete API documentation, including request/response schemas, visit the API docs.
All endpoints use the base URL of your Qualytics deployment (e.g., https://your-instance.qualytics.io/api).
Permissions¶
Members and Managers need the Editor team permission on every affected datastore to create, edit or delete connections or submit OpenLineage changes. Unrestricted Admins can write across datastores; an Admin restricted to a team follows that team's permissions. Evidence details require Viewer on every managed endpoint. Listing a container's metadata sources requires Reporter on that container's datastore. See Lineage Permissions for graph browsing and cross-datastore rules.
Create an Edge¶
Creates a lineage edge connecting a source and a target. Both sides must be at the same granularity: either both containers or both fields.
Endpoint: POST /api/lineage/edges
Permission: Admin, or Member/Manager with Editor permission on every affected datastore.
Container-level edge
Request:
curl -X POST "https://your-instance.qualytics.io/api/lineage/edges" \
-H "Authorization: Bearer YOUR_API_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"source_container_id": 42,
"target_container_id": 55,
"source_type": "manual"
}'
Response (200):
{
"id": 1,
"created": "2026-05-01T10:00:00.000000Z",
"source_container_id": 42,
"source_field_id": null,
"target_container_id": 55,
"target_field_id": null,
"source_external_fqn": null,
"target_external_fqn": null,
"description": null,
"source_type": "manual",
"evidence": [],
"source_container": {
"id": 42,
"name": "raw_orders",
"display_name": null,
"container_type": "table",
"datastore_id": 10,
"datastore_name": "Production DB"
},
"source_field": null,
"target_container": {
"id": 55,
"name": "orders_clean",
"display_name": null,
"container_type": "computed_table",
"datastore_id": 10,
"datastore_name": "Production DB"
},
"target_field": null
}
Field-level edge
Request:
curl -X POST "https://your-instance.qualytics.io/api/lineage/edges" \
-H "Authorization: Bearer YOUR_API_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"source_field_id": 100,
"target_field_id": 200,
"source_type": "manual"
}'
Response (200): Same structure as the container-level example, with source_field and target_field populated instead.
Auto container-edge
When creating a field-level edge with source_type: "manual" and the source and target fields belong to different containers, Qualytics automatically creates a container-level edge between those containers if one does not already exist.
Request Body:
| Field | Type | Required | Description |
|---|---|---|---|
source_container_id |
integer | Conditional | Source container ID. Mutually exclusive with source_field_id. |
source_field_id |
integer | Conditional | Source field ID. Mutually exclusive with source_container_id. |
target_container_id |
integer | Conditional | Target container ID. Mutually exclusive with target_field_id. |
target_field_id |
integer | Conditional | Target field ID. Mutually exclusive with target_container_id. |
description |
string | No | Optional transformation note (e.g. UPPER(email)). |
source_type |
string | No | How the connection was created. One of manual, catalog_import, inferred. Defaults to manual. |
Same-granularity rule
Exactly one of source_container_id or source_field_id must be set, and exactly one of target_container_id or target_field_id must be set. Mixing container and field on opposite sides (e.g. source_container_id + target_field_id) is not allowed and returns 422.
Fields on Every Edge Response¶
The edge detail and listing endpoints return these fields alongside the endpoints and description. Graph and field-connection responses use a smaller shape: they omit last_asserted_at, retired_at, and the evidence details, and include only live relationships.
| Field | Type | Description |
|---|---|---|
source_type |
string | The value stored on the edge. manual, inferred for Qualytics managed, the data catalog's name for an imported edge, the datastore's type for a collected one, or openlineage for one sent as an OpenLineage event. |
source_external_fqn |
string | Set instead of source_container_id when the upstream end is an asset that is not registered in Qualytics, spelled as the source names it. null otherwise. |
target_external_fqn |
string | The same for the downstream end. |
evidence |
array | What the edge was derived from. Empty for manual, data catalog, and Qualytics managed edges. One entry per piece of evidence behind a collected edge, and one per job that asserted an OpenLineage edge. |
last_asserted_at |
string | The latest time a source asserted the edge, as the source reported it. Set for OpenLineage edges, null otherwise. |
retired_at |
string | When the edge was retired because nothing asserts it any more. null for a live edge. Only OpenLineage edges are retired, see Retirement. |
An edge always has at least one endpoint resolved to a container or a field. An edge with both ends unresolved is never stored as a graph relationship; its OpenLineage event is retained for later replay.
The Evidence Object¶
| Field | Type | Description |
|---|---|---|
provider |
string | The datastore type the evidence was read from, such as snowflake. For OpenLineage, the producer URI of the job's latest event. It is recorded but does not identify the job. |
evidence_type |
string | What was read: structured_query_history, parsed_query_history, parsed_view_definition, parsed_stored_code, or native_snapshot. For OpenLineage, run_event or job_event. |
authorship |
string | Which side computed the relationship. provider when the source's own lineage reported it, qualytics when Qualytics derived it from statement text, asserted when an OpenLineage producer sent it. |
source_relation |
string | The relation it was read from, such as SNOWFLAKE.ACCOUNT_USAGE.ACCESS_HISTORY. For OpenLineage, the job that asserted the edge, encoded as a JSON array [namespace, name]. Empty without Viewer on every managed endpoint. |
observed_at |
string | When the relationship was last observed, or null. For OpenLineage, the time of the latest event that asserted it. |
first_observed_operation_id |
integer | The Sync operation that first observed it. |
last_observed_operation_id |
integer | The Sync operation that last observed it. |
details |
object | OpenLineage evidence only, on Get an Edge and List Edges: the run ID and state, the job, the two datasets, the transformations, the job's SQL or source code when it sent them (on a table-level edge only; a field-level edge carries the event ID but does not include that SQL or source code), the ID of the stored event, and submitted_by, the Qualytics user whose token sent it. null for other evidence, and null when the caller lacks the Viewer team permission on the datastore of every container the edge touches. Unrestricted Admins always receive it. |
retired_at |
string | When the job stopped asserting the edge, or null. |
List Edges¶
Returns a paginated list of lineage edges with optional filters.
Endpoint: GET /api/lineage/edges
Role: Member
Query Parameters:
| Parameter | Type | Description |
|---|---|---|
container_id |
integer | Filter to edges where this container is the source or target. |
field_id |
integer | Filter to edges where this field is the source or target. |
source_type |
string | Filter by the value stored on the edge. An edge created by a sync carries the data catalog's name or the datastore's type rather than catalog_import. See Lineage Sources for each category's value. |
include_retired |
boolean | Include edges retired because nothing asserts them any more (default: false). |
page |
integer | Page number (default: 1). |
size |
integer | Page size (default: 50). |
Example request and response
Request:
curl -X GET "https://your-instance.qualytics.io/api/lineage/edges?container_id=42" \
-H "Authorization: Bearer YOUR_API_TOKEN"
Response:
{
"items": [
{
"id": 1,
"created": "2026-05-01T10:00:00.000000Z",
"source_container_id": 42,
"source_field_id": null,
"target_container_id": 55,
"target_field_id": null,
"description": "Raw orders → cleaned orders",
"source_type": "manual",
"source_container": { "id": 42, "name": "raw_orders", "display_name": null, "container_type": "table", "datastore_id": 10, "datastore_name": "Production DB" },
"source_field": null,
"target_container": { "id": 55, "name": "orders_clean", "display_name": null, "container_type": "computed_table", "datastore_id": 10, "datastore_name": "Production DB" },
"target_field": null
}
],
"total": 1,
"page": 1,
"size": 50,
"pages": 1
}
Get an Edge¶
Returns a single lineage edge by ID, including a retired one.
Endpoint: GET /api/lineage/edges/{id}
Role: Member
Example request and response
Request:
curl -X GET "https://your-instance.qualytics.io/api/lineage/edges/1" \
-H "Authorization: Bearer YOUR_API_TOKEN"
Response (200): Same structure as Create an Edge.
Update an Edge¶
Updates the description and/or source_type of an existing edge. The source and target cannot be changed. Delete and recreate the edge if you need to rewire it.
The source_type of an edge its provider maintains (OpenLineage, a data catalog, or source collection) cannot be changed: the request returns 422. Relabeling it would take it out of its provider's reconciliation while its evidence still names that provider.
Endpoint: PUT /api/lineage/edges/{id}
Permission: Admin, or Member/Manager with Editor permission on every affected datastore.
Example request and response
Request:
curl -X PUT "https://your-instance.qualytics.io/api/lineage/edges/1" \
-H "Authorization: Bearer YOUR_API_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"description": "ETL pipeline v2",
"source_type": "catalog_import"
}'
Response (200): Returns the updated edge object.
Request Body:
| Field | Type | Required | Description |
|---|---|---|---|
description |
string | No | New transformation description. Omit the field to leave the current value unchanged. |
source_type |
string | No | Updated source type: manual, catalog_import, or inferred. |
Delete an Edge¶
Deletes a lineage edge. This does not affect the underlying containers or fields.
An OpenLineage edge is retired rather than deleted, so who asserted it stays auditable. It is returned with include_retired=true, and a later event that asserts it again revives it.
Endpoint: DELETE /api/lineage/edges/{id}
Permission: Admin, or Member/Manager with Editor permission on every affected datastore.
Example request
curl -X DELETE "https://your-instance.qualytics.io/api/lineage/edges/1" \
-H "Authorization: Bearer YOUR_API_TOKEN"
Response: 204 No Content
Get the Lineage Graph¶
Returns the lineage graph for a container, including all nodes and connections up to the specified depth.
Endpoint: GET /api/lineage/graph
Role: Member
Query Parameters:
| Parameter | Type | Required | Description |
|---|---|---|---|
container_id |
integer | Yes | The container to center the graph on. |
depth |
integer | No | How many hops to include (1–20). Defaults to 2. |
side |
string | No | Restrict direction: upstream, downstream, or omit for both. |
Example request and response
Request:
curl -X GET "https://your-instance.qualytics.io/api/lineage/graph?container_id=55&depth=2&side=upstream" \
-H "Authorization: Bearer YOUR_API_TOKEN"
Response:
{
"nodes": [
{
"node_type": "container",
"id": 42,
"name": "raw_orders",
"display_name": null,
"container_type": "table",
"table_type": "table",
"datastore": { "id": 10, "name": "Production DB", "store_type": "jdbc", "type": "snowflake", "enrichment_only": false },
"field_count": 12,
"linked_field_count": 3,
"is_target": false,
"active_anomaly_count": 0,
"purpose": null,
"has_upstream": false,
"has_downstream": false
},
{
"node_type": "container",
"id": 55,
"name": "orders_clean",
"display_name": null,
"container_type": "computed_table",
"table_type": null,
"datastore": { "id": 10, "name": "Production DB", "store_type": "jdbc", "type": "snowflake", "enrichment_only": false },
"field_count": 10,
"linked_field_count": 3,
"is_target": true,
"active_anomaly_count": 2,
"purpose": null,
"has_upstream": false,
"has_downstream": false
}
],
"edges": [
{
"id": 1,
"source_container_id": 42,
"source_field_id": null,
"target_container_id": 55,
"target_field_id": null,
"description": "Raw orders → cleaned orders",
"source_type": "manual",
"evidence": []
}
],
"external_nodes": [
{
"node_type": "external",
"id": "ext:snowflake:SALES.PUBLIC.ORDERS_STAGING",
"fqn": "SALES.PUBLIC.ORDERS_STAGING",
"source_type": "snowflake"
}
],
"external_edges": [
{
"id": 2,
"source_container_id": null,
"source_field_id": null,
"target_container_id": 42,
"target_field_id": null,
"source_node_id": "ext:snowflake:SALES.PUBLIC.ORDERS_STAGING",
"target_node_id": null,
"description": null,
"source_type": "snowflake",
"evidence": [
{
"provider": "snowflake",
"evidence_type": "structured_query_history",
"authorship": "provider",
"source_relation": "SNOWFLAKE.ACCOUNT_USAGE.ACCESS_HISTORY",
"observed_at": "2026-09-18T02:14:00.000000Z",
"first_observed_operation_id": 901,
"last_observed_operation_id": 944
}
]
}
]
}
Graph depth and flags
The has_upstream and has_downstream flags on each node are true only when that container has connections in that direction that are not already in the response. A true flag marks a node you can expand by querying it directly or with a larger depth. A false flag means every container it connects to in that direction is already in the response, not that every edge is. A one-sided query (side=upstream or side=downstream) leaves out edges between two returned containers that are not on the traversal path, so query without side to get those edges too.
Assets that are not registered in Qualytics
Lineage collected from a source datastore reaches assets Qualytics does not manage. Those never appear in nodes or edges, so a client that only reads those two arrays keeps working unchanged. They arrive in external_nodes and external_edges instead.
An external node's id is the string ext:<source_type>:<fqn>, and an external edge references it through source_node_id or target_node_id rather than a container id. Exactly one end of an external edge is external, and the other is a container.
List Container Fields¶
Returns a paginated list of fields for a container, including whether each field has any lineage connections.
Endpoint: GET /api/lineage/containers/{container_id}/fields
Role: Member
Query Parameters:
| Parameter | Type | Description |
|---|---|---|
name |
string | Filter fields by name (partial match). |
page |
integer | Page number (default: 1). |
size |
integer | Page size (default: 50). |
Example request and response
Request:
curl -X GET "https://your-instance.qualytics.io/api/lineage/containers/55/fields" \
-H "Authorization: Bearer YOUR_API_TOKEN"
Response:
{
"items": [
{
"id": 200,
"name": "email_upper",
"display_name": null,
"status": "active",
"latest_profile_id": 301,
"type": "string",
"is_computed_field": true,
"active_anomaly_count": 0,
"has_lineage": true
},
{
"id": 201,
"name": "order_date",
"display_name": null,
"status": "active",
"latest_profile_id": 302,
"type": "date",
"is_computed_field": false,
"active_anomaly_count": 1,
"has_lineage": false
}
],
"total": 10,
"page": 1,
"size": 50,
"pages": 1
}
Get Field Connections¶
Returns all fields connected to a given field through field-level connections, along with the edges between them. Optionally restrict results to fields within specific containers.
Endpoint: GET /api/lineage/fields/{field_id}/connections
Role: Member
Query Parameters:
| Parameter | Type | Description |
|---|---|---|
container_ids |
list of integers | Restrict results to fields within these containers. Can be repeated: ?container_ids=42&container_ids=55. |
Example request and response
Request:
curl -X GET "https://your-instance.qualytics.io/api/lineage/fields/100/connections" \
-H "Authorization: Bearer YOUR_API_TOKEN"
Response:
{
"fields": [
{
"id": 200,
"name": "email_upper",
"display_name": null,
"status": "active",
"latest_profile_id": 301,
"type": "string",
"container_id": 55
}
],
"edges": [
{
"id": 2,
"source_container_id": null,
"source_field_id": 100,
"target_container_id": null,
"target_field_id": 200,
"description": "UPPER(email)",
"source_type": "manual"
}
]
}
Send OpenLineage Events¶
Accepts OpenLineage events and applies them as lineage and dataset metadata. See OpenLineage Integration for how datasets are matched to containers, when connections are retired, and which metadata is applied.
Endpoints:
POST /api/lineage/openlineagePOST /api/v1/lineage, the path the OpenLineage clients send to by default. Both behave the same.
Permission: Admin, or Member/Manager with Editor permission on every affected datastore.
Editor is needed on the datastore of every container a connection touches, on both sides of a cross-datastore connection, including the connections an event retires, whether by reconciliation or by a DROP or RENAME. When several datastores match a dataset, the event applies only to matches the caller can edit. A known dataset with no writable match is denied, including tables not yet catalogued. An unknown dataset can remain an outside endpoint. New global tags still require Admin permission; an invalid tag or a denied tag creation rolls back only its event, and the rest of the batch continues.
Request body: one RunEvent, JobEvent, or DatasetEvent, or a JSON list of up to 1,000 of them. A body compressed with gzip is accepted when the request sets Content-Encoding: gzip. An event whose eventTime is more than five minutes in the future is rejected.
eventTime must be an RFC 3339 date-time string with a timezone offset or Z; numbers, numeric strings, date-only values and timestamps without a timezone are rejected. producer and schemaURL must be absolute URIs. Unknown facets and extra properties are retained without being applied to assets. This is permissive validation of the envelope and supported content, not full validation against every published facet schema. Schema URLs are never fetched. See Events Qualytics Reads.
An event can attempt at most 10,000 dataset and field relationships, including their writable datastore matches. The limit is cumulative across table-level and field-level relationships and also applies during replay. Exceeding it rejects the event with 422 and rolls back its changes.
Responses:
A single event answers with its own outcome:
| Status | When |
|---|---|
201 Created |
The event was applied, or was valid but had nothing to apply (ignored). |
403 Forbidden |
The event lacked Editor permission on an affected datastore. It applies no changes. The report is returned under detail. |
422 Unprocessable Entity |
The event was rejected, for example because it failed validation or its eventTime is in the future. The report is returned under detail. |
500 Internal Server Error |
The event could not be applied. The report is returned under detail. The OpenLineage clients retry a 5xx response. |
A JSON list answers 201 Created whatever its events' outcomes, with each event's status in the body. Its events are applied one at a time, so by the time one fails the others are already applied, and an error code would make a client send them all again. Check each event's status in the response.
Whatever the body:
| Status | When |
|---|---|
400 Bad Request |
Invalid JSON or gzip, a non-finite number, an unpaired Unicode surrogate, a \u0000 character, or JSON nesting deeper than 100 levels. The body is rejected before any event is applied or stored. |
413 Payload Too Large |
The body exceeds 50 MiB before or after decompression, or carries more than 1,000 events. |
The response reports each event: its position in the request, the ID it was stored under, its status (applied, ignored, rejected, or failed), any validation errors with their location in the event, and a summary of what changed. The event ID is an audit reference; there is no REST endpoint to retrieve the raw event or its unknown facets.
Example request and response
Request:
curl -X POST "https://your-instance.qualytics.io/api/v1/lineage" \
-H "Authorization: Bearer YOUR_API_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"eventType": "COMPLETE",
"eventTime": "2026-10-09T12:00:00Z",
"producer": "https://github.com/your-org/lineage-exporter",
"schemaURL": "https://openlineage.io/spec/2-0-2/OpenLineage.json#/$defs/RunEvent",
"run": { "runId": "3f1c9a2e-7b41-4c1e-9d55-0a2f6c1e8b90" },
"job": { "namespace": "nightly", "name": "load_orders" },
"inputs": [
{ "namespace": "postgres://warehouse.example.com:5432", "name": "analytics.public.raw_orders" }
],
"outputs": [
{ "namespace": "postgres://warehouse.example.com:5432", "name": "analytics.public.orders" }
]
}'
Response (201):
Python, with the OpenLineage client
The OpenLineage Python client sends to /api/v1/lineage by default, so it only needs your Qualytics URL and an API token. Install it with pip install openlineage-python.
from datetime import datetime, timezone
from uuid import uuid4
from openlineage.client import OpenLineageClient
from openlineage.client.event_v2 import InputDataset, Job, JobEvent, OutputDataset, Run, RunEvent, RunState
from openlineage.client.facet_v2 import column_lineage_dataset, documentation_dataset
from openlineage.client.transport.http import ApiKeyTokenProvider, HttpConfig, HttpTransport
client = OpenLineageClient(
transport=HttpTransport(
HttpConfig(
url="https://your-instance.qualytics.io",
auth=ApiKeyTokenProvider({"apiKey": "YOUR_API_TOKEN"}),
)
)
)
PRODUCER = "https://github.com/your-org/lineage-exporter"
WAREHOUSE = "postgres://warehouse.example.com:5432"
orders = OutputDataset(
namespace=WAREHOUSE,
name="analytics.public.orders",
facets={
"documentation": documentation_dataset.DocumentationDatasetFacet(description="One row per order"),
"columnLineage": column_lineage_dataset.ColumnLineageDatasetFacet(
fields={
"order_total": column_lineage_dataset.Fields(
inputFields=[
column_lineage_dataset.InputField(
namespace=WAREHOUSE,
name="analytics.public.raw_orders",
field="amount",
transformations=[
column_lineage_dataset.Transformation(type="DIRECT", subtype="AGGREGATION")
],
)
]
)
}
),
},
)
raw_orders = InputDataset(namespace=WAREHOUSE, name="analytics.public.raw_orders")
# Lineage from a run that just finished.
client.emit(
RunEvent(
eventType=RunState.COMPLETE,
eventTime=datetime.now(timezone.utc).isoformat(),
run=Run(runId=str(uuid4())),
job=Job(namespace="nightly", name="load_orders"),
producer=PRODUCER,
inputs=[raw_orders],
outputs=[orders],
)
)
# Or design-time lineage, from a system that describes jobs without running them.
client.emit(
JobEvent(
eventTime=datetime.now(timezone.utc).isoformat(),
job=Job(namespace="design", name="load_orders"),
producer=PRODUCER,
inputs=[raw_orders],
outputs=[orders],
)
)
The client raises an error when a request is rejected, and retries one that answers 5xx. A job is identified by its namespace and name, so a JobEvent replaces the job's previous design-time lineage whichever version of the producer sent it. For runtime lineage, a COMPLETE RunEvent reconciles the combined inputs and outputs reported throughout that run. START and RUNNING events add lineage without retiring earlier connections, and FAIL and ABORT retire what their run alone asserted. See Run States.
List a Container's Metadata Sources¶
Returns each source category's current value for a container's and its fields' metadata (description, tags, owners, and field types), with when each value last changed and which one is shown. See Which Value Is Shown.
Endpoint: GET /api/containers/{id}/metadata-assertions
Role: Member, with the Reporter team permission on the container's datastore.
| Field | Type | Description |
|---|---|---|
container_id |
integer | Set when the value belongs to the container. |
field_id |
integer | Set when the value belongs to one of its fields. |
attribute |
string | description, tags, owners, or type. |
source |
string | user, sync, openlineage, or the data catalog's name. |
provider |
string | The OpenLineage producer, the data catalog, or the ID of the user. |
value |
any | The source's value. |
asserted_at |
string | When the source's value last changed. |
observed_at |
string | When the source last reported it. |
details |
object | For OpenLineage, the stored event ID and the dataset. The job and run are left out; read them from the connection's evidence, which requires the Viewer team permission. |
effective |
boolean | Whether this is the value shown. |
Independent OpenLineage producers share one current assertion per asset and attribute; this endpoint does not return a separate competing value for every producer.
A description only Sync has ever written has no rows: Sync's column comment is recorded once a user, a data catalog, or OpenLineage also sets the description.
Example request and response
Request:
curl -X GET "https://your-instance.qualytics.io/api/containers/55/metadata-assertions" \
-H "Authorization: Bearer YOUR_API_TOKEN"
Response (200):
[
{
"container_id": 55,
"field_id": null,
"attribute": "description",
"source": "openlineage",
"provider": "https://github.com/your-org/lineage-exporter",
"value": "One row per order",
"asserted_at": "2026-10-09T12:00:00Z",
"observed_at": "2026-10-09T12:00:04Z",
"details": { "event_id": 812, "dataset": { "namespace": "postgres://warehouse.example.com:5432", "name": "analytics.public.orders" } },
"effective": true
}
]
Error Responses¶
| Status Code | Description |
|---|---|
401 Unauthorized |
Missing or invalid API token. |
403 Forbidden |
User lacks the required role or Editor permission on an affected datastore. A denied OpenLineage event applies no changes. In a list of events the request still answers 201, and the denial is reported in that event's status. |
404 Not Found |
Edge, container, or field with the specified ID does not exist. |
409 Conflict |
An edge with the same source, target, and source_type already exists. Uniqueness is scoped to source_type, so the same pair can carry one edge per origin. |
422 Unprocessable Entity |
Invalid request body (e.g., mixed-granularity edge, missing source/target), or a change to the source_type of an edge its provider maintains. |
Error response examples
404 Not Found:
409 Conflict:
422 Unprocessable Entity (mixed granularity):
Role Summary¶
| Operation | Minimum Role |
|---|---|
| List edges | Member |
| Get an edge | Member |
| Get the lineage graph | Member |
| List container fields | Member |
| Get field connections | Member |
| List a container's metadata sources | Member |
| Create an edge | Admin, or Member/Manager with Editor on every affected datastore |
| Update an edge | Admin, or Member/Manager with Editor on every affected datastore |
| Delete an edge | Admin, or Member/Manager with Editor on every affected datastore |
| Send OpenLineage events | Admin, or Member/Manager with Editor on every affected datastore |