API conventions
The rules every endpoint follows, so the endpoint pages can be short.
Read this once and the rest of the reference gets much shorter. Every endpoint below obeys all of it.
Base URL and endpoints
https://app.xpectraflow.comThis is the whole HTTP surface. Each row links to the page that documents it in full.
Sustained ingestion does not go over HTTP — it goes over gRPC at
app.xpectraflow.com:50051, authenticated with the same key. append is for
batches, backfills and slow sensors, not for a 10 kHz feed. Anything you find
outside the table above is internal and may change without notice.
There is no /api/v1 prefix. An earlier draft of these docs described one; the
endpoints under it were never built, and versioning will be introduced when
there is a second version to distinguish from — not before.
Authentication
One header, everywhere, including gRPC metadata:
x-api-key: sk_xp_…See Authentication for creating and scoping a key.
Errors
Every failure has the same shape:
{
"error": {
"code": "insufficient_scope",
"message": "This API key lacks the datasets:write scope.",
"details": [],
"request_id": "31592257-1558-48ed-8328-a5086ccfa4ed"
}
}Branch on code. It is stable for a given condition. message is prose for a
human reading logs and may be reworded.
| Status | code | Means |
|---|---|---|
| 400 | invalid_request | Malformed body or parameters; see details |
| 401 | unauthorized | Missing, malformed, expired or revoked key |
| 403 | insufficient_scope | Authenticated, but the key lacks the scope |
| 403 | forbidden | Authenticated and scoped, but not allowed |
| 404 | not_found | No such resource — or it is not yours |
| 409 | conflict | Name already taken, or a state conflict |
| 413 | payload_too_large | Body exceeded the limit |
| 500 | internal_error | Our fault. request_id is what to quote |
On validation failures details carries per-field messages:
"details": [{ "field": "channels.0.name", "message": "String must contain at least 1 character(s)" }]404 deliberately conflates "does not exist" with "exists but belongs to another
organization". Distinguishing them would confirm that an id is real, which is the
one thing worth withholding from someone guessing ids.
Request ids
Send X-Request-Id and it is echoed back and used in our logs. Send nothing and
one is generated. Either way it appears on the response — on success as well as
failure — and inside every error body. Quote it when reporting a problem.
curl -si https://app.xpectraflow.com/api/experiments/list \
-H "x-api-key: $XPECTRA_API_KEY" \
-H "X-Request-Id: my-job-42"Types
| Casing | camelCase in JSON bodies and query parameters |
| Timestamps | RFC 3339, UTC, milliseconds — 2026-07-30T12:00:00.000Z |
| Ids | UUID v4 strings |
| Content type | application/json |
The protobuf definitions use snake_case and epoch nanoseconds. That boundary is
the gRPC wire format only; nothing in the HTTP API uses it.
Verbs
GET | Read, with query parameters. Never changes anything |
POST | Create or change. Returns 200 and the resulting resource |
PATCH is not offered, and neither is pagination: the list endpoints return
everything they match, ordered newest first. Both will arrive with a documented
shape rather than appearing quietly.
Nothing here deletes anything
There is no endpoint that deletes an experiment, a dataset or a channel, and this is not an omission waiting to be filled.
An API key is a static string that lives in a config file on a machine you may
not fully control. Deleting an experiment cascades to its datasets and their
telemetry, so a single leaked producer credential would be enough to erase a
test campaign — and the scope that credential needs for its actual job,
telemetry:write, is one a producer legitimately holds.
The demand for scripted deletion is close to zero next to that. Delete from the console, where a human is present and the action is attributable to them.
The one exception is /api/webhooks/delete, which unregisters a delivery
target. That destroys no data, and being unable to stop deliveries over the API
would make the feature less safe rather than more.
Updates replace, they do not merge
POST /api/experiments/update and POST /api/datasets/update are full
replacements of the mutable fields. Omitting description clears it; omitting a
required field is a 400.
This is stated rather than smoothed over because the alternative — accepting a partial body and merging — makes it possible to erase a field by forgetting it, and a validation error is a much better outcome than a silently blanked description.