# Airbyte — API: Every Verb, Every Role: The Airbyte HTTP Surface

One sentence in the controller sets the shape of everything below it: the vendor integration surface is **read-authority**. Import writes to the graph, and only additively. Audit and generate never write anything. Recording an audit run in the history is opt-in bookkeeping - except for re-audit, which always records its run because that is the whole point of the verb.

# Every Verb, Every Role: The Airbyte HTTP Surface

One sentence in the controller sets the shape of everything below it: the vendor integration surface is **read-authority**. Import writes to the graph, and only additively. Audit and generate never write anything. Recording an audit run in the history is opt-in bookkeeping - except for re-audit, which always records its run because that is the whole point of the verb.

Read the route table with that in mind and the role column stops looking arbitrary. This article is the HTTP reference for governing an Airbyte estate: exact routes, exact request bodies, exact response fields, exact error strings - including the one verb this connector refuses. (The newer sync-plan routes appear in the table for completeness; their workflow is documented separately.)

Placeholders: `https://coremodels.example.com` is the API base URL, `$TOKEN` is your credential, `{PROJECT_ID}` is the 32-character hex project id.

## Two surfaces

| Surface | Base | Auth | Verbs for `airbyte` |
|---|---|---|---|
| Interactive | `graph/integrations/...` | your CoreModels login token | vendors · import · audit · reaudit · history · badge · status · generate · reconcile · sync |
| Machine-to-machine | `v1/...` | user API keys | audit · badge |

The `v1` surface exists so automation never has to hold an interactive login token. It carries `audit` and `badge` only; `reaudit` and `history` live on the interactive surface.

| Route | Method | Role |
|---|---|---|
| `graph/integrations/vendors` | GET | any authenticated user |
| `graph/integrations/airbyte/import/{PROJECT_ID}` | POST | Admin |
| `graph/integrations/airbyte/audit/{PROJECT_ID}` | POST | Viewer |
| `graph/integrations/airbyte/reaudit/{PROJECT_ID}` | POST | Viewer |
| `graph/integrations/airbyte/history/{PROJECT_ID}` | GET | Viewer |
| `graph/integrations/airbyte/badge/{PROJECT_ID}` | GET | Viewer |
| `graph/integrations/airbyte/status/{PROJECT_ID}` | GET | Viewer |
| `graph/integrations/airbyte/generate/{PROJECT_ID}` | POST | Viewer (refused - see below) |
| `graph/integrations/reconcile/{PROJECT_ID}` | POST | Admin |
| `graph/integrations/airbyte/sync/propose/{PROJECT_ID}` | POST | Viewer |
| `graph/integrations/sync/plan/{PROJECT_ID}/{PLAN_ID}` | GET | Viewer |
| `graph/integrations/sync/ledger/{PROJECT_ID}` | GET | Viewer |
| `v1/{PROJECT_ID}/integrations/airbyte/audit` | POST | Viewer |
| `v1/{PROJECT_ID}/integrations/airbyte/badge` | GET | Viewer |

The `{vendor}` path segment resolves through the connector registry case-insensitively, so `airbyte`, `Airbyte` and `AIRBYTE` all reach the same connector. The three sync-plan routes propose, fetch and list reviewable sync plans - `sync/propose` takes the same artifacts body as import and audit, and its only writes are bookkeeping (the stored plan and its ledger entry); that workflow has its own article.

## The three request bodies

Import and audit share one body shape:

```json
{
  "artifacts": { "catalog": "<raw catalog.json content as a JSON string>" },
  "spaces": [],
  "recordHistory": false
}
```

- `artifacts` (required) - artifact name to raw content. Airbyte has exactly one name: `catalog`.
- `spaces` (optional) - target space ids; empty or omitted means the project's main space.
- `recordHistory` (audit only, default `false`) - append this run to the rolling audit trail.

Re-audit takes no artifacts:

```json
{ "projectName": null, "spaces": null, "notifySlack": false }
```

`notifySlack` (default `false`) additionally posts the recorded run to the project's configured Slack webhook when it carries errors or warnings. `projectName` selects which stored snapshot to re-audit; `null` means the most recent one. For Airbyte the stored estate is named `airbyte-connection` - a catalog carries no project name of its own, so the connector uses a stable estate name.

Reconciliation takes two vendor keys:

```json
{ "vendorA": "airbyte", "vendorB": "snowflake", "spaces": null }
```

## Discovery

```bash
curl -sS "https://coremodels.example.com/graph/integrations/vendors" \
  -H "Authorization: Bearer $TOKEN" | jq '.vendors[] | select(.key == "airbyte")'
```

```json
{
  "key": "airbyte",
  "displayName": "Airbyte",
  "capabilities": "Import, Audit",
  "artifacts": {
    "catalog": "required - an AirbyteCatalog (source discover output) or ConfiguredAirbyteCatalog (connection export) JSON"
  }
}
```

`capabilities` is a flags string rendered from what the connector declares. Read it before you write a client: it is the contract that decides which of the routes above will actually do work.

## Import (Admin)

```http
POST /graph/integrations/airbyte/import/{PROJECT_ID}
Authorization: Bearer $TOKEN
Content-Type: application/json

{ "artifacts": { "catalog": "<catalog.json>" } }
```

The response is a counter set plus two honesty channels:

| Field | Meaning |
|---|---|
| `datasetsAdded` | streams written as new governed Types |
| `datasetsSkippedExisting` | streams already governed - left untouched |
| `fieldsAdded` | properties added to *already-governed* streams (the additive re-import pass) |
| `lineageEdgesAdded` / `lineageEdgesSkipped` | lineage relations written / skipped |
| `nodesEnriched` | nodes whose Airbyte metadata was written or refreshed |
| `snapshotStored` | whether the parsed estate was persisted for artifact-free re-audit |
| `lossiness` | what was approximated, each as `{kind, path, explanation}` |
| `errors` | non-empty only when the import could not proceed |

Import is additive by design: already-governed streams are never mutated or deleted here. Changes to governed meaning surface through the audit and are applied by a human. The one exception is stated plainly in the layer's own posture: vendor-metadata mixin values *are* refreshed on every import, because they mirror the estate rather than carry governed meaning.

## Audit (Viewer)

```http
POST /graph/integrations/airbyte/audit/{PROJECT_ID}
Authorization: Bearer $TOKEN
Content-Type: application/json

{ "artifacts": { "catalog": "<catalog.json>" }, "recordHistory": true }
```

```jsonc
{
  "success": true,
  "vendor": "airbyte",
  "projectName": "airbyte-connection",
  "errorCount": 1,             // > 0 ⇒ the change violates governed meaning
  "warningCount": 1,
  "infoCount": 2,
  "codes": { "field-type-drift": 1, "stream-no-primary-key": 1, "no-cursor-field": 1, "untyped-fields": 1 },
  "driftedObjects": ["public.users.created_at"],
  "fingerprint": "9f2c41ab7d0e5b83",
  "metrics": {
    "datasets (estate)": "3",
    "datasets governed": "3 / 3",
    "fields governed": "11 / 11",
    "governed nodes with canonical mappings": "0 / 15 (0%)",
    "last import": "2026-08-04T09:12:44.1183920+00:00"
  },
  "findings": [
    {
      "section": "Drift",
      "severity": "Error",
      "code": "field-type-drift",
      "subject": "public.users.created_at",
      "message": "Field type changed since the last import.",
      "detail": "governed: timestamp_with_timezone, estate: string"
    }
  ],
  "markdown": "# airbyte Schema Audit - airbyte-connection\n\n🔴 **1 errors · 1 warnings · 2 info**\n…",
  "historyRecorded": true,
  "lossiness": []
}
```

Findings are grouped into three sections with a fixed code vocabulary:

- **Coverage** - `dataset-unmapped` (Warning), `field-unmapped` (Info), `projection-unmapped` (Info).
- **Drift** - `dataset-removed`, `field-removed`, `field-type-drift`, `enum-narrowed` (all Error), `enum-constraint-removed`, `enum-widened` (Warning), `contract-drift`.
- **Conformance** - the connector's own rules. For Airbyte: `stream-no-primary-key` (Warning), `no-cursor-field` (Info), `untyped-fields` (Info).

`driftedObjects` is the distinct subject list of the Drift section - the machine-readable answer to "what exactly moved". `fingerprint` is a content hash of the audited artifact, so two runs with the same fingerprint audited the same catalog.

## Re-audit (Viewer)

Audit asks whether fresh artifacts still conform to the governed model. Re-audit asks the mirror question: does the governed model still match the last-known estate? It runs the identical audit engine over the snapshot stored at import time against the **current** governed view - no artifacts, no vendor calls.

```bash
curl -sS -X POST \
  "https://coremodels.example.com/graph/integrations/airbyte/reaudit/$PROJECT_ID" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{}' | jq '{errorCount, warningCount, codes, historyRecorded}'
```

The response shape is identical to audit, with `historyRecorded: true` always - this verb records its run with trigger `reaudit`. Additional metric: `snapshot stored`, the timestamp of the snapshot being re-audited.

Without a stored snapshot you get a plain error instead:

```json
{
  "success": false,
  "payload": null,
  "lossiness": [],
  "errors": [
    {
      "path": "snapshot",
      "message": "No stored estate snapshot for vendor 'airbyte' - import the vendor project first (imports persist the parsed snapshot)."
    }
  ]
}
```

## History, badge, status (Viewer)

```json
{
  "success": true,
  "vendor": "airbyte",
  "projects": [
    {
      "projectName": "airbyte-connection",
      "runs": [
        {
          "at": "2026-08-04T09:31:07.4410000+00:00",
          "trigger": "ci",
          "errorCount": 0,
          "warningCount": 1,
          "infoCount": 2,
          "codes": { "stream-no-primary-key": 1, "no-cursor-field": 1, "untyped-fields": 1 },
          "fingerprint": "9f2c41ab7d0e5b83"
        }
      ]
    }
  ]
}
```

Runs are newest-first, one trail per vendor and estate, capped to the most recent fifty. The `trigger` vocabulary is `audit`, `ci`, `reaudit`, `scheduled`.

The badge route returns `image/svg+xml` - a shields-style badge labeled `airbyte audit`, colored from the latest recorded run: `#4c1` clean, `#dfb317` warnings only, `#e05d44` with the error count, `#9f9f9f` when nothing has been recorded. An unknown vendor segment renders a gray badge rather than an error page, so a badge URL in a README is always safe.

Status reports the last import:

```jsonc
{
  "success": true,
  "vendor": "airbyte",
  "imported": true,
  "state": {
    "vendor": "airbyte",
    "projectName": "airbyte-connection",
    "importedAt": "2026-08-04T09:12:44.1183920+00:00",
    "toolVersion": null,          // a catalog declares no tool version
    "artifactVersion": null,
    "generatedAt": null,
    "sourceFingerprint": "9f2c41ab7d0e5b83",
    "counts": "fieldsAdded=0, lineageAdded=0, lineageSkipped=0, nodesEnriched=14",
    "facts": "{\"streams\":\"3\",\"fields\":\"11\"}"
  },
  "governedDatasets": 3
}
```

`imported: false` with a null `state` means nothing has been imported yet. `governedDatasets` counts vendor identities that currently resolve to a governed Type.

## Cross-estate reconciliation (Admin)

When a second connector governs the same physical relations - the warehouse tables your Airbyte streams land in - reconciliation links the pairs as one governed entity with reciprocal `sameAs` assertions, dataset-level and field-level, idempotently:

```bash
curl -sS -X POST \
  "https://coremodels.example.com/graph/integrations/reconcile/$PROJECT_ID" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"vendorA": "airbyte", "vendorB": "snowflake"}' \
  | jq '{linksWritten, pairs, unmatchedA, unmatchedB}'
```

Matching is on the recorded physical relation name, normalized by stripping quotes, trimming and upper-casing - an exact string comparison, not a fuzzy one. An Airbyte stream's physical name is its namespace-qualified stream name, so pairs only match where that string equals the counterpart's recorded relation name. Everything unmatched is returned in `unmatchedA` / `unmatchedB` rather than guessed at.

## The `v1` machine surface

```http
POST /v1/{PROJECT_ID}/integrations/airbyte/audit
Authorization: Bearer $TOKEN
Content-Type: application/json

{ "artifacts": { "catalog": "<catalog.json>" }, "recordHistory": true }
```

Same body, same fields - wrapped in the standard API envelope:

```json
{
  "success": true,
  "error": null,
  "data": { "vendor": "airbyte", "errorCount": 0, "warningCount": 1, "codes": { "stream-no-primary-key": 1 }, "markdown": "…" }
}
```

So automation gates on `data.errorCount` and prints `data.markdown`. With `recordHistory: true` the run is recorded with trigger `ci`. A failure comes back in the same envelope:

```json
{ "success": false, "error": { "isFatal": false, "message": "…" }, "data": null }
```

## The error catalog

Predictable strings you can assert against:

| Situation | Message |
|---|---|
| Unknown vendor segment | `Unknown vendor '<v>'. Registered: <comma-joined keys>.` |
| Missing or empty `artifacts` | `Body must include 'artifacts': { "<name>": "<content>" } (e.g. manifest for dbt).` |
| Wrong artifact name | `The 'catalog' artifact ((Configured)AirbyteCatalog JSON) is required.` |
| Body is not JSON | `Not valid JSON: <parser detail>` |
| JSON without streams | `Expected {"streams": [...]} with at least one stream.` |
| Streams present, none named | `No named streams found in the catalog.` |
| Right shape, wrong internals | `The catalog is valid JSON but not shaped like an Airbyte catalog: <detail>` |

## Generate: the refusal, in full

The generate route exists for every vendor segment. For Airbyte it returns:

```json
{
  "success": false,
  "payload": null,
  "lossiness": [],
  "errors": [
    { "path": "capabilities", "message": "Connector 'airbyte' does not support generation." }
  ]
}
```

This is a design decision, not a missing feature. An Airbyte catalog states what a source exposes; generating one from the governed model would assert authorship over a system we do not control. The connector declares `Import | Audit`, the service refuses anything else at the capability gate, and nothing is silently emitted - the same contract that makes `lossiness`, `snapshotStored` and `errorCount` worth trusting.

For worked end-to-end calls against a real catalog, see the Airbyte quickstart in the CoreModels integration docs.
