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 haverule.id: nulland the check name inrule.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.