XpectraFlow docs
Ingestion

HTTP append

Send batches of rows with curl. Good to a few hundred points a second.

The curl-driveable way in. Good for backfills, slow sensors, reprocessing jobs, and anything a person triggers.

Requires a dataset that already has storage — register a stream first. Full field reference on the datasets page.

A batch

POST/api/datasets/appendscope telemetry:write
curl -s -X POST https://app.xpectraflow.com/api/datasets/append \
  -H "x-api-key: $XPECTRA_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "experimentId": "'"$EXPERIMENT_ID"'",
    "datasetId": "'"$DATASET_ID"'",
    "channels": ["thrust", "chamber_pressure"],
    "rows": [
      { "time": "2026-07-31T12:00:00.000Z", "values": [1.50, 214.7] },
      { "time": "2026-07-31T12:00:00.010Z", "values": [1.52, 215.1] },
      { "time": "2026-07-31T12:00:00.020Z", "values": [1.49, null] }
    ]
  }'
{ "inserted": 3, "duplicates": 0, "rowCount": 1204998 }

Columnar, not row-objects: at fifty channels, repeating key names on every row is roughly ten times the payload for no added clarity.

  • channels accepts either the ch_N column or the channel's name.
  • values must be exactly as long as channels. null writes SQL NULL — a gap in one channel is not a gap in the row.
  • time is RFC 3339 or epoch milliseconds.
  • Limits: 4096 channels, 5000 rows per request.

Retrying is free

The storage primary key is (time, dataset_id), so a replayed batch re-inserts nothing:

{ "inserted": 0, "duplicates": 3, "rowCount": 1204998 }

That primary key is the idempotency mechanism. There is deliberately no Idempotency-Key header — two mechanisms for one job is how one of them comes to be forgotten.

Both numbers are reported honestly, so a half-landed batch tells you so.

Streaming from a script

import os, time, requests

BASE = "https://app.xpectraflow.com"
HEADERS = {"x-api-key": os.environ["XPECTRA_API_KEY"]}
BATCH = 500

def flush(rows):
    if not rows:
        return
    r = requests.post(f"{BASE}/api/datasets/append", headers=HEADERS, json={
        "experimentId": os.environ["XPECTRA_EXPERIMENT_ID"],
        "datasetId": os.environ["XPECTRA_DATASET_ID"],
        "channels": ["thrust", "chamber_pressure"],
        "rows": rows,
    }, timeout=30)
    r.raise_for_status()
    body = r.json()
    # Worth logging. A steady stream of duplicates means your timestamps are
    # repeating, which is silent data loss everywhere else.
    if body["duplicates"]:
        print(f"warning: {body['duplicates']} duplicate timestamps")

buffer = []
for sample in read_sensor():          # your loop
    buffer.append({"time": sample.t.isoformat(), "values": [sample.thrust, sample.pressure]})
    if len(buffer) >= BATCH:
        flush(buffer)
        buffer = []
flush(buffer)

Batch to a few hundred rows and flush on a timer as well as a count, so a slow sensor does not sit unflushed for minutes.

Four things that will bite you

When to stop using this

When you are flushing more than a few times a second. Each request is a round trip, a transaction, and a timestamp per row; the columnar frame formats send one base timestamp and a sample period for a whole frame.

Moving to gRPC does not change the data model — the same dataset, the same ch_N columns.

On this page