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
/api/datasets/appendscope telemetry:writecurl -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.
channelsaccepts either thech_Ncolumn or the channel's name.valuesmust be exactly as long aschannels.nullwrites SQL NULL — a gap in one channel is not a gap in the row.timeis 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.