Skip to content

REST API v1 reference

The v1 API under /api/v1/ is the supported surface for external integrations. It is designed around cheap polling, stable identifiers and one wire format per concept. Everything on this page is covered by the compatibility rules: additions are non-breaking, and your client must ignore fields it does not know.

Authentication is unchanged from the rest of the platform: send an API key in the X-API-Key header (or a JWT bearer token). All resources are scoped to your organization.

Conventions

Envelope. Successful responses are {"data": …}; paginated collections add "meta": {"total", "limit", "offset"}. There are no success flags.

Errors. Every error is application/problem+json (RFC 9457):

{
  "type": "https://expecto.dev/problems/run-already-active",
  "title": "Conflict",
  "status": 409,
  "detail": "Another run is already active for this session",
  "code": "RUN_ALREADY_ACTIVE",
  "active_run_id": "…"
}

Switch on the type slug (or the code extension) — both are stable. Requests that a resource's current state does not allow are 409 invalid-state; validation problems are 422 validation-error with an errors[] array naming the offending fields.

Pagination. ?limit= (default 100, max 1000) and ?offset=, with a stable documented sort order per collection.

Idempotency. POST /v1/sessions and POST /v1/sessions/{sid}/runs accept an Idempotency-Key header. Retrying with the same key and body returns the first result instead of creating a duplicate; the same key with a different body is a 409 idempotency-key-reuse. Keys expire after 24 hours.

Concurrent session reads. Reading a session loads its whole context, so an API key may have at most 4 such reads in flight at once: GET /v1/sessions/{sid}, its context and files subresources, and the legacy GET /api/sessions endpoints. A request over the cap waits up to 15 seconds for a free slot and then returns 429 with Retry-After. Fan out no wider than the cap — a client that issues one request per session of a listing page will be throttled. Interactive users signed in to the web app are not affected.

The resource model

Session  (thin container of one delivery)
└── Run  (one pipeline execution — the central resource)
    ├── report     light validation summary
    └── findings   unified violations of all stages

The minimal integration is five calls:

POST /v1/sessions                 → create the delivery
PUT  /v1/sessions/{sid}/files/{role}   × one per file
POST /v1/sessions/{sid}/runs      → execute the pipeline
     (webhook or cheap polling)
GET  /v1/sessions/{sid}/runs/latest/report
GET  /v1/sessions/{sid}/runs/latest/findings   — when the report shows failures

Sessions

POST /v1/sessions

{
  "data_contract": {"external_id": "FIBU-STD-01"},
  "data_provider": {"external_id": "PM-1162"},
  "period": "2026-07",
  "context": {}
}

data_contract and data_provider take either {"id": "<uuid>"} or {"external_id": "…"} — external IDs are yours, unique per organization, and remove the need to store UUID mappings on your side. Optional: data_contract.revision, deadline, supplier_id. Returns 201 with the thin session and a Location header.

GET /v1/sessions/{sid} — the polling target

Returns ~1 KB: identifiers, status, health, period, latest_run summary and links to the subresources. Nothing heavy is embedded.

The response carries an ETag. Poll with If-None-Match: <etag> and an unchanged session answers 304 with an empty body — polling every few seconds is fine.

{
  "data": {
    "id": "…", "status": "checked", "health": "action_required",
    "period": "2026-07",
    "latest_run": {"id": "…", "seq": 3, "status": "errors"},
    "links": {
      "context": "/api/v1/sessions/…/context",
      "files": "/api/v1/sessions/…/files",
      "runs": "/api/v1/sessions/…/runs",
      "latest_run": "/api/v1/sessions/…/runs/latest"
    }
  }
}

Subresources

Endpoint Purpose
GET /v1/sessions/{sid}/context The session context (including context tables). This is the only place tables are served: a session listing returns scalar context values only.
PATCH /v1/sessions/{sid}/context Replace the context: {"context": {…}}.
GET /v1/sessions/{sid}/files Per-role upload and parse state + missing_required_roles.
PUT /v1/sessions/{sid}/files/{role} Upload and replace the file for one source role — a single idempotent call, no separate delete. Unknown role → 404. Multipart body, field file.

POST /v1/sessions/{sid}/decision

One resource for the human decision on a delivery:

{"action": "approve", "comment": "Salden geprüft"}

action is confirm, approve or reject. comment is required for reject (it is the rejection reason) and doubles as the override reason when approving a session that still has blocking violations. A decision the current status does not allow is a 409 invalid-state problem.

Runs

POST /v1/sessions/{sid}/runs

Executing the pipeline creates a Run:

{"stages": ["parse"], "source": "salesforce"}

Both fields are optional. Omit stages for the full pipeline from the session's current stage (parse first — the technical result arrives within seconds, the content result later); ["parse"] alone stops after parse. source is a free-form origin label recorded on the run.

Returns 202 with Location: /v1/sessions/{sid}/runs/{rid} and the run body. While a run is active a second POST answers 409 run-already-active — retry with the same Idempotency-Key to get the original 202 replayed instead.

If the project's automatic context fetch is still running when the run is requested, the run is accepted (202, status pending) and parse starts right away — session.parsed or session.failed (stage = parse) arrives within seconds. The run then waits before transform: status waiting, waiting_for: "context_fetch", executed_stages: ["parse"]. It continues by itself when the fetch completes, under the same run id; a failed fetch or a wait longer than the server's RUN_WAIT_TIMEOUT_SECONDS (default 30 min, counted from the run's creation) closes it as errors and emits session.failed (reason context_fetch_failed / context_fetch_timeout). A second POST while it waits is 409 run-already-active with the waiting run's id.

GET /v1/sessions/{sid}/runs and GET …/runs/{rid}

History (newest first, paginated) and the run resource: one status verdict (pending / waiting / running / passed / warnings / errors; the first three are not final), waiting_for (context_fetch while the run is deferred, else null), requested_stages vs executed_stages, per-stage outcomes and timings in stages, trigger {channel, source}, error + error_class when the run failed outside the data.

The literal latest is accepted wherever {rid} appears — after a webhook you can read /runs/latest/report without resolving a run id.

Results

GET /v1/sessions/{sid}/runs/{rid}/report — "file 1"

The light validation summary, available once the run is finished (409 run-not-finished before that): counters plus one entry per evaluated rule:

{
  "data": {
    "session_id": "…", "run_id": "…", "run_status": "errors",
    "counters": {"rules_checked": 12, "rules_passed": 9,
                 "rules_failed": 2, "rules_not_applicable": 1,
                 "total_findings": 117},
    "parse": [{"role": "buchungen", "status": "parsed",
               "errors": 2, "warnings": 1, "infos": 0}],
    "rules": [
      {"rule_id": "8ec5e7d9-…", "name": "BR-3 Balance Reconciliation",
       "stage": "check", "outcome": "failed", "level": "error",
       "error_class": "content", "total_findings": 110,
       "message": "Pro Konto und Objekt: …"}
    ]
  }
}

No sample rows by default — add ?include=samples if you want them. session_id and run_id are always in the body.

GET /v1/sessions/{sid}/runs/{rid}/findings

One record format for the violations of all stages:

{
  "finding_id": "1f0c5a…",
  "stage": "check",
  "rule": {"id": "8ec5e7d9-…", "name": "BR-3 Balance Reconciliation"},
  "severity": "error",
  "class": "content",
  "message": "Pro Konto und Objekt: …",
  "location": {"role": "buchungen", "row": 17, "column": "Saldo"},
  "data": {"sap_account": "0000300080", "balance_current": "1000.00"}
}
  • stage — parse (file-format issues), validate (entity rules), check (project business rules).
  • rule.id — the stable UUID; filter with ?rule_id=. Rule names are display strings, never identifiers. Parse checks have rule.id: null and the check name in rule.name.
  • data — exactly the columns the rule's SELECT produced.
  • Filters: ?stage=, ?rule_id=, ?severity=error|warning|info, ?class=format|content|system; standard pagination; stable sort (stage, rule, row, finding_id).

Progressive availability: findings of a completed stage are readable while the run is still executing later stages — request ?stage=parse right after the session.parsed webhook, seconds into the run.

GET …/findings/summary returns aggregated counts per stage and rule without individual records.

CSV

Send Accept: text/csv to /report or /findings and the same data streams back as CSV in one synchronous request, with session_id and run_id as columns in every row. A single-rule extract (?rule_id=…) flattens the rule's own columns — the "file per rule" shape. Above the row cap the request answers with a problem detail pointing at the async export job instead of a truncated file.

Webhooks or polling?

Both are supported and cheap: webhook events (see the webhooks reference) carry data.run_id, and the thin session + ETag makes short-interval polling harmless. Pick whichever fits your runtime.